feat: 群分析本地消息库 / 处理中占位图 / Web 日志窗口重构

- 群分析: 新增 history_store 只读本地消息源(读 learning_chat 落库,含 uninfo
  昵称补齐/去重/截断判定),适配器优先读本地库、失败回退 OneBot 分页;分页
  锚点字段回退链修复 NapCat 传 message_seq 翻页断裂;新增「本地记录」开关
- core/message_utils: 新增 common_proc_reply 占位图通用回复(引用消息 + 处理中
  动图,支持后台任务显式指定 target),群分析/战况/倒放改用
- web_hub + web: 日志页改固定窗口滚动 + 翻页锚定 + 自动换行,SSE 日志轮转发
  reset 帧,入口 HTML no-cache,行数统计增量缓存,CPU 改非阻塞采样,登录信息缓存
- 插件内 CLAUDE.md / DESIGN.md 不入库(.gitignore),galgame_card 两份文档取消
  跟踪(文件保留在磁盘)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:59:01 +08:00
co-authored by Claude Code
parent 662c28cb2f
commit 51b08ccb68
23 changed files with 1100 additions and 393 deletions
+5 -1
View File
@@ -140,13 +140,17 @@ function makeSSEStream(opts: SSEStreamOpts): () => void {
export function hubLogStream(opts: {
since: number
onLine: (line: string) => void
// 服务端检测到日志被轮转/清空时回调,调用方应丢弃已失效的旧窗口
onReset?: () => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
}): () => void {
let since = opts.since
return makeSSEStream({
url: () => '/hub/api/logs/stream?since=' + since,
onData: (obj) => {
if (obj && obj.line !== undefined) {
if (!obj) return
if (obj.reset) { opts.onReset?.(); return }
if (obj.line !== undefined) {
opts.onLine(obj.line)
if (typeof obj.offset === 'number') since = obj.offset
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { hubDashboardStream } from './api/client'
export type ConnStatus = '连接中' | '实时' | '重连中' | '已断开'
export type ConnStatus = '连接中' | '已连接' | '重连中' | '已断开'
interface ConnState {
status: ConnStatus
@@ -28,7 +28,7 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
if (d && d.ok !== false) { setData(d); setError(''); setLastUpdate(Date.now()) }
else if (d && d.ok === false) setError(d.msg || '采集失败')
},
onStatus: (s) => setStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
onStatus: (s) => setStatus(s === 'open' ? '已连接' : s === 'reconnecting' ? '重连中' : '已断开'),
})
return dispose
}, [])
+2 -2
View File
@@ -192,9 +192,9 @@ export function AppLayout() {
<div className="flex shrink-0 items-center gap-2 md:gap-3">
<span
title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'}
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '实时' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '已连接' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
>
<span className={'size-1.5 rounded-full ' + (status === '实时' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
<span className={'size-1.5 rounded-full ' + (status === '已连接' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
{status}
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
</span>
+119 -43
View File
@@ -1,22 +1,36 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { hubLogs, hubLogStream } from './api/client'
// 保留的最大行数(窗口较大以容纳「加载更早」预置的历史行)
const MAX_LINES = 6000
// 渲染窗口:日志页**始终只渲染这么多行**,先入先出——新行从尾部挤入,最旧的行从头部被挤掉。
// 行数直接决定每次更新的 DOM 协调与布局开销,是页面的主要成本,不要随意调大。
const WINDOW_LINES = 1000
// 「向前翻页」每次额外取的行数:比窗口小,翻页时保留一半旧内容做重叠,
// 配合浏览器的滚动锚定,视线里的那行不会跳走。
const PAGE_LINES = 500
// 新行合并刷新间隔(ms):日志突发时避免「一行一次 setState」
const FLUSH_MS = 120
export interface LogLine { id: number; text: string }
export interface LogInfo { file: string; size: number; total: number }
interface LogState {
lines: string[]
lines: LogLine[]
info: LogInfo
error: string
loading: boolean
hasMore: boolean
loadingEarlier: boolean
paused: boolean
setPaused: (v: boolean) => void
/** true = 自动滚动(跟随最新,新行实时入列);false = 冻结视图(新行暂存,回到底部再补上) */
follow: boolean
setFollow: (v: boolean) => void
/** 自动换行:false 时长行不折行,改为横向滚动 */
wrap: boolean
setWrap: (v: boolean) => void
refresh: () => Promise<void>
loadEarlier: () => Promise<void>
/** 向前翻一页;返回本次前置进来的行数(0 表示没翻成),调用方据此把视图锚回原处 */
loadEarlier: () => Promise<number>
}
const Ctx = createContext<LogState>({
@@ -26,62 +40,110 @@ const Ctx = createContext<LogState>({
loading: false,
hasMore: false,
loadingEarlier: false,
paused: false,
setPaused: () => {},
follow: true,
setFollow: () => {},
wrap: true,
setWrap: () => {},
refresh: async () => {},
loadEarlier: async () => {},
loadEarlier: async () => 0,
})
/**
* 日志数据与日志流常驻 Provider:首次进入(任意页面)时拉取一次,
* SSE 流保持不断,页面之间切换只读缓存数据,不重建连接、不显示加载动画。
*
* 窗口模型:内存里始终只有 WINDOW_LINES 行(先入先出)。
* - 自动滚动:新行进窗口尾部,最旧的行被挤掉(tail -f 行为)
* - 冻结视图:新行只进暂存区(同样保留最新 WINDOW_LINES 行),窗口不变,
* 回到底部时一次性补上
* - 向前翻页:滚动到顶部时取更早的 PAGE_LINES 行,从尾部挤掉同样多的新行,
* 窗口行数不变(反过来的先入先出)
*/
export function LogProvider({ children }: { children: ReactNode }) {
const [lines, setLines] = useState<string[]>([])
const [lines, setLines] = useState<LogLine[]>([])
const [info, setInfo] = useState<LogInfo>({ file: '', size: 0, total: 0 })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [paused, setPaused] = useState(false)
const [follow, setFollow] = useState(true)
const [wrap, setWrap] = useState(true)
const sinceRef = useRef(0)
const earliestRef = useRef(0)
const pausedRef = useRef(false)
const pendingRef = useRef<string[]>([])
const followRef = useRef(true)
const frozenRef = useRef<string[]>([])
const disposeRef = useRef<(() => void) | null>(null)
const loadingEarlierRef = useRef(false)
const append = useCallback((line: string) => {
setLines(prev => [...prev, line].slice(-MAX_LINES))
// 行 id:作为 React key 保持稳定(用下标做 key 时,窗口一滑动整列表都要 diff)
const idRef = useRef(0)
// 待入列的新行与合并刷新定时器
const bufferRef = useRef<string[]>([])
const timerRef = useRef<number | null>(null)
const toItems = useCallback((texts: string[]): LogLine[] => {
const items: LogLine[] = []
for (const text of texts) items.push({ id: idRef.current++, text })
return items
}, [])
const flush = useCallback(() => {
timerRef.current = null
const buffered = bufferRef.current
if (!buffered.length) return
bufferRef.current = []
// 一次涌入超过整个窗口时(日志突发/整文件重放),只有最后 WINDOW_LINES 行留得下,
// 先裁掉前面的,避免为注定被挤掉的行白渲染一遍
const kept = buffered.length > WINDOW_LINES ? buffered.slice(-WINDOW_LINES) : buffered
// 副作用放在 setState 之外:StrictMode 下 updater 可能被调用两次
const items = toItems(kept)
setLines(prev => {
const next = prev.concat(items)
return next.length > WINDOW_LINES ? next.slice(-WINDOW_LINES) : next
})
}, [toItems])
const enqueue = useCallback((line: string) => {
if (!followRef.current) {
// 冻结视图期间只暂存,且同样只保留最新 WINDOW_LINES 行
frozenRef.current.push(line)
if (frozenRef.current.length > WINDOW_LINES) {
frozenRef.current.splice(0, frozenRef.current.length - WINDOW_LINES)
}
return
}
bufferRef.current.push(line)
if (timerRef.current == null) timerRef.current = window.setTimeout(flush, FLUSH_MS)
}, [flush])
const connect = useCallback(() => {
if (disposeRef.current) disposeRef.current()
disposeRef.current = hubLogStream({
since: sinceRef.current,
onLine: (line) => {
if (pausedRef.current) {
pendingRef.current.push(line)
// 暂停期间只保留最近若干行,避免恢复时一次性渲染超大数组
if (pendingRef.current.length > 500) pendingRef.current.splice(0, pendingRef.current.length - 500)
return
}
append(line)
onLine: enqueue,
onReset: () => {
// 服务端日志被轮转/清空:丢弃已失效的旧窗口,从新文件继续
if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null }
bufferRef.current = []
frozenRef.current = []
setLines([])
earliestRef.current = 0
setHasMore(false)
},
})
}, [append])
}, [enqueue])
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const d = await hubLogs(500)
const d = await hubLogs(WINDOW_LINES)
if (!d) throw new Error('读取日志失败')
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
setLines(d.lines || [])
setLines(toItems(d.lines || []))
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
// stream 从文件尾续读(end);「加载更早」从窗口起点向前翻(offset)
// stream 从文件尾续读(end);向前翻页从窗口起点继续往前(offset)
sinceRef.current = d.end || 0
earliestRef.current = d.offset || 0
setHasMore((d.offset || 0) > 0)
@@ -89,47 +151,61 @@ export function LogProvider({ children }: { children: ReactNode }) {
} catch (e: any) {
setError(e.message || '读取日志失败')
} finally { setLoading(false) }
}, [connect])
}, [connect, toItems])
const loadEarlier = useCallback(async () => {
const loadEarlier = useCallback(async (): Promise<number> => {
const before = earliestRef.current
if (!before || loadingEarlierRef.current) return
if (!before || loadingEarlierRef.current) return 0
loadingEarlierRef.current = true
setLoadingEarlier(true)
setError('')
try {
const d = await hubLogs(500, before)
const d = await hubLogs(PAGE_LINES, before)
if (!d || d.ok === false) throw new Error((d && d.msg) || '读取日志失败')
setLines(prev => [...(d.lines || []), ...prev].slice(-MAX_LINES))
const items = toItems(d.lines || [])
setLines(prev => {
const next = items.concat(prev)
// 往前翻时从尾部(较新的行)挤掉:窗口行数不变,新的行让位给历史
return next.length > WINDOW_LINES ? next.slice(0, WINDOW_LINES) : next
})
earliestRef.current = d.offset || 0
setHasMore((d.offset || 0) > 0)
return items.length
} catch (e: any) {
setError(e.message || '读取日志失败')
return 0
} finally {
loadingEarlierRef.current = false
setLoadingEarlier(false)
}
}, [])
}, [toItems])
// 首次进入就建立并保持;provider 卸载(登出/离开 /hub)时才断开
useEffect(() => {
refresh()
return () => { if (disposeRef.current) disposeRef.current() }
return () => {
if (disposeRef.current) disposeRef.current()
// 组件卸载时清掉待刷新定时器,避免对已卸载组件 setState
if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null }
}
}, [refresh])
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => {
// 恢复接收时,把暂停期间暂存的行补进来
if (!paused && pendingRef.current.length) {
const flush = pendingRef.current
pendingRef.current = []
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
followRef.current = follow
// 恢复自动滚动时,把冻结期间暂存的行一次性补上(仍只保留 WINDOW_LINES 行)
if (follow && frozenRef.current.length) {
const frozen = frozenRef.current
frozenRef.current = []
const items = toItems(frozen)
setLines(prev => {
const next = prev.concat(items)
return next.length > WINDOW_LINES ? next.slice(-WINDOW_LINES) : next
})
}
}, [paused])
}, [follow, toItems])
return (
<Ctx.Provider value={{ lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, refresh, loadEarlier }}>
<Ctx.Provider value={{ lines, info, error, loading, hasMore, loadingEarlier, follow, setFollow, wrap, setWrap, refresh, loadEarlier }}>
{children}
</Ctx.Provider>
)
+66 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { Button, Card, Spinner } from '@heroui/react'
import { useLogs } from '../../logs'
import { fmtBytes } from '../../lib/format'
@@ -67,7 +67,9 @@ function parseAnsi(text: string): { text: string; style: CSSProperties }[] {
return out
}
function AnsiLine({ text }: { text: string }) {
// 记忆化:窗口内已渲染过的行必须能整体跳过,否则每次更新都要把整窗口的
// ANSI 解析 + DOM 协调重做一遍。
const AnsiLine = memo(function AnsiLine({ text }: { text: string }) {
const parts = parseAnsi(text)
const hasAnsi = parts.some((p) => Boolean(p.style.color || p.style.backgroundColor || p.style.fontWeight || p.style.textDecoration))
if (hasAnsi) {
@@ -78,13 +80,16 @@ function AnsiLine({ text }: { text: string }) {
const color = m && LEVEL_COLORS[m[1]]
if (color) return <span style={{ color }}>{text}</span>
return <>{text}</>
}
})
export default function LogsPage() {
// 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs()
const [follow, setFollow] = useState(true)
const { lines, info, error, loading, hasMore, loadingEarlier, follow, setFollow, wrap, setWrap, loadEarlier } = useLogs()
const scrollRef = useRef<HTMLDivElement>(null)
// 一次「滚到顶」只翻一页,必须离开顶部后才会再次触发(避免连环翻页)
const topLoadBlockRef = useRef(false)
// 本次前置进来的行数:等 DOM 更新后据此把视图锚回原来的位置
const pendingAnchorRef = useRef<number | null>(null)
useEffect(() => {
if (follow && scrollRef.current) {
@@ -92,19 +97,56 @@ export default function LogsPage() {
}
}, [lines, follow])
// 向前翻页后把新行(它们被插在顶部)往上推出视野,让原本正在看的那一行留在原处。
// 窗口行数固定,翻页前后总高度几乎不变,浏览器自带的滚动锚定在 scrollTop=0 时也不生效,
// 所以这里手动按元素位置锚定。
useEffect(() => {
const n = pendingAnchorRef.current
if (n == null) return
const el = scrollRef.current
if (!el) { pendingAnchorRef.current = null; return }
const first = el.firstElementChild as HTMLElement | null
const target = el.children[n] as HTMLElement | undefined
if (!first || !target) return // DOM 还没更新,等下一次 lines 变化再试
el.scrollTop = target.offsetTop - first.offsetTop
pendingAnchorRef.current = null
}, [lines])
const handleLoadEarlier = useCallback(async () => {
const n = await loadEarlier()
if (n > 0) pendingAnchorRef.current = n
}, [loadEarlier])
const onScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
if (atBottom) {
// 回到底部即恢复跟随(否则新日志会把视图拽走,永远看不了历史)
if (!follow) setFollow(true)
return
}
if (follow) setFollow(false)
if (el.scrollTop > 8) {
topLoadBlockRef.current = false
return
}
if (!topLoadBlockRef.current && hasMore && !loadingEarlier) {
topLoadBlockRef.current = true
handleLoadEarlier()
}
}, [follow, hasMore, loadingEarlier, handleLoadEarlier, setFollow])
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p>
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送,固定窗口滚动,向上滚动自动加载更早)</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
{hasMore && (
<Button size="sm" variant="tertiary" isDisabled={loadingEarlier} onPress={loadEarlier}>{loadingEarlier ? '加载中…' : '加载更早'}</Button>
)}
<Button size="sm" variant="tertiary" onPress={() => setPaused(!paused)}>{paused ? '恢复接收' : '暂停接收'}</Button>
<Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
<Button size="sm" variant="tertiary" onPress={() => setFollow(!follow)}>{follow ? '停止跟随' : '跟随最新'}</Button>
<Button size="sm" variant="tertiary" onPress={() => setWrap(!wrap)}>{wrap ? '关闭换行' : '自动换行'}</Button>
</div>
</div>
@@ -113,16 +155,25 @@ export default function LogsPage() {
<Card className="border border-slate-200 bg-white">
<Card.Header>
<div className="flex w-full items-center justify-between">
<Card.Title>日志流</Card.Title>
<Card.Title>
日志流
{loadingEarlier && <span className="ml-2 text-xs font-normal text-slate-400">加载更早…</span>}
</Card.Title>
{info.file && <span className="text-xs text-slate-400">{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
</div>
</Card.Header>
<Card.Content>
<div ref={scrollRef} className="h-[60vh] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100 md:h-[calc(100vh-320px)]">
<div
ref={scrollRef}
onScroll={onScroll}
// white-space / word-break 都是可继承属性,放在容器上:切换「自动换行」只改一个 class,
// 不必逐行改 1000 个元素(那会导致整列表重排,实测一次卡 100ms+)
className={`h-[60vh] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100 md:h-[calc(100vh-320px)] ${wrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
>
{loading && !lines.length ? (
<div className="flex h-full items-center justify-center"><Spinner /></div>
) : lines.length ? lines.map((ln, i) => (
<div key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
) : lines.length ? lines.map(ln => (
<div key={ln.id}><AnsiLine text={ln.text} /></div>
)) : (
<div className="text-slate-500">暂无日志…</div>
)}