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
+355
View File
@@ -0,0 +1,355 @@
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Input, Label, Select, ListBox, TextField, toast } from '@heroui/react'
import { api } from '../../api/client'
const gc = api('galgame_card')
interface Group {
group_id: number; group_name: string; enabled: boolean
participants: number; corpus_count: number; impression_count: number; summary_count: number
}
interface Participant { user_id: number; corpus_count: number }
interface LogRow { id: number; user_id: number; nickname?: string; content: string; target_user_id?: number | null; created_at: string }
interface ImpressionRow { id: number; user_id: number; content: string; cover_from_id: number; cover_to_id: number; created_at: string }
interface SummaryRow { version: number; card_text: string; corpus_count: number; impression_count: number; created_at: string }
interface SettingsData { web_username: string; web_enable: boolean; thresholds: Record<string, number> }
function unpack(res: any, errorLabel: string = '操作失败') {
if (res && res.status !== 0) throw new Error(res.msg || errorLabel)
return res ? res.data : null
}
const selectCls = 'bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
const inputCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
function fmtTime(ts: string) { return ts || '—' }
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) {
return (
<div className="flex gap-1 border-b border-slate-200 mb-4">
{tabs.map(t => (
<button key={t} onClick={() => onChange(t)}
className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>
{t}
</button>
))}
</div>
)
}
export default function GalgameCardPage() {
const [view, setView] = useState<'groups' | 'detail' | 'data' | 'settings'>('groups')
const [groups, setGroups] = useState<Group[]>([])
const [activeGid, setActiveGid] = useState<number | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const loadGroups = useCallback(async () => {
setLoading(true); setError('')
try {
const data = await unpack(await gc.get('groups'), '加载群列表失败')
setGroups(data || [])
} catch (e: any) { setError(e.message || '加载失败') } finally { setLoading(false) }
}, [])
useEffect(() => { loadGroups() }, [loadGroups])
const toggleGroup = async (g: Group, enabled: boolean) => {
try { await gc.post(`groups/${g.group_id}/enabled`, { enabled }); await loadGroups() }
catch (e: any) { toast.danger(e.message || '切换失败') }
}
const openDetail = (g: Group) => { setActiveGid(g.group_id); setView('detail') }
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-800">群聊人设卡</h2>
<p className="text-sm text-slate-400">群采集开关 · 参与者 · 语料/印象/画像浏览</p>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-1">
{(['groups', 'data', 'settings'] as const).map(v => (
<Button key={v} size="sm" variant={view === v ? 'primary' : 'tertiary'} onPress={() => setView(v)}>
{v === 'groups' ? '群管理' : v === 'data' ? '数据浏览' : '设置'}
</Button>
))}
</div>
<Button size="sm" isDisabled={loading} onPress={() => { setView('groups'); loadGroups() }}>{loading ? '加载中…' : '刷新'}</Button>
</div>
</div>
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
{view === 'groups' && (
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>群采集开关</Card.Title></Card.Header>
<Card.Content>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<thead><tr>
<th className={thCls}>群</th><th className={thCls}>开关</th><th className={thCls}>参与者</th><th className={thCls}>语料</th><th className={thCls}>印象</th><th className={thCls}>画像</th><th className={thCls}></th>
</tr></thead>
<tbody>
{groups.map(g => (
<tr key={g.group_id}>
<td className={tdCls}><div className="font-medium text-slate-800">{g.group_name || '(未命名群)'}</div><div className="text-xs text-slate-400">{g.group_id}</div></td>
<td className={tdCls}>
<button onClick={() => toggleGroup(g, !g.enabled)}
className={'relative h-5 w-10 rounded-full transition-colors ' + (g.enabled ? 'bg-emerald-500' : 'bg-slate-300')}>
<span className={'absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ' + (g.enabled ? 'left-5' : 'left-0.5')} />
</button>
</td>
<td className={tdCls}>{g.participants}</td><td className={tdCls}>{g.corpus_count}</td><td className={tdCls}>{g.impression_count}</td><td className={tdCls}>{g.summary_count}</td>
<td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" onPress={() => openDetail(g)}>管理</Button></td>
</tr>
))}
{!groups.length && <tr><td colSpan={7} className={tdCls + ' text-slate-400 text-center'}>暂无管理中的群,请先在「添加群」中添加(或等待数据采集自动登记)</td></tr>}
</tbody>
</table>
</div>
</Card.Content>
</Card>
)}
{view === 'detail' && activeGid !== null && <GroupDetail gid={activeGid} onBack={() => setView('groups')} />}
{view === 'data' && <DataBrowser groups={groups} />}
{view === 'settings' && <SettingsView />}
</div>
)
}
function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
const [tab, setTab] = useState<'participants' | 'logs' | 'impressions' | 'summaries'>('participants')
const [participants, setParticipants] = useState<Participant[]>([])
const [logs, setLogs] = useState<LogRow[]>([])
const [logTotal, setLogTotal] = useState(0)
const [logPage, setLogPage] = useState(1)
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
const [summaries, setSummaries] = useState<SummaryRow[]>([])
const [addUserId, setAddUserId] = useState('')
const [logFilter, setLogFilter] = useState('')
const loadParticipants = useCallback(async () => {
try { const d = await unpack(await gc.get(`groups/${gid}/participants`), '加载参与者失败'); setParticipants(d || []) } catch (e: any) { toast.danger(e.message) }
}, [gid])
const loadLogs = useCallback(async (page: number, userId = '') => {
const q = new URLSearchParams({ page: String(page), per_page: '50' })
if (userId) q.set('user_id', userId)
try {
const d = await unpack(await gc.get(`groups/${gid}/logs?${q}`), '加载语料失败')
setLogs((d && d.items) || []); setLogTotal((d && d.total) || 0); setLogPage(page)
} catch (e: any) { toast.danger(e.message) }
}, [gid])
const loadImpressions = useCallback(async () => {
try { const d = await unpack(await gc.get(`groups/${gid}/impressions?page=1&per_page=100`), '加载印象失败'); setImpressions((d && d.items) || []) } catch (e: any) { toast.danger(e.message) }
}, [gid])
const loadSummaries = useCallback(async () => {
try { const d = await unpack(await gc.get(`groups/${gid}/summaries`), '加载画像失败'); setSummaries(d || []) } catch (e: any) { toast.danger(e.message) }
}, [gid])
useEffect(() => { loadParticipants() }, [loadParticipants])
useEffect(() => {
if (tab === 'logs') loadLogs(1, logFilter)
else if (tab === 'impressions') loadImpressions()
else if (tab === 'summaries') loadSummaries()
}, [tab]) // eslint-disable-line react-hooks/exhaustive-deps
const addParticipant = async () => {
if (!addUserId.trim()) return
try { await gc.post(`groups/${gid}/participants`, { user_id: Number(addUserId.trim()) }); setAddUserId(''); loadParticipants() }
catch (e: any) { toast.danger(e.message || '添加失败') }
}
const removeParticipant = async (uid: number) => {
if (!window.confirm(`移除 ${uid}?其语料保留在库中`)) return
try { await gc.del(`groups/${gid}/participants/${uid}`); loadParticipants() } catch (e: any) { toast.danger(e.message) }
}
const clearGroup = async () => {
if (!window.confirm('确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复')) return
try { await gc.del(`groups/${gid}/data`); toast.success('已清空'); loadParticipants(); loadLogs(1); loadImpressions(); loadSummaries() } catch (e: any) { toast.danger(e.message) }
}
const clearLogs = async () => {
if (!window.confirm('删除该群全部语料?印象/画像保留')) return
try { await gc.del(`groups/${gid}/logs`); loadLogs(1) } catch (e: any) { toast.danger(e.message) }
}
const pagination = Math.max(1, Math.ceil(logTotal / 50))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button size="sm" variant="tertiary" onPress={onBack}>← 返回</Button>
<span className="text-sm font-semibold text-slate-800">群 #{gid} 管理</span>
</div>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={clearGroup}>清空该群全部数据</Button>
</div>
<Card className="border border-slate-200 bg-white">
<Card.Content>
<Tabs tabs={['参与者', '语料', '印象', '画像']} active={{ participants: '参与者', logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '参与者': 'participants', '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
{tab === 'participants' && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<TextField><Label>QQ 号</Label><Input value={addUserId} onChange={e => setAddUserId(e.target.value)} placeholder="加入人设名单" /></TextField>
<Button size="sm" onPress={addParticipant}>加入人设名单</Button>
<span className="text-xs text-slate-400">仅名单内成员会被采集</span>
</div>
<table className="w-full">
<thead><tr><th className={thCls}>QQ</th><th className={thCls}>语料条数</th><th className={thCls}></th></tr></thead>
<tbody>
{participants.map(p => (
<tr key={p.user_id}><td className={tdCls}>{p.user_id}</td><td className={tdCls}>{p.corpus_count}</td><td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeParticipant(p.user_id)}>移除</Button></td></tr>
))}
{!participants.length && <tr><td colSpan={3} className={tdCls + ' text-slate-400 text-center'}>暂无参与者</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'logs' && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<TextField><Label>按 QQ 过滤</Label><Input value={logFilter} onChange={e => setLogFilter(e.target.value)} placeholder="可选" /></TextField>
<Button size="sm" onPress={() => loadLogs(1, logFilter)}>查询</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={clearLogs}>删除全部语料</Button>
</div>
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
<tbody>
{logs.map(r => (
<tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
))}
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无语料</td></tr>}
</tbody>
</table>
<div className="flex items-center gap-3 text-xs text-slate-400">
<Button size="sm" variant="tertiary" isDisabled={logPage <= 1} onPress={() => loadLogs(logPage - 1, logFilter)}>上一页</Button>
<span>第 {logPage} / {pagination} 页 · 共 {logTotal} 条</span>
<Button size="sm" variant="tertiary" isDisabled={logPage >= pagination} onPress={() => loadLogs(logPage + 1, logFilter)}>下一页</Button>
</div>
</div>
)}
{tab === 'impressions' && (
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
<tbody>
{impressions.map(r => (
<tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
))}
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无印象</td></tr>}
</tbody>
</table>
)}
{tab === 'summaries' && (
<div className="space-y-3">
{summaries.map(s => (
<div key={s.version} className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<div className="mb-1 text-xs text-slate-500">v{s.version} · {fmtTime(s.created_at)} · 语料 {s.corpus_count} 条 · 印象 {s.impression_count} 条</div>
<pre className="whitespace-pre-wrap text-xs text-slate-700">{s.card_text}</pre>
</div>
))}
{!summaries.length && <p className="text-sm text-slate-400">暂无画像(语料积累后由总结路径生成)</p>}
</div>
)}
</Card.Content>
</Card>
</div>
)
}
function DataBrowser({ groups }: { groups: Group[] }) {
const [gid, setGid] = useState<number | ''>('')
const [tab, setTab] = useState<'logs' | 'impressions' | 'summaries'>('logs')
const [logs, setLogs] = useState<LogRow[]>([])
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
const [summaries, setSummaries] = useState<SummaryRow[]>([])
const [err, setErr] = useState('')
const load = useCallback(async () => {
setErr('')
if (!gid) return
try {
if (tab === 'logs') { const d = await unpack(await gc.get(`groups/${gid}/logs?page=1&per_page=100`), '加载失败'); setLogs((d && d.items) || []) }
else if (tab === 'impressions') { const d = await unpack(await gc.get(`groups/${gid}/impressions?page=1&per_page=100`), '加载失败'); setImpressions((d && d.items) || []) }
else { const d = await unpack(await gc.get(`groups/${gid}/summaries`), '加载失败'); setSummaries(d || []) }
} catch (e: any) { setErr(e.message) }
}, [gid, tab])
useEffect(() => { load() }, [load])
return (
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>数据浏览</Card.Title></Card.Header>
<Card.Content className="space-y-3">
<div className="flex items-center gap-3">
<Select selectedKey={gid === '' ? '' : String(gid)} onSelectionChange={(k) => setGid(k ? Number(k) : '')} aria-label="选择群">
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover><ListBox>
{groups.map(g => <ListBox.Item key={g.group_id} id={String(g.group_id)}>{g.group_name || g.group_id}({g.group_id})</ListBox.Item>)}
</ListBox></Select.Popover>
</Select>
{err && <span className="text-xs text-red-500">{err}</span>}
</div>
<Tabs tabs={['语料', '印象', '画像']} active={{ logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
{tab === 'logs' && (
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
<tbody>
{logs.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
</tbody>
</table>
)}
{tab === 'impressions' && (
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
<tbody>
{impressions.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
</tbody>
</table>
)}
{tab === 'summaries' && (
<div className="space-y-3">
{summaries.map(s => <div key={s.version} className="rounded-lg border border-slate-200 bg-slate-50 p-3"><div className="mb-1 text-xs text-slate-500">v{s.version} · {fmtTime(s.created_at)}</div><pre className="whitespace-pre-wrap text-xs text-slate-700">{s.card_text}</pre></div>)}
{!summaries.length && <p className="text-sm text-slate-400">{gid ? '暂无数据' : '请先选择群'}</p>}
</div>
)}
</Card.Content>
</Card>
)
}
function SettingsView() {
const [data, setData] = useState<SettingsData | null>(null)
useEffect(() => {
gc.get('settings').then(res => { try { setData(unpack(res)) } catch { } }).catch(() => {})
}, [])
const rows = data ? [
['Web 登录账号', data.web_username],
['Web 后台启用', data.web_enable ? '是' : '否'],
['长文截断上限', (data.thresholds?.max_content_len ?? '—') + ' 字'],
['复读检测窗口', (data.thresholds?.repeat_window ?? '—') + ' 条'],
['刷屏采样间隔', (data.thresholds?.throttle_seconds ?? '—') + ' 秒'],
['单用户语料滚动上限', (data.thresholds?.rolling_window ?? '—') + ' 条'],
] : []
return (
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>当前配置(只读)</Card.Title></Card.Header>
<Card.Content>
{!data ? <p className="text-sm text-slate-400">加载中…</p> : (
<table className="w-full max-w-[560px]">
<tbody>{rows.map(([k, v]) => <tr key={k}><td className={tdCls + ' w-48 text-slate-400'}>{k}</td><td className={tdCls}>{v}</td></tr>)}</tbody>
</table>
)}
<p className="mt-3 text-xs text-slate-400">阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>
</Card.Content>
</Card>
)
}