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:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -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>
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()],
});