feat(channels): 管理面板消息日志——chat_messages 加 adapter_id + 配置级/平台/总览三粒度端点
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

后端:
- 迁移 0007:chat_messages 加 adapter_id 列 + 索引
- history.Message 加 AdapterID;persistToMain/persistIsolated 传适配器名(a.Name())
- channelbridge: 4 个持久化点 + registerSilenced 传 a;light.go registerSilenced 签名加 a
- Store 新增 ListAdapterLog(?adapter=, 不排除主会话)/ListAllLog(adapter_id!='', 总览);ListPlatformLog 保留
- 新端点 GET /api/v1/admin/channels/messages:?adapter= 配置级 / ?platform= 平台 / 无参=总览,historyMessage 加 adapterId
- 测试:ListAdapterLog/ListAllLog 过滤用例 + 端点粒度用例 + 桥接持久化断言

前端:渠道管理每配置(账号)「消息日志」按钮 + 页顶「适配器总览日志」入口 + ChannelLogPanel 弹窗(总览/配置级),Miuix 风格,build 绿。
This commit is contained in:
Aska committed 2026-09-07 17:25:23 +08:00
1 parent c1bd740f7c
commit 2e044d1d19
15 files changed
+560 -41

No files matched your search

+96 -4
View File
@@ -5,9 +5,11 @@ import {
adminChannelOpen,
adminChannelClose,
adminChannelTest,
adminChannelMessages,
adminChannelLogOverview,
adminDeleteChannelAccount,
} from "../../lib/api";
import type { AdminChannelRecord, ChannelTestResult } from "../../lib/types";
import type { AdminChannelMessage, AdminChannelRecord, ChannelTestResult } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
@@ -20,6 +22,14 @@ function platformOf(name: string): string {
return name.split(":")[0];
}
/** Adapter (platform:account) name for one account config. The bridge registers a
* channels.json account as "kook:conf0" (env single-account = bare platform), so a
* config id of "default"/"" maps to the bare platform name. */
function adapterNameOf(platform: string, accountId?: string): string {
const id = accountId?.trim();
return id && id !== "default" ? `${platform}:${id}` : platform;
}
/** Open-config target: the platform + the prefill bundle for its form. */
interface CfgTarget {
platform: string;
@@ -34,6 +44,7 @@ export function ChannelsModule(): React.ReactNode {
const [err, setErr] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [cfg, setCfg] = useState<CfgTarget | null>(null);
const [logFor, setLogFor] = useState<{ adapter: string | null } | null>(null);
const [ownerId, setOwnerId] = useState("");
const [platform, setPlatform] = useState("");
@@ -157,6 +168,14 @@ export function ChannelsModule(): React.ReactNode {
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
{tokenMasked ? "token 已配置" : "token 未填"}
</Tag>
<Button
size="sm"
variant="ghost"
disabled={busy !== null}
onClick={() => setLogFor({ adapter: adapterNameOf(platformOf(c.name), a.id) })}
>
消息日志
</Button>
{a.id ? (
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
删除
@@ -195,9 +214,14 @@ export function ChannelsModule(): React.ReactNode {
title="渠道管理"
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
actions={
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
</Button>
<span className="flex items-center gap-2">
<Button variant="ghost" disabled={busy !== null} onClick={() => setLogFor({ adapter: null })}>
适配器总览日志
</Button>
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
</Button>
</span>
}
/>
@@ -266,6 +290,10 @@ export function ChannelsModule(): React.ReactNode {
/>
) : null}
{logFor ? (
<ChannelLogPanel adapter={logFor.adapter} onClose={() => setLogFor(null)} />
) : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
@@ -304,3 +332,67 @@ export function ChannelsModule(): React.ReactNode {
</div>
);
}
/** Overlay that renders one adapter config's send/receive message log, or the
* adapter overview (adapter == null) across every adapter. */
function ChannelLogPanel({ adapter, onClose }: { adapter: string | null; onClose: () => void }): React.ReactNode {
const isOverview = adapter == null;
const platform = adapter ? platformOf(adapter) : "";
const { data, loading, error, reload } = useAsyncData(
() => (adapter ? adminChannelMessages(adapter, 200) : adminChannelLogOverview(200)),
[adapter],
);
// Backend returns newest-first; display oldest→newest for a natural read.
const msgs = data ? [...data].reverse() : data;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-3xl rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-1)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--mu-glass-border)] px-5 py-3">
{platform ? <Tag tone="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag> : <Tag tone="accent">总览</Tag>}
{adapter ? <Tag tone="neutral">{adapter}</Tag> : null}
<h3 className="text-sm font-semibold">{isOverview ? "适配器总览日志" : "消息日志"}</h3>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/channels/messages</span>
<Button size="sm" variant="ghost" onClick={reload}>
刷新
</Button>
<Button size="sm" variant="soft" onClick={onClose}>
关闭
</Button>
</div>
<div className="max-h-[70vh] space-y-2 overflow-y-auto px-5 py-4">
{loading ? (
<Notice>加载中…</Notice>
) : error ? (
<Notice tone="err">{error}</Notice>
) : !msgs || msgs.length === 0 ? (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
) : (
msgs.map((m) => <ChannelLogRow key={m.id} m={m} showAdapter={isOverview} />)
)}
</div>
</div>
</div>
);
}
/** One message line in the adapter log. */
function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode {
const isAssistant = m.role === "assistant";
return (
<div
className={`rounded-[var(--mu-radius)] px-3 py-2 ${
isAssistant ? "bg-[var(--mu-surface-2)]" : "bg-[var(--mu-surface-3)]/70"
}`}
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag tone={isAssistant ? "ok" : "neutral"}>{isAssistant ? "她 / 昔涟" : "用户"}</Tag>
{m.senderName ? <Tag tone="neutral">{m.senderName}</Tag> : null}
{showAdapter && m.adapterId ? <Tag tone="neutral">{m.adapterId}</Tag> : null}
{m.deviceId ? <span className="font-mono text-[var(--mu-text-lo)]">{m.deviceId}</span> : null}
<span className="ml-auto font-mono">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-[var(--mu-text)]">{m.content}</div>
</div>
);
}