Files
zhuiguang-ai/src/app/(admin)/admin/bot-adversarial-learning/BotAdversarialLearningClient.tsx
T

364 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}