项目改造

This commit is contained in:
2026-09-03 15:44:57 +08:00
parent 24d7c64e17
commit 2849a6250c
60 changed files with 166 additions and 13754 deletions
@@ -18,14 +18,13 @@ nonebot_plugin_galgame_card/
├── models.py 数据层:五张表(persona_group/user/chat_log/impression/summary)
├── repository.py 数据层:仓储(来源无关,所有读写唯一入口)
├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测
├── config.py 插件配置(WEB_ENABLE/WEB_USERNAME/WEB_PASSWORD/WEB_SECRET_KEY)
├── web.py Web 管理后台后端(/galgame_card/api/*,JWT 登录,仿 learning_chat)
├── web/index.html 管理后台前端(单文件,无需构建)
├── config.py 插件配置(仅图片识别开关等;Web 鉴权统一走 /hub)
├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_auth)
├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律)
└── CLAUDE.md 本文件
```
**Web 管理后台**:`http://<host>:<port>/galgame_card`(默认 admin/galgame,可在 .env 改 WEB_*)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。路由在 `@driver.on_startup` 里注册,改后端需重启 bot。
**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。
## 核心设计(速览,细节见 DESIGN.md)
@@ -69,7 +68,7 @@ poetry run pytest
- ✅ 数据层:五表 + 仓储(含群开关、滚动淘汰、版本自增)
- ✅ 采集层:消息路径(监控→鉴权→治理→脱敏→采样→落库)
- ✅ Web 管理后台:/galgame_card(群开关、参与者、数据浏览/清理;Chrome 已实测)
- ✅ Web 管理后台:嵌入 /hub(/api/galgame_card + hexi/web 页面;群开关、参与者、数据浏览/清理)
- ⏳ QQ 命令集:`开启人设采集` / `加入人设` / `退出人设` / `查看人设`(Web 已覆盖同等功能,QQ 命令可选做)
- ⏳ 总结路径:LLM 客户端、印象生成、九段画像生成、调度触发
- ⏳ 呈现层:人物卡展示/图片渲染
@@ -28,7 +28,6 @@ except ImportError:
from . import models # noqa: F401 # 确保表结构注册
from . import repository, processor
from . import web # noqa: F401 # Web 管理后台(/galgame_card)
from . import handlers as _handlers # noqa: E402
from hexi.web_config import register_plugin_config # noqa: E402
@@ -57,7 +56,7 @@ def _get_galgame_config_values() -> dict:
conf = getattr(conf_mod, "config", None) if conf_mod else None
if conf is None:
return {}
keys = ("web_enable", "web_username", "web_password", "web_secret_key", "image_vision_enabled")
keys = ("image_vision_enabled",)
return {k: getattr(conf, k, None) for k in keys}
@@ -65,10 +64,6 @@ register_plugin_config(
__name__,
{
"fields": [
{"key": "web_enable", "label": "Web 后台启用", "type": "bool", "default": True},
{"key": "web_username", "label": "Web 登录账号", "type": "string", "default": "admin"},
{"key": "web_password", "label": "Web 登录密码", "type": "password", "default": "galgame", "secret": True},
{"key": "web_secret_key", "label": "Web 签名密钥", "type": "password", "default": "galgame-card-secret-key", "secret": True},
{"key": "image_vision_enabled", "label": "图片识别启用", "type": "bool", "default": False},
]
},
@@ -1,6 +1,6 @@
"""插件配置(Web 管理后台)
"""插件配置(图片识别等运行时行为)。
env 变量名与字段名对应(大写):WEB_ENABLE / WEB_USERNAME / WEB_PASSWORD / WEB_SECRET_KEY
access 由统一 Web 管理台 /hub 提供,插件不再自持 Web 鉴权/开关。
"""
from nonebot import get_plugin_config
@@ -8,10 +8,6 @@ from pydantic import BaseModel
class Config(BaseModel):
web_enable: bool = True
web_username: str = "admin"
web_password: str = "galgame"
web_secret_key: str = "galgame-card-secret-key"
# 图片多模态识别(预留接口,当前未启用;将来接入后置 True)
image_vision_enabled: bool = False
@@ -1,266 +0,0 @@
"""Web 管理后台:群开关 / 参与者 / 数据浏览(挂在 bot 端口 /galgame_card 路径)
仿 nonebot_plugin_learning_chat 的网页模式:JWT 登录 + /galgame_card/api/* 路由 + 静态页。
"""
import datetime
from pathlib import Path
from typing import Optional
from fastapi import Depends, Header, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from jose import jwt
from nonebot import get_adapter, get_app, get_driver, logger
from nonebot.adapters.onebot.v11 import Adapter, Bot
from pydantic import BaseModel
from . import repository
from .config import config
API_PREFIX = "/galgame_card/api"
WEB_DIR = Path(__file__).parent / "web"
driver = get_driver()
def authentication():
def inner(token: Optional[str] = Header(None, alias="Authorization")):
try:
payload = jwt.decode(token, config.web_secret_key, algorithms="HS256")
if not payload.get("username") or payload["username"] != config.web_username:
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
return Depends(inner)
class LoginModel(BaseModel):
username: str
password: str
def _ok(data=None, msg: str = "ok") -> JSONResponse:
return JSONResponse({"status": 0, "msg": msg, "data": data})
def _err(code: int, msg: str) -> JSONResponse:
return JSONResponse({"status": code, "msg": msg})
async def _first_bot() -> Optional[Bot]:
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
@driver.on_startup
async def init_web():
if not config.web_enable:
return
app = get_app()
@app.post(f"{API_PREFIX}/login")
async def login(user: LoginModel):
if user.username != config.web_username or user.password != config.web_password:
return _err(-100, "登录失败,请确认账号密码无误")
token = jwt.encode(
{
"username": user.username,
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12),
},
config.web_secret_key,
algorithm="HS256",
)
return _ok({"token": token}, "登录成功")
@app.get(f"{API_PREFIX}/groups", dependencies=[authentication()])
async def groups():
"""管理中的群:数据库直读,不依赖 OneBot"""
rows = await repository.list_groups()
corpus = await repository.corpus_counts_by_group()
impressions = await repository.impression_counts_by_group()
summaries = await repository.summary_counts_by_group()
participants = await repository.participant_counts_by_group()
result = []
for row in rows:
gid = row.group_id
result.append(
{
"group_id": gid,
"group_name": row.group_name,
"enabled": row.enabled,
"participants": participants.get(gid, 0),
"corpus_count": corpus.get(gid, 0),
"impression_count": impressions.get(gid, 0),
"summary_count": summaries.get(gid, 0),
}
)
return _ok(result)
@app.get(f"{API_PREFIX}/groups/discover", dependencies=[authentication()])
async def discover_groups():
"""OneBot 全部群("添加群"选择器用),顺带回填已管理群名称"""
bot = await _first_bot()
if bot is None:
return _err(-100, "未连接 OneBot,无法获取群列表")
group_list = await bot.get_group_list()
managed_ids = {row.group_id for row in await repository.list_groups()}
result = [
{
"group_id": int(g["group_id"]),
"group_name": g.get("group_name", ""),
"managed": int(g["group_id"]) in managed_ids,
}
for g in group_list
]
await repository.backfill_group_names(
{int(g["group_id"]): g.get("group_name", "") for g in group_list}
)
return _ok(result)
@app.post(f"{API_PREFIX}/groups", dependencies=[authentication()])
async def add_group(data: dict):
gid = int(data.get("group_id") or 0)
if gid <= 0:
return _err(400, "group_id 不合法")
await repository.add_group(gid, str(data.get("group_name") or "").strip())
return _ok(msg="已加入管理列表")
@app.post(f"{API_PREFIX}/groups/{{group_id}}/enabled", dependencies=[authentication()])
async def set_group_enabled(group_id: int, data: dict):
await repository.set_group_enabled(group_id, bool(data.get("enabled")))
return _ok(msg="已开启采集" if data.get("enabled") else "已关闭采集")
@app.get(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
async def participants(group_id: int):
members = await repository.joined_users(group_id)
stats = dict(await repository.chat_log_stats_by_user(group_id))
result = [
{"user_id": uid, "corpus_count": stats.get(uid, 0)} for uid in members
]
return _ok(result)
@app.post(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
async def add_participant(group_id: int, data: dict):
user_id = int(data.get("user_id") or 0)
if user_id <= 0:
return _err(400, "user_id 不合法")
if await repository.is_joined(user_id, group_id):
return _err(400, "已在人设名单中")
await repository.join(user_id, group_id)
return _ok(msg="已加入")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/participants/{{user_id}}", dependencies=[authentication()])
async def remove_participant(group_id: int, user_id: int):
await repository.leave(user_id, group_id)
return _ok(msg="已移除")
@app.get(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
async def logs(group_id: int, user_id: Optional[int] = None, page: int = 1, per_page: int = 50):
offset = max(0, (page - 1) * per_page)
items = await repository.list_logs(group_id, user_id, offset, per_page)
total = await repository.count_logs_filtered(group_id, user_id)
result = [
{
"id": row.id,
"user_id": row.user_id,
"nickname": row.nickname,
"content": row.content,
"target_user_id": row.target_user_id,
"target_inherited": row.target_inherited,
"follows_id": row.follows_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok({"items": result, "total": total})
@app.get(f"{API_PREFIX}/groups/{{group_id}}/impressions", dependencies=[authentication()])
async def impressions(group_id: int, page: int = 1, per_page: int = 50):
offset = max(0, (page - 1) * per_page)
items = await repository.list_impressions_group(group_id, offset, per_page)
result = [
{
"id": row.id,
"user_id": row.user_id,
"content": row.content,
"cover_from_id": row.cover_from_id,
"cover_to_id": row.cover_to_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok({"items": result, "total": await repository.count_impressions_group(group_id)})
@app.get(f"{API_PREFIX}/groups/{{group_id}}/summaries", dependencies=[authentication()])
async def summaries(group_id: int):
items = await repository.list_summaries_group(group_id)
result = [
{
"version": row.version,
"card_text": row.card_text,
"corpus_count": row.corpus_count,
"impression_count": row.impression_count,
"model": row.model,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok(result)
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
async def delete_logs(group_id: int, user_id: Optional[int] = None):
count = await repository.delete_logs(group_id, user_id)
return _ok(msg=f"已删除 {count} 条语料")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/data", dependencies=[authentication()])
async def clear_group(group_id: int):
await repository.clear_group_data(group_id)
return _ok(msg="已清空该群全部数据")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}", dependencies=[authentication()])
async def remove_group(group_id: int):
await repository.remove_group(group_id)
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
@app.get(f"{API_PREFIX}/settings", dependencies=[authentication()])
async def settings():
from . import processor
return _ok(
{
"web_username": config.web_username,
"web_enable": config.web_enable,
"thresholds": {
"max_content_len": processor.MAX_CONTENT_LEN,
"repeat_window": processor.REPEAT_WINDOW,
"throttle_seconds": processor.THROTTLE_SECONDS,
"rolling_window": repository.ROLLING_WINDOW,
},
}
)
# React 管理后台(web/persona-admin 构建产物)
dist_dir = WEB_DIR / "persona-admin" / "dist"
if (dist_dir / "index.html").exists():
app.mount(
"/galgame_card/assets",
StaticFiles(directory=dist_dir / "assets"),
name="galgame_card_assets",
)
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
return FileResponse(dist_dir / "index.html")
else:
# 构建产物缺失时回退到旧版单文件页面
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
return HTMLResponse(html)
logger.info("群聊人设卡 | Web 管理后台已挂载: /galgame_card")
@@ -1,370 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>群聊人设卡 · 管理后台</title>
<style>
:root { --bg:#1a1d24; --panel:#232833; --border:#333a47; --text:#dde2ea; --muted:#8a94a6;
--accent:#5b8cff; --danger:#ff5c5c; --ok:#3ecf8e; --sidebar:#1e222b; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--text); font:14px/1.6 "Microsoft YaHei",sans-serif; }
h1 { font-size:20px; }
h2 { font-size:16px; margin:16px 0 10px; }
.card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
table { width:100%; border-collapse:collapse; }
th,td { padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); }
th { color:var(--muted); font-weight:normal; font-size:12px; }
tr:hover td { background:rgba(255,255,255,.03); }
button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:6px 14px; cursor:pointer; font-size:13px; }
button:hover { opacity:.85; }
button.danger { background:var(--danger); }
button.ghost { background:transparent; border:1px solid var(--border); color:var(--muted); }
input,select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font-size:13px; }
.switch { position:relative; width:42px; height:22px; display:inline-block; cursor:pointer; }
.switch input { display:none; }
.switch .track { position:absolute; inset:0; background:var(--border); border-radius:11px; transition:.2s; }
.switch .track::after { content:""; position:absolute; width:16px; height:16px; background:#fff; border-radius:50%; top:3px; left:3px; transition:.2s; }
.switch input:checked + .track { background:var(--ok); }
.switch input:checked + .track::after { left:23px; }
.muted { color:var(--muted); font-size:12px; }
.tabs { display:flex; gap:6px; margin-bottom:12px; }
.tabs button.active { background:var(--accent); }
pre { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:12px; white-space:pre-wrap; word-break:break-all; font-size:12px; max-height:400px; overflow:auto; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
.hidden { display:none; }
.msg { margin:8px 0; font-size:13px; }
.msg.err { color:var(--danger); }
.msg.ok { color:var(--ok); }
/* 登录 */
.login-wrap { max-width:320px; margin:80px auto; }
.login-wrap .card { padding:28px; }
.login-wrap input { width:100%; margin-bottom:12px; padding:9px; }
.login-wrap button { width:100%; padding:9px; }
/* 侧栏 + 主体 */
#mainView { display:flex; min-height:100vh; }
.sidebar { width:200px; flex-shrink:0; background:var(--sidebar); border-right:1px solid var(--border);
display:flex; flex-direction:column; position:sticky; top:0; height:100vh; }
.brand { padding:20px 18px 14px; font-size:16px; font-weight:bold; border-bottom:1px solid var(--border); }
.nav { flex:1; padding:10px 0; }
.nav a { display:block; padding:10px 18px; color:var(--muted); text-decoration:none; font-size:14px; border-left:3px solid transparent; }
.nav a:hover { color:var(--text); background:rgba(255,255,255,.03); }
.nav a.active { color:var(--text); background:rgba(91,140,255,.12); border-left-color:var(--accent); }
.sidebar-footer { padding:14px 18px; border-top:1px solid var(--border); }
.sidebar-footer button { width:100%; background:transparent; border:1px solid var(--border); color:var(--muted); }
.sidebar-footer button:hover { color:var(--danger); border-color:var(--danger); }
.content { flex:1; padding:24px; min-width:0; }
.page-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
</style>
</head>
<body>
<div id="loginView" class="login-wrap hidden">
<div class="card">
<h1 style="text-align:center">群聊人设卡</h1>
<input id="username" placeholder="用户名">
<input id="password" type="password" placeholder="密码">
<button onclick="login()">登 录</button>
<div id="loginMsg" class="msg"></div>
</div>
</div>
<div id="mainView" class="hidden">
<aside class="sidebar">
<div class="brand">群聊人设卡</div>
<nav class="nav">
<a href="#/groups">群管理</a>
<a href="#/data">数据浏览</a>
<a href="#/settings">设置</a>
</nav>
<div class="sidebar-footer"><button onclick="logout()">退出登录</button></div>
</aside>
<main class="content" id="content"></main>
</div>
<script>
const API = "/galgame_card/api";
let TOKEN = localStorage.getItem("gc_token") || "";
/* ---------- 基础 ---------- */
async function api(path, options = {}) {
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) showLogin();
throw new Error(data.msg || "请求失败");
}
return data.data;
}
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c])); }
function pageHead(title, extra = "") {
return `<div class="page-head"><h1>${esc(title)}</h1><div>${extra}</div></div>`;
}
function showLogin() {
document.getElementById("loginView").classList.remove("hidden");
document.getElementById("mainView").classList.add("hidden");
}
function showMain() {
document.getElementById("loginView").classList.add("hidden");
document.getElementById("mainView").classList.remove("hidden");
}
async function login() {
const msg = document.getElementById("loginMsg");
try {
const data = await api("/login", { method: "POST", body: JSON.stringify({
username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value }) });
TOKEN = data.token; localStorage.setItem("gc_token", TOKEN);
msg.className = "msg ok"; msg.textContent = "登录成功";
showMain(); location.hash = "#/groups"; route();
} catch (e) { msg.className = "msg err"; msg.textContent = e.message; }
}
function logout() { localStorage.removeItem("gc_token"); TOKEN = ""; showLogin(); }
/* ---------- 路由:侧栏 + 主体 ---------- */
const routes = { "#/groups": renderGroupList, "#/data": renderDataBrowser, "#/settings": renderSettings };
function route() {
const hash = location.hash || "#/groups";
document.querySelectorAll(".nav a").forEach(a => a.classList.toggle("active", a.getAttribute("href") === hash));
const content = document.getElementById("content");
const m = hash.match(/^#\/groups\/(\d+)/);
if (m) { renderGroupDetail(Number(m[1])); return; }
(routes[hash] || renderGroupList)(content);
}
/* ---------- 群管理 ---------- */
async function renderGroupList(content) {
content.innerHTML = pageHead("群管理", '<button class="ghost" onclick="route()">刷新</button>') +
`<div class="card"><h2>群采集开关</h2>
<table><thead><tr><th>群</th><th>开关</th><th>参与者</th><th>语料</th><th>印象</th><th>画像</th><th></th></tr></thead>
<tbody id="groupRows"></tbody></table><div id="listMsg" class="msg"></div></div>`;
const tbody = document.getElementById("groupRows");
document.getElementById("listMsg").textContent = "加载中…";
try {
const groups = await api("/groups");
tbody.innerHTML = groups.map(g => `<tr>
<td><b>${esc(g.group_name)}</b><br><span class="muted">${g.group_id}</span></td>
<td><label class="switch"><input type="checkbox" ${g.enabled ? "checked" : ""} onchange="toggleGroup(${g.group_id}, this.checked)"><span class="track"></span></label></td>
<td>${g.participants}</td><td>${g.corpus_count}</td><td>${g.impression_count}</td><td>${g.summary_count}</td>
<td><a href="#/groups/${g.group_id}"><button>管理</button></a></td></tr>`).join("");
document.getElementById("listMsg").textContent = "";
} catch (e) { document.getElementById("listMsg").className = "msg err"; document.getElementById("listMsg").textContent = e.message; }
}
async function toggleGroup(gid, enabled) {
try { await api(`/groups/${gid}/enabled`, { method: "POST", body: JSON.stringify({ enabled }) }); }
catch (e) { alert(e.message); route(); }
}
/* ---------- 群详情(管理) ---------- */
async function renderGroupDetail(gid) {
const content = document.getElementById("content");
content.innerHTML = pageHead(`群管理 #${gid}`,
`<a href="#/groups"><button class="ghost">← 返回</button></a>
<button class="danger" onclick="clearGroup(${gid})">清空该群全部数据</button>`) +
`<div class="card"><h2>参与者</h2>
<div class="row"><input id="addUserId" placeholder="QQ 号" type="number">
<button onclick="addParticipant(${gid})">加入人设名单</button>
<span class="muted">仅名单内成员会被采集</span></div>
<table><thead><tr><th>QQ</th><th>语料条数</th><th></th></tr></thead><tbody id="participantRows"></tbody></table></div>
<div class="card"><div class="tabs">
<button id="tabLogs" class="active" onclick="switchTab('logs')">语料</button>
<button id="tabImpressions" onclick="switchTab('impressions')">印象</button>
<button id="tabSummaries" onclick="switchTab('summaries')">画像</button>
</div>
<div id="tabLogsView">
<div class="row"><input id="logUserId" placeholder="按 QQ 过滤" type="number">
<button onclick="loadLogs(${gid}, 1)">查询</button>
<button class="danger" onclick="clearLogs(${gid})">删除全部语料</button></div>
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
<tbody id="logRows"></tbody></table>
<div class="row" id="logPager" style="margin-top:10px"></div>
</div>
<div id="tabImpressionsView" class="hidden">
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
<tbody id="impressionRows"></tbody></table>
</div>
<div id="tabSummariesView" class="hidden"><div id="summaryList"></div></div>
</div>`;
window._gid = gid; window._logPage = 1;
loadParticipants(gid); switchTab("logs"); loadLogs(gid, 1);
}
async function loadParticipants(gid) {
const tbody = document.getElementById("participantRows");
try {
const items = await api(`/groups/${gid}/participants`);
tbody.innerHTML = items.map(p => `<tr><td>${p.user_id}</td><td>${p.corpus_count}</td>
<td><button class="danger" onclick="removeParticipant(${gid}, ${p.user_id})">移除</button></td></tr>`).join("")
|| `<tr><td colspan="3" class="muted">暂无参与者</td></tr>`;
} catch (e) { alert(e.message); }
}
async function addParticipant(gid) {
const uid = document.getElementById("addUserId").value.trim();
if (!uid) return;
try { await api(`/groups/${gid}/participants`, { method: "POST", body: JSON.stringify({ user_id: Number(uid) }) });
document.getElementById("addUserId").value = ""; loadParticipants(gid); }
catch (e) { alert(e.message); }
}
async function removeParticipant(gid, uid) {
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
await api(`/groups/${gid}/participants/${uid}`, { method: "DELETE" });
loadParticipants(gid);
}
function switchTab(tab) {
["logs", "impressions", "summaries"].forEach(t => {
const name = t[0].toUpperCase() + t.slice(1);
document.getElementById(`tab${name}`).classList.toggle("active", t === tab);
document.getElementById(`tab${name}View`).classList.toggle("hidden", t !== tab);
});
const gid = window._gid;
if (tab === "logs") loadLogs(gid, 1);
if (tab === "impressions") loadImpressions(gid);
if (tab === "summaries") loadSummaries(gid);
}
async function loadLogs(gid, page) {
window._logPage = page;
const tbody = document.getElementById("logRows");
const uid = document.getElementById("logUserId").value.trim();
const q = new URLSearchParams({ page, per_page: 50 });
if (uid) q.set("user_id", uid);
try {
const data = await api(`/groups/${gid}/logs?${q}`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
<td>${esc(r.content)}</td></tr>`).join("") || `<tr><td colspan="5" class="muted">暂无语料</td></tr>`;
const pages = Math.max(1, Math.ceil(data.total / 50));
document.getElementById("logPager").innerHTML =
`<button class="ghost" ${page <= 1 ? "disabled" : ""} onclick="loadLogs(${gid}, ${page - 1})">上一页</button>
<span class="muted">第 ${page}/${pages} 页 · 共 ${data.total} 条</span>
<button class="ghost" ${page >= pages ? "disabled" : ""} onclick="loadLogs(${gid}, ${page + 1})">下一页</button>`;
} catch (e) { alert(e.message); }
}
async function clearLogs(gid) {
if (!confirm("删除该群全部语料?印象/画像保留")) return;
try { await api(`/groups/${gid}/logs`, { method: "DELETE" }); loadLogs(gid, 1); } catch (e) { alert(e.message); }
}
async function loadImpressions(gid) {
const tbody = document.getElementById("impressionRows");
try {
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|| `<tr><td colspan="5" class="muted">暂无印象</td></tr>`;
} catch (e) { alert(e.message); }
}
async function loadSummaries(gid) {
const box = document.getElementById("summaryList");
try {
const items = await api(`/groups/${gid}/summaries`);
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|| `<p class="muted">暂无画像(语料积累后由总结路径生成)</p>`;
} catch (e) { alert(e.message); }
}
async function clearGroup(gid) {
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
try { await api(`/groups/${gid}`, { method: "DELETE" }); location.hash = "#/groups"; route(); } catch (e) { alert(e.message); }
}
/* ---------- 数据浏览(跨群) ---------- */
async function renderDataBrowser(content) {
content.innerHTML = pageHead("数据浏览") +
`<div class="card"><div class="row">
<select id="dataGroupSelect" onchange="dataSelectGroup()"><option value="">选择群…</option></select>
<span class="muted" id="dataGroupInfo"></span></div>
<div class="tabs">
<button id="dTabLogs" class="active" onclick="dataTab('logs')">语料</button>
<button id="dTabImpressions" onclick="dataTab('impressions')">印象</button>
<button id="dTabSummaries" onclick="dataTab('summaries')">画像</button>
</div>
<div id="dTabLogsView">
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
<tbody id="dLogRows"><tr><td colspan="5" class="muted">请先选择群</td></tr></tbody></table>
</div>
<div id="dTabImpressionsView" class="hidden">
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
<tbody id="dImpressionRows"></tbody></table>
</div>
<div id="dTabSummariesView" class="hidden"><div id="dSummaryList"></div></div>
</div>`;
try {
const groups = await api("/groups");
document.getElementById("dataGroupSelect").innerHTML =
`<option value="">选择群…</option>` + groups.map(g => `<option value="${g.group_id}">${esc(g.group_name)}(${g.group_id})</option>`).join("");
} catch (e) { alert(e.message); }
}
async function dataSelectGroup() {
const gid = Number(document.getElementById("dataGroupSelect").value);
window._dataGid = gid;
document.getElementById("dataGroupInfo").textContent = gid ? "" : "";
if (!gid) return;
dataTab("logs");
}
function dataTab(tab) {
["logs", "impressions", "summaries"].forEach(t => {
const name = "d" + t[0].toUpperCase() + t.slice(1);
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}`).classList.toggle("active", t === tab);
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}View`).classList.toggle("hidden", t !== tab);
});
const gid = window._dataGid;
if (!gid) return;
if (tab === "logs") loadDataLogs(gid);
if (tab === "impressions") loadDataImpressions(gid);
if (tab === "summaries") loadDataSummaries(gid);
}
async function loadDataLogs(gid) {
const tbody = document.getElementById("dLogRows");
try {
const data = await api(`/groups/${gid}/logs?page=1&per_page=100`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
<td>${esc(r.content)}</td></tr>`).join("")
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
} catch (e) { alert(e.message); }
}
async function loadDataImpressions(gid) {
const tbody = document.getElementById("dImpressionRows");
try {
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
} catch (e) { alert(e.message); }
}
async function loadDataSummaries(gid) {
const box = document.getElementById("dSummaryList");
try {
const items = await api(`/groups/${gid}/summaries`);
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|| `<p class="muted">暂无数据</p>`;
} catch (e) { alert(e.message); }
}
/* ---------- 设置 ---------- */
async function renderSettings(content) {
content.innerHTML = pageHead("设置") + `<div class="card" id="settingsBody"><p class="muted">加载中…</p></div>`;
try {
const s = await api("/settings");
const rows = [
["Web 登录账号", s.web_username],
["Web 后台启用", s.web_enable ? "是" : "否"],
["长文截断上限", `${s.thresholds.max_content_len} 字`],
["复读检测窗口", `${s.thresholds.repeat_window} 条`],
["刷屏采样间隔", `${s.thresholds.throttle_seconds} 秒`],
["单用户语料滚动上限", `${s.thresholds.rolling_window} 条`],
];
document.getElementById("settingsBody").innerHTML =
`<h2>当前配置(只读)</h2>
<table>${rows.map(r => `<tr><th style="width:200px">${r[0]}</th><td>${esc(r[1])}</td></tr>`).join("")}</table>
<p class="muted" style="margin-top:12px">阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>`;
} catch (e) { document.getElementById("settingsBody").innerHTML = `<p class="msg err">${esc(e.message)}</p>`; }
}
/* ---------- 启动 ---------- */
window.addEventListener("hashchange", route);
if (TOKEN) { showMain(); route(); } else { showLogin(); }
</script>
</body>
</html>
@@ -1,12 +0,0 @@
<!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
@@ -1,30 +0,0 @@
{
"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"
}
}
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -1,112 +0,0 @@
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>
);
}
@@ -1,92 +0,0 @@
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;
};
}
@@ -1,24 +0,0 @@
@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;
}
@@ -1,20 +0,0 @@
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>
);
@@ -1,171 +0,0 @@
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>
);
}
@@ -1,334 +0,0 @@
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>
);
}
@@ -1,227 +0,0 @@
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>
);
}
@@ -1,58 +0,0 @@
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>
);
}
@@ -1,71 +0,0 @@
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>
);
}
@@ -1,12 +0,0 @@
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()],
};
@@ -1,20 +0,0 @@
{
"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"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "/galgame_card/",
plugins: [react()],
});
@@ -2,7 +2,7 @@
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
旧版独立后台 /galgame_card(JWT 自鉴权)仍保留可用,前端由 hub 渲染。
前端由统一管理台 hexi/web 渲染。
"""
from __future__ import annotations
@@ -15,7 +15,6 @@ from fastapi.responses import JSONResponse
from hexi.web_auth import require_admin
from . import repository
from .config import config
API = require_admin
@@ -42,11 +41,8 @@ async def _first_bot():
def build_admin_app() -> FastAPI | None:
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
在 hub 统一登录态下可用;未启用 Web 时返回 None。
鉴权由 hub 统一登录态提供(hexi.web_auth)。
"""
if not config.web_enable:
return None
app = FastAPI(title="Galgame Card API")
@app.get("/groups", dependencies=[API])
@@ -213,8 +209,6 @@ def build_admin_app() -> FastAPI | None:
return _ok(
{
"web_username": config.web_username,
"web_enable": config.web_enable,
"thresholds": {
"max_content_len": processor.MAX_CONTENT_LEN,
"repeat_window": processor.REPEAT_WINDOW,