Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>群聊人设卡 · 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+7159
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "persona-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.0",
|
||||
"@heroui/theme": "^2.4.26",
|
||||
"framer-motion": "^11.11.17",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
NavLink,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import { Button, Tooltip } from "@heroui/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { clearToken, hasToken } from "./api";
|
||||
import Login from "./pages/Login";
|
||||
import GroupList from "./pages/GroupList";
|
||||
import GroupDetail from "./pages/GroupDetail";
|
||||
import DataBrowser from "./pages/DataBrowser";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/groups", label: "群管理", icon: "◈" },
|
||||
{ to: "/data", label: "数据浏览", icon: "▤" },
|
||||
{ to: "/settings", label: "设置", icon: "⚙" },
|
||||
];
|
||||
|
||||
function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasToken()) navigate("/login", { replace: true });
|
||||
}, [location.pathname]);
|
||||
|
||||
const logout = () => {
|
||||
clearToken();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<aside className="w-56 shrink-0 border-r border-default-100 bg-content1 flex flex-col">
|
||||
<div className="px-5 py-5 text-lg font-bold tracking-wide border-b border-default-100">
|
||||
群聊人设卡
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-default-500 hover:text-foreground hover:bg-default-100/50"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="w-4 text-center">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-3 py-4 border-t border-default-100 flex items-center gap-2">
|
||||
<Tooltip content={isDark ? "切换亮色" : "切换暗色"} placement="right">
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={() => setTheme(isDark ? "light" : "dark")}
|
||||
aria-label="切换主题"
|
||||
>
|
||||
{isDark ? "☀" : "🌙"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="退出登录" placement="right">
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="danger"
|
||||
size="sm"
|
||||
onPress={logout}
|
||||
className="justify-start"
|
||||
>
|
||||
⏻ 退出登录
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/groups" replace />} />
|
||||
<Route path="/groups" element={<GroupList />} />
|
||||
<Route path="/groups/:gid" element={<GroupDetail />} />
|
||||
<Route path="/data" element={<DataBrowser />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/groups" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
const API = "/galgame_card/api";
|
||||
|
||||
let token: string = localStorage.getItem("gc_token") || "";
|
||||
|
||||
export function setToken(t: string) {
|
||||
token = t;
|
||||
localStorage.setItem("gc_token", t);
|
||||
}
|
||||
export function clearToken() {
|
||||
token = "";
|
||||
localStorage.removeItem("gc_token");
|
||||
}
|
||||
export function hasToken() {
|
||||
return !!token;
|
||||
}
|
||||
|
||||
export async function api<T = any>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(API + path, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status !== 0) {
|
||||
if (data.status === 400) {
|
||||
clearToken();
|
||||
window.location.hash = "#/login";
|
||||
}
|
||||
throw new Error(data.msg || "请求失败");
|
||||
}
|
||||
return data.data as T;
|
||||
}
|
||||
|
||||
/* ---------- 类型 ---------- */
|
||||
|
||||
export interface GroupInfo {
|
||||
group_id: number;
|
||||
group_name: string;
|
||||
enabled: boolean;
|
||||
participants: number;
|
||||
corpus_count: number;
|
||||
impression_count: number;
|
||||
summary_count: number;
|
||||
}
|
||||
export interface DiscoverGroup {
|
||||
group_id: number;
|
||||
group_name: string;
|
||||
managed: boolean;
|
||||
}
|
||||
export interface Participant {
|
||||
user_id: number;
|
||||
corpus_count: number;
|
||||
}
|
||||
export interface LogItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
content: string;
|
||||
target_user_id: number | null;
|
||||
target_inherited: boolean;
|
||||
follows_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
export interface ImpressionItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
content: string;
|
||||
cover_from_id: number;
|
||||
cover_to_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
export interface SummaryItem {
|
||||
version: number;
|
||||
card_text: string;
|
||||
corpus_count: number;
|
||||
impression_count: number;
|
||||
model: string;
|
||||
created_at: string;
|
||||
}
|
||||
export interface SettingsInfo {
|
||||
web_username: string;
|
||||
web_enable: boolean;
|
||||
thresholds: {
|
||||
max_content_len: number;
|
||||
repeat_window: number;
|
||||
throttle_seconds: number;
|
||||
rolling_window: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Microsoft YaHei", -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* 侧栏滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-default-700 rounded-full;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { HeroUIProvider, ToastProvider } from "@heroui/react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<HeroUIProvider>
|
||||
<ToastProvider toastProps={{ placement: "top-center" }} />
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HeroUIProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tab,
|
||||
Tabs,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@heroui/react";
|
||||
import { addToast } from "@heroui/react";
|
||||
import { api, GroupInfo, ImpressionItem, LogItem, SummaryItem } from "../api";
|
||||
|
||||
export default function DataBrowser() {
|
||||
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
||||
const [gid, setGid] = useState<number>(0);
|
||||
const [tab, setTab] = useState("logs");
|
||||
|
||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
|
||||
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api<GroupInfo[]>("/groups")
|
||||
.then(setGroups)
|
||||
.catch((e: any) => addToast({ title: e.message, color: "danger" }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gid) return;
|
||||
setLoading(true);
|
||||
const loaders: Record<string, Promise<any>> = {
|
||||
logs: api(`/groups/${gid}/logs?page=1&per_page=100`).then((d: any) =>
|
||||
setLogs(d.items)
|
||||
),
|
||||
impressions: api(`/groups/${gid}/impressions?page=1&per_page=100`).then((d: any) =>
|
||||
setImpressions(d.items)
|
||||
),
|
||||
summaries: api(`/groups/${gid}/summaries`).then(setSummaries),
|
||||
};
|
||||
Promise.all([loaders.logs, loaders.impressions, loaders.summaries]).finally(() =>
|
||||
setLoading(false)
|
||||
);
|
||||
}, [gid]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-5">数据浏览</h1>
|
||||
<Card className="bg-content1">
|
||||
<CardBody>
|
||||
<Select
|
||||
label="选择群"
|
||||
placeholder="选择要浏览的群"
|
||||
className="max-w-sm mb-4"
|
||||
variant="bordered"
|
||||
selectedKeys={gid ? [String(gid)] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const k = Array.from(keys)[0];
|
||||
if (k) setGid(Number(k));
|
||||
}}
|
||||
>
|
||||
{groups.map((g) => (
|
||||
<SelectItem key={String(g.group_id)}>
|
||||
{g.group_name || `群 ${g.group_id}`}({g.group_id})
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{!gid && <p className="text-default-500 py-6">请先选择群</p>}
|
||||
|
||||
{loading && gid && (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && gid && (
|
||||
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
|
||||
<Tab key="logs" title="语料">
|
||||
<Table aria-label="语料" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>ID</TableColumn>
|
||||
<TableColumn>发送者</TableColumn>
|
||||
<TableColumn>发给</TableColumn>
|
||||
<TableColumn>时间</TableColumn>
|
||||
<TableColumn>内容(已脱敏)</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={logs} emptyContent={gid ? "暂无数据" : "请先选择群"}>
|
||||
{(l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>{l.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>{l.nickname || "—"}</div>
|
||||
<div className="text-xs text-default-500">{l.user_id}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{l.target_user_id ? (
|
||||
<span>
|
||||
→ {l.target_user_id}
|
||||
{l.target_inherited && (
|
||||
<span className="text-xs text-warning ml-1">延续</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-default-500">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-default-500">{l.created_at}</TableCell>
|
||||
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Tab>
|
||||
|
||||
<Tab key="impressions" title="印象">
|
||||
<Table aria-label="印象" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>ID</TableColumn>
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>覆盖语料区间</TableColumn>
|
||||
<TableColumn>时间</TableColumn>
|
||||
<TableColumn>印象内容</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={impressions} emptyContent={gid ? "暂无数据" : "请先选择群"}>
|
||||
{(r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.user_id}</TableCell>
|
||||
<TableCell className="text-default-500">
|
||||
#{r.cover_from_id} ~ #{r.cover_to_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-default-500">{r.created_at}</TableCell>
|
||||
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Tab>
|
||||
|
||||
<Tab key="summaries" title="画像">
|
||||
{summaries.length === 0 ? (
|
||||
<p className="text-default-500 py-6">{gid ? "暂无数据" : "请先选择群"}</p>
|
||||
) : (
|
||||
summaries.map((s) => (
|
||||
<div key={s.version} className="mb-4">
|
||||
<div className="text-sm text-default-500 mb-2">
|
||||
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
|
||||
{s.impression_count} 条
|
||||
</div>
|
||||
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
|
||||
{s.card_text}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Input,
|
||||
Spinner,
|
||||
Tab,
|
||||
Tabs,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@heroui/react";
|
||||
import { addToast } from "@heroui/react";
|
||||
import { api, ImpressionItem, LogItem, Participant, SummaryItem } from "../api";
|
||||
|
||||
export default function GroupDetail() {
|
||||
const { gid } = useParams();
|
||||
const gidNum = Number(gid);
|
||||
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [addUid, setAddUid] = useState("");
|
||||
const [tab, setTab] = useState("logs");
|
||||
|
||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||
const [logTotal, setLogTotal] = useState(0);
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const [logUid, setLogUid] = useState("");
|
||||
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
|
||||
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadParticipants = useCallback(async () => {
|
||||
try {
|
||||
setParticipants(await api<Participant[]>(`/groups/${gidNum}/participants`));
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
}, [gidNum]);
|
||||
|
||||
const loadLogs = useCallback(
|
||||
async (page: number) => {
|
||||
const q = new URLSearchParams({ page: String(page), per_page: "50" });
|
||||
if (logUid) q.set("user_id", logUid);
|
||||
try {
|
||||
const data = await api<{ items: LogItem[]; total: number }>(
|
||||
`/groups/${gidNum}/logs?${q}`
|
||||
);
|
||||
setLogs(data.items);
|
||||
setLogTotal(data.total);
|
||||
setLogPage(page);
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
},
|
||||
[gidNum, logUid]
|
||||
);
|
||||
|
||||
const loadImpressions = useCallback(async () => {
|
||||
try {
|
||||
const data = await api<{ items: ImpressionItem[] }>(
|
||||
`/groups/${gidNum}/impressions?page=1&per_page=100`
|
||||
);
|
||||
setImpressions(data.items);
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
}, [gidNum]);
|
||||
|
||||
const loadSummaries = useCallback(async () => {
|
||||
try {
|
||||
setSummaries(await api<SummaryItem[]>(`/groups/${gidNum}/summaries`));
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
}, [gidNum]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([loadParticipants(), loadLogs(1)]).finally(() => setLoading(false));
|
||||
}, [loadParticipants, loadLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "impressions") loadImpressions();
|
||||
if (tab === "summaries") loadSummaries();
|
||||
}, [tab]);
|
||||
|
||||
/* ---------- 操作 ---------- */
|
||||
const addParticipant = async () => {
|
||||
const uid = Number(addUid.trim());
|
||||
if (!uid) return;
|
||||
try {
|
||||
await api(`/groups/${gidNum}/participants`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: uid }),
|
||||
});
|
||||
setAddUid("");
|
||||
loadParticipants();
|
||||
addToast({ title: `已加入 ${uid}`, color: "success" });
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
};
|
||||
|
||||
const removeParticipant = async (uid: number) => {
|
||||
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
|
||||
try {
|
||||
await api(`/groups/${gidNum}/participants/${uid}`, { method: "DELETE" });
|
||||
loadParticipants();
|
||||
addToast({ title: "已移除", color: "success" });
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!confirm("删除该群全部语料?印象/画像保留")) return;
|
||||
try {
|
||||
await api(`/groups/${gidNum}/logs`, { method: "DELETE" });
|
||||
loadLogs(1);
|
||||
addToast({ title: "已删除全部语料", color: "success" });
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
};
|
||||
|
||||
const clearGroup = async () => {
|
||||
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
|
||||
try {
|
||||
await api(`/groups/${gidNum}/data`, { method: "DELETE" });
|
||||
addToast({ title: "已清空", color: "success" });
|
||||
setTimeout(() => (window.location.hash = "#/groups"), 600);
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
};
|
||||
|
||||
const pages = Math.max(1, Math.ceil(logTotal / 50));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner label="加载中…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<Link to="/groups">
|
||||
<Button size="sm" variant="flat" isIconOnly aria-label="返回">
|
||||
←
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold flex-1">群管理 #{gidNum}</h1>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={clearGroup}>
|
||||
清空该群全部数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 参与者 */}
|
||||
<Card className="bg-content1 mb-5">
|
||||
<CardBody className="gap-3">
|
||||
<h2 className="font-semibold">参与者</h2>
|
||||
<div className="flex gap-2 max-w-md">
|
||||
<Input
|
||||
placeholder="QQ 号"
|
||||
type="number"
|
||||
value={addUid}
|
||||
onValueChange={setAddUid}
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && addParticipant()}
|
||||
/>
|
||||
<Button size="sm" color="primary" onPress={addParticipant}>
|
||||
加入人设名单
|
||||
</Button>
|
||||
</div>
|
||||
<Table aria-label="参与者" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>QQ</TableColumn>
|
||||
<TableColumn>语料条数</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={participants} emptyContent="暂无参与者">
|
||||
{(p) => (
|
||||
<TableRow key={p.user_id}>
|
||||
<TableCell>{p.user_id}</TableCell>
|
||||
<TableCell>{p.corpus_count}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" color="danger" variant="light" onPress={() => removeParticipant(p.user_id)}>
|
||||
移除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据页签 */}
|
||||
<Card className="bg-content1">
|
||||
<CardBody>
|
||||
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
|
||||
<Tab key="logs" title="语料">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Input
|
||||
placeholder="按 QQ 过滤"
|
||||
type="number"
|
||||
value={logUid}
|
||||
onValueChange={setLogUid}
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
className="max-w-40"
|
||||
/>
|
||||
<Button size="sm" variant="flat" onPress={() => loadLogs(1)}>
|
||||
查询
|
||||
</Button>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={clearLogs}>
|
||||
删除全部语料
|
||||
</Button>
|
||||
</div>
|
||||
<Table aria-label="语料" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>ID</TableColumn>
|
||||
<TableColumn>发送者</TableColumn>
|
||||
<TableColumn>发给</TableColumn>
|
||||
<TableColumn>时间</TableColumn>
|
||||
<TableColumn>内容(已脱敏)</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={logs} emptyContent="暂无语料">
|
||||
{(l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>{l.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>{l.nickname || "—"}</div>
|
||||
<div className="text-xs text-default-500">{l.user_id}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{l.target_user_id ? (
|
||||
<span>
|
||||
→ {l.target_user_id}
|
||||
{l.target_inherited && (
|
||||
<span className="text-xs text-warning ml-1">延续</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-default-500">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-default-500">{l.created_at}</TableCell>
|
||||
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
isDisabled={logPage <= 1}
|
||||
onPress={() => loadLogs(logPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-default-500">
|
||||
第 {logPage}/{pages} 页 · 共 {logTotal} 条
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
isDisabled={logPage >= pages}
|
||||
onPress={() => loadLogs(logPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab key="impressions" title="印象">
|
||||
<Table aria-label="印象" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>ID</TableColumn>
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>覆盖语料区间</TableColumn>
|
||||
<TableColumn>时间</TableColumn>
|
||||
<TableColumn>印象内容</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={impressions} emptyContent="暂无印象">
|
||||
{(r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.user_id}</TableCell>
|
||||
<TableCell className="text-default-500">
|
||||
#{r.cover_from_id} ~ #{r.cover_to_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-default-500">{r.created_at}</TableCell>
|
||||
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Tab>
|
||||
|
||||
<Tab key="summaries" title="画像">
|
||||
{summaries.length === 0 ? (
|
||||
<p className="text-default-500 py-6">暂无画像(语料积累后由总结路径生成)</p>
|
||||
) : (
|
||||
summaries.map((s) => (
|
||||
<div key={s.version} className="mb-4">
|
||||
<div className="text-sm text-default-500 mb-2">
|
||||
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
|
||||
{s.impression_count} 条
|
||||
</div>
|
||||
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
|
||||
{s.card_text}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Spinner,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
useDisclosure,
|
||||
} from "@heroui/react";
|
||||
import { addToast } from "@heroui/react";
|
||||
import { api, DiscoverGroup, GroupInfo } from "../api";
|
||||
|
||||
export default function GroupList() {
|
||||
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 添加群弹窗
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [discoverList, setDiscoverList] = useState<DiscoverGroup[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [discoverLoading, setDiscoverLoading] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setGroups(await api<GroupInfo[]>("/groups"));
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const toggle = async (g: GroupInfo, enabled: boolean) => {
|
||||
try {
|
||||
await api(`/groups/${g.group_id}/enabled`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
g.enabled = enabled;
|
||||
setGroups([...groups]);
|
||||
addToast({
|
||||
title: enabled ? "已开启采集" : "已关闭采集",
|
||||
description: `${g.group_name || g.group_id}`,
|
||||
color: enabled ? "success" : "warning",
|
||||
});
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
};
|
||||
|
||||
const removeGroup = async (g: GroupInfo) => {
|
||||
if (!confirm(`将 ${g.group_name || g.group_id} 移出管理列表?其数据保留`)) return;
|
||||
try {
|
||||
await api(`/groups/${g.group_id}`, { method: "DELETE" });
|
||||
load();
|
||||
addToast({ title: "已移出管理列表", color: "success" });
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 添加群 ---------- */
|
||||
const openDiscover = async () => {
|
||||
onOpen();
|
||||
setDiscoverLoading(true);
|
||||
setSelected([]);
|
||||
try {
|
||||
setDiscoverList(await api<DiscoverGroup[]>("/groups/discover"));
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
} finally {
|
||||
setDiscoverLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAdd = async () => {
|
||||
const picked = discoverList.filter(
|
||||
(g) => selected.includes(String(g.group_id)) && !g.managed
|
||||
);
|
||||
if (picked.length === 0) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
for (const g of picked) {
|
||||
await api("/groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ group_id: g.group_id, group_name: g.group_name }),
|
||||
});
|
||||
}
|
||||
addToast({ title: `已加入 ${picked.length} 个群`, color: "success" });
|
||||
onOpenChange(false);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
addToast({ title: e.message, color: "danger" });
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h1 className="text-xl font-bold">群管理</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="flat" onPress={load}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onPress={openDiscover}>
|
||||
+ 添加群
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="bg-content1">
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner label="加载中…" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-danger text-center py-8">{error}</p>
|
||||
) : (
|
||||
<Table aria-label="管理群列表" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>群</TableColumn>
|
||||
<TableColumn>采集开关</TableColumn>
|
||||
<TableColumn>参与者</TableColumn>
|
||||
<TableColumn>语料</TableColumn>
|
||||
<TableColumn>印象</TableColumn>
|
||||
<TableColumn>画像</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={groups} emptyContent="还没有管理的群,点右上角「添加群」">
|
||||
{(g) => (
|
||||
<TableRow key={g.group_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{g.group_name || `群 ${g.group_id}`}</div>
|
||||
<div className="text-xs text-default-500">{g.group_id}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch size="sm" isSelected={g.enabled} onValueChange={(v) => toggle(g, v)} />
|
||||
</TableCell>
|
||||
<TableCell>{g.participants}</TableCell>
|
||||
<TableCell>{g.corpus_count}</TableCell>
|
||||
<TableCell>{g.impression_count}</TableCell>
|
||||
<TableCell>{g.summary_count}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/groups/${g.group_id}`}>
|
||||
<Button size="sm" color="primary" variant="flat">
|
||||
管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" color="danger" variant="light" onPress={() => removeGroup(g)}>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 添加群弹窗 */}
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="lg" scrollBehavior="inside">
|
||||
<ModalContent>
|
||||
<ModalHeader>添加群到管理列表</ModalHeader>
|
||||
<ModalBody>
|
||||
{discoverLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner label="正在从 OneBot 获取群列表…" />
|
||||
</div>
|
||||
) : (
|
||||
<CheckboxGroup value={selected} onValueChange={setSelected}>
|
||||
{discoverList.map((g) => (
|
||||
<div key={g.group_id} className="flex items-center justify-between py-1">
|
||||
<Checkbox value={String(g.group_id)} isDisabled={g.managed}>
|
||||
{g.group_name || `群 ${g.group_id}`}
|
||||
<span className="text-xs text-default-500 ml-2">{g.group_id}</span>
|
||||
</Checkbox>
|
||||
{g.managed && <span className="text-xs text-success">已管理</span>}
|
||||
</div>
|
||||
))}
|
||||
</CheckboxGroup>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="flat" onPress={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={selected.length === 0 || discoverLoading}
|
||||
isLoading={adding}
|
||||
onPress={confirmAdd}
|
||||
>
|
||||
加入管理({selected.filter((s) => !discoverList.find((g) => String(g.group_id) === s)?.managed).length})
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, CardBody, Input } from "@heroui/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, setToken } from "../api";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await api<{ token: string }>("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
setToken(data.token);
|
||||
navigate("/groups", { replace: true });
|
||||
} catch (e: any) {
|
||||
setError(e.message || "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-content2">
|
||||
<Card className="w-80 bg-content1" shadow="lg">
|
||||
<CardBody className="gap-4 p-8">
|
||||
<h1 className="text-xl font-bold text-center mb-2">群聊人设卡</h1>
|
||||
<Input
|
||||
label="用户名"
|
||||
value={username}
|
||||
onValueChange={setUsername}
|
||||
variant="bordered"
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
<Input
|
||||
label="密码"
|
||||
type="password"
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
variant="bordered"
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
{error && <p className="text-danger text-sm text-center">{error}</p>}
|
||||
<Button color="primary" isLoading={loading} onPress={submit}>
|
||||
登 录
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardBody, Spinner, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/react";
|
||||
import { api, SettingsInfo } from "../api";
|
||||
|
||||
export default function Settings() {
|
||||
const [info, setInfo] = useState<SettingsInfo | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api<SettingsInfo>("/settings")
|
||||
.then(setInfo)
|
||||
.catch((e: any) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-5">设置</h1>
|
||||
<Card className="bg-content1 max-w-2xl">
|
||||
<CardBody>
|
||||
{error ? (
|
||||
<p className="text-danger">{error}</p>
|
||||
) : !info ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="font-semibold mb-3">当前配置(只读)</h2>
|
||||
<Table aria-label="配置" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>项</TableColumn>
|
||||
<TableColumn>值</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow key="user">
|
||||
<TableCell>Web 登录账号</TableCell>
|
||||
<TableCell>{info.web_username}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="enable">
|
||||
<TableCell>Web 后台启用</TableCell>
|
||||
<TableCell>{info.web_enable ? "是" : "否"}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="len">
|
||||
<TableCell>长文截断上限</TableCell>
|
||||
<TableCell>{info.thresholds.max_content_len} 字</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="repeat">
|
||||
<TableCell>复读检测窗口</TableCell>
|
||||
<TableCell>{info.thresholds.repeat_window} 条</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="throttle">
|
||||
<TableCell>刷屏采样间隔</TableCell>
|
||||
<TableCell>{info.thresholds.throttle_seconds} 秒</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="rolling">
|
||||
<TableCell>单用户语料滚动上限</TableCell>
|
||||
<TableCell>{info.thresholds.rolling_window} 条</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p className="text-default-500 text-sm mt-4">
|
||||
阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME /
|
||||
WEB_PASSWORD。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { heroui } from "@heroui/theme";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
plugins: [heroui()],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/galgame_card/",
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user