364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
GraduationCap,
|
||
Trophy,
|
||
Sparkles,
|
||
RefreshCw,
|
||
Play,
|
||
Activity,
|
||
Eye,
|
||
Heart,
|
||
MessageSquare,
|
||
Users,
|
||
AlertCircle,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
TrendingUp,
|
||
} from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import { LazyImage } from "@/components/ui/LazyImage";
|
||
|
||
interface SourceMetrics {
|
||
replyCount: number;
|
||
likeCount: number;
|
||
viewCount: number;
|
||
humanReplies: number;
|
||
engagementScore: number;
|
||
}
|
||
|
||
interface Learning {
|
||
id: number;
|
||
botId: number;
|
||
botUserId: number | null;
|
||
botName: string;
|
||
botAvatar: string | null;
|
||
weekKey: string;
|
||
sourceRefType: string;
|
||
sourceRefId: number;
|
||
sourceTitle: string | null;
|
||
sourceUserName: string | null;
|
||
sourceUserAvatar: string | null;
|
||
sourceMetrics: SourceMetrics;
|
||
relevanceScore: number;
|
||
learnedInsight: string;
|
||
learnCategories: string[];
|
||
status: string;
|
||
usedCount: number;
|
||
learnedAt: string;
|
||
expiresAt: string | null;
|
||
forumSlug: string | null;
|
||
categoryName: string | null;
|
||
}
|
||
|
||
interface Summary {
|
||
totalLearnings: number;
|
||
activeCount: number;
|
||
usedCount: number;
|
||
avgRelevance: number;
|
||
botCount: number;
|
||
weeks: number;
|
||
}
|
||
|
||
const RANGE_OPTIONS = [
|
||
{ value: 1, label: "近 1 周" },
|
||
{ value: 2, label: "近 2 周" },
|
||
{ value: 4, label: "近 4 周" },
|
||
{ value: 8, label: "近 8 周" },
|
||
];
|
||
|
||
const CATEGORY_META: Record<string, { label: string; color: string; bg: string }> = {
|
||
hook: { label: "钩子", color: "text-violet-700", bg: "bg-violet-50" },
|
||
story: { label: "故事", color: "text-amber-700", bg: "bg-amber-50" },
|
||
data: { label: "数据", color: "text-sky-700", bg: "bg-sky-50" },
|
||
question: { label: "提问", color: "text-emerald-700", bg: "bg-emerald-50" },
|
||
contrarian: { label: "反主流", color: "text-rose-700", bg: "bg-rose-50" },
|
||
empathy: { label: "共情", color: "text-pink-700", bg: "bg-pink-50" },
|
||
practical: { label: "实用", color: "text-slate-700", bg: "bg-slate-100" },
|
||
};
|
||
|
||
const STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
|
||
active: { label: "生效中", color: "text-emerald-700", bg: "bg-emerald-50" },
|
||
used: { label: "已用", color: "text-sky-700", bg: "bg-sky-50" },
|
||
expired: { label: "已过期", color: "text-slate-500", bg: "bg-slate-50" },
|
||
};
|
||
|
||
export default function BotAdversarialLearningClient() {
|
||
const [weeks, setWeeks] = useState(4);
|
||
const [data, setData] = useState<{ summary: Summary; learnings: Learning[] } | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [running, setRunning] = useState(false);
|
||
const [runResult, setRunResult] = useState<string | null>(null);
|
||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(`/api/admin/bots/adversarial-learning?weeks=${weeks}`, { cache: "no-store" });
|
||
const json = await res.json();
|
||
if (!res.ok) throw new Error(json.error || "请求失败");
|
||
setData(json);
|
||
} catch (e: unknown) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [weeks]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
const triggerRun = useCallback(
|
||
async (mode: "week" | "force") => {
|
||
setRunning(true);
|
||
setRunResult(null);
|
||
try {
|
||
const res = await fetch("/api/admin/bots/adversarial-learning/run", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ lookback: 7, force: mode === "force" }),
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok) throw new Error(json.error || "运行失败");
|
||
setRunResult(`✓ exitCode=${json.exitCode} duration=${json.duration}ms`);
|
||
await fetchData();
|
||
} catch (e: unknown) {
|
||
setRunResult(`✗ ${(e as Error).message}`);
|
||
} finally {
|
||
setRunning(false);
|
||
}
|
||
},
|
||
[fetchData]
|
||
);
|
||
|
||
const toggle = useCallback((id: number) => {
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const groupedByWeek = useMemo(() => {
|
||
const map = new Map<string, Learning[]>();
|
||
if (!data) return map;
|
||
for (const l of data.learnings) {
|
||
if (!map.has(l.weekKey)) map.set(l.weekKey, []);
|
||
map.get(l.weekKey)!.push(l);
|
||
}
|
||
return map;
|
||
}, [data]);
|
||
|
||
return (
|
||
<div className="p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl lg:text-3xl font-bold flex items-center gap-2">
|
||
<GraduationCap className="w-6 h-6 text-violet-600" />
|
||
Bot 对抗学习看板
|
||
</h1>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
每周日 23:00 自动给每个 bot 选 1 篇过去 7 天真人发布的高互动帖,LLM 提取"为什么火"的洞察,下周 bot 发帖/回复时作为参考
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<select
|
||
value={weeks}
|
||
onChange={(e) => setWeeks(Number(e.target.value))}
|
||
className="px-3 py-2 text-sm rounded-md border bg-background"
|
||
>
|
||
{RANGE_OPTIONS.map((o) => (
|
||
<option key={o.value} value={o.value}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
onClick={() => triggerRun("week")}
|
||
disabled={running}
|
||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:bg-slate-50 disabled:opacity-50"
|
||
>
|
||
<Play className={cn("w-3.5 h-3.5", running && "animate-pulse")} />
|
||
本周学习
|
||
</button>
|
||
<button
|
||
onClick={() => triggerRun("force")}
|
||
disabled={running}
|
||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
|
||
>
|
||
<Sparkles className="w-3.5 h-3.5" />
|
||
强制重学
|
||
</button>
|
||
<button
|
||
onClick={fetchData}
|
||
disabled={loading}
|
||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:bg-slate-50 disabled:opacity-50"
|
||
>
|
||
<RefreshCw className={cn("w-3.5 h-3.5", loading && "animate-spin")} />
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{runResult && (
|
||
<div
|
||
className={cn(
|
||
"p-3 rounded-md text-sm font-mono",
|
||
runResult.startsWith("✓")
|
||
? "bg-emerald-50 text-emerald-700"
|
||
: "bg-rose-50 text-rose-700"
|
||
)}
|
||
>
|
||
{runResult}
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="p-3 rounded-md bg-rose-50 text-rose-700 text-sm flex items-center gap-2">
|
||
<AlertCircle className="w-4 h-4" /> {error}
|
||
</div>
|
||
)}
|
||
|
||
{data && (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||
<SummaryCard icon={<GraduationCap className="w-4 h-4" />} label="学习总数" value={data.summary.totalLearnings} accent="text-violet-600" />
|
||
<SummaryCard icon={<Activity className="w-4 h-4" />} label="生效中" value={data.summary.activeCount} accent="text-emerald-600" />
|
||
<SummaryCard icon={<Trophy className="w-4 h-4" />} label="引用次数" value={data.summary.usedCount} accent="text-amber-600" />
|
||
<SummaryCard icon={<TrendingUp className="w-4 h-4" />} label="平均关联度" value={data.summary.avgRelevance.toFixed(2)} accent="text-rose-600" />
|
||
<SummaryCard icon={<Users className="w-4 h-4" />} label="参与 bot" value={data.summary.botCount} accent="text-sky-600" />
|
||
</div>
|
||
)}
|
||
|
||
<div className="p-3 bg-violet-50/40 rounded-md text-xs text-slate-600 flex flex-wrap items-center gap-3">
|
||
<div><b>学习源</b>:过去 7 天 真人(非 bot)发布的高互动 topic(reply+like+humanReplies 排序)</div>
|
||
<div>·</div>
|
||
<div><b>关联度</b> = 同板块(1.0) + 关键词重叠(0.6) + 时效衰减(0.3)</div>
|
||
<div>·</div>
|
||
<div><b>每周 1 条</b>,unique(botId, weekKey),默认 7 天后过期</div>
|
||
</div>
|
||
|
||
{/* 按周分组 */}
|
||
{data && data.learnings.length === 0 && (
|
||
<div className="p-8 text-center text-muted-foreground text-sm border rounded-lg bg-slate-50/40">
|
||
还没有对抗学习数据。运行 <code className="px-1 bg-slate-100 rounded">node scripts/bot-adversarial-learning-run.mjs</code> 开始第一周,或点击"本周学习"按钮。
|
||
</div>
|
||
)}
|
||
|
||
{Array.from(groupedByWeek.entries()).map(([weekKey, items]) => (
|
||
<div key={weekKey} className="space-y-3">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-violet-100 text-violet-700 text-sm font-bold">
|
||
{weekKey.split("-W")[1]}
|
||
</span>
|
||
<span>第 {weekKey.split("-W")[1]} 周 ({weekKey})</span>
|
||
<span className="text-xs text-muted-foreground font-normal">· {items.length} 条学习</span>
|
||
</h2>
|
||
|
||
<div className="grid gap-3">
|
||
{items.map((l) => {
|
||
const isExpanded = expanded.has(l.id);
|
||
const statusMeta = STATUS_META[l.status] || STATUS_META.active;
|
||
return (
|
||
<div key={l.id} className="border rounded-lg bg-white">
|
||
<button
|
||
onClick={() => toggle(l.id)}
|
||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-slate-50"
|
||
>
|
||
{l.botAvatar ? (
|
||
<LazyImage src={l.botAvatar} alt={l.botName} width={32} height={32} className="w-8 h-8 rounded-full object-cover" />
|
||
) : (
|
||
<div className="w-8 h-8 rounded-full bg-violet-100 text-violet-600 flex items-center justify-center text-sm font-medium">
|
||
{l.botName?.charAt(0) || "?"}
|
||
</div>
|
||
)}
|
||
<div className="flex-1 text-left min-w-0">
|
||
<div className="font-medium text-sm flex items-center gap-2">
|
||
{l.botName}
|
||
<span className="text-xs text-muted-foreground">←</span>
|
||
<span className="text-sm text-slate-700 truncate">{l.sourceTitle || `topic#${l.sourceRefId}`}</span>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground mt-0.5">
|
||
{l.sourceUserName && <>@{l.sourceUserName} · </>}
|
||
{l.categoryName && <>{l.categoryName} · </>}
|
||
关联度 {l.relevanceScore}
|
||
</div>
|
||
</div>
|
||
<div className="hidden md:flex items-center gap-3 text-xs text-muted-foreground">
|
||
<span className="inline-flex items-center gap-1"><MessageSquare className="w-3 h-3" /> {l.sourceMetrics.replyCount}</span>
|
||
<span className="inline-flex items-center gap-1"><Heart className="w-3 h-3 text-rose-500" /> {l.sourceMetrics.likeCount}</span>
|
||
<span className="inline-flex items-center gap-1"><Users className="w-3 h-3" /> {l.sourceMetrics.humanReplies}</span>
|
||
<span className="inline-flex items-center gap-1"><Eye className="w-3 h-3" /> {l.sourceMetrics.viewCount}</span>
|
||
</div>
|
||
<span className={cn("text-xs px-2 py-0.5 rounded-full font-medium", statusMeta.bg, statusMeta.color)}>
|
||
{statusMeta.label}
|
||
</span>
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">
|
||
引用 {l.usedCount}
|
||
</span>
|
||
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||
</button>
|
||
|
||
{isExpanded && (
|
||
<div className="border-t bg-slate-50/40 p-4 space-y-3">
|
||
<div className="bg-white border rounded-md p-3">
|
||
<div className="text-xs font-medium text-muted-foreground mb-1">📌 来源真人帖</div>
|
||
<div className="text-sm font-medium">{l.sourceTitle}</div>
|
||
{l.sourceUserName && (
|
||
<div className="text-xs text-muted-foreground mt-1">作者:{l.sourceUserName}</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bg-white border rounded-md p-3">
|
||
<div className="text-xs font-medium text-muted-foreground mb-1">🧠 LLM 提取的洞察</div>
|
||
<div className="text-sm whitespace-pre-wrap leading-relaxed">{l.learnedInsight}</div>
|
||
{l.learnCategories.length > 0 && (
|
||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||
{l.learnCategories.map((c) => {
|
||
const m = CATEGORY_META[c] || { label: c, color: "text-slate-600", bg: "bg-slate-100" };
|
||
return (
|
||
<span key={c} className={cn("text-xs px-1.5 py-0.5 rounded font-medium", m.bg, m.color)}>
|
||
{m.label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-xs text-muted-foreground">
|
||
生效窗口:{l.learnedAt.slice(0, 16)} → {l.expiresAt?.slice(0, 16) || "永久"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="text-xs text-muted-foreground text-center">
|
||
<Link href="/admin/bot-analytics" className="hover:underline">
|
||
← 返回 Bot 表现分析
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SummaryCard({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number | string; accent: string }) {
|
||
return (
|
||
<div className="border rounded-lg p-3 bg-white">
|
||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
{icon}
|
||
{label}
|
||
</div>
|
||
<div className={cn("text-2xl font-bold mt-1", accent)}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|