- 前端:`lib/wsParse.ts` 的 `parseBackthinkItem` 现在也读原始 `PushResult` 的 route/detail/bypassed(兼容 camelCase),与历史 DTO 路径**同名同义**(空串 → undefined、未报告的 bool 不编成 false); Backthink 页 Notice 改为如实描述。证据=用后端两条路径的**真实序列化**喂同一个 PushResult 做逐字段比对 - 图片上限可配:`CHAT_IMAGE_MAX_BYTES`(默认 5242880 = 与 /upload 同口径;**未配置/0/负 一律用默认**, 不存在「禁用上限」);删掉重复的字符常量,换算只留一份 `chatImageCharsFor`;补 3 条用例 (默认值==upload 预算、配 1 MiB 真生效且只有合法那条驱动回合、非正值回落默认) - `Docs/api-reference.md`:§1 端点表按 `routes.go` **补齐**(auth refresh/logout、upload、files、三支 audio、 /admin/plugins/installed、/admin/llm/catalog、observability/logs、notifications、owner-profile、reminders), 既有编号一律未动(保交叉引用);两张 `historyMessage` 字段表补 `personaId`(+ `clientMsgId`); 修正 §8.1「主人档案保存仍 501」与实现不符(已真实实现) - 复核:build/vet 绿 + gateway/config 包测试绿 + 前端 tsc 0 错 + vite build 成功 - 登记:§1 各表的编号跨表重复(不重编号以保交叉引用);`admin_plugins.go:217` 注释写错鉴权档(未改); §6.4 的 WS 事件帧表仍未补全
158 lines
7.7 KiB
TypeScript
158 lines
7.7 KiB
TypeScript
import { useState } from "react";
|
||
import { useAppStore } from "../../store/useAppStore";
|
||
import { useAuditStore } from "../audit";
|
||
import { Notice, PageHeader } from "../components";
|
||
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
|
||
import type { BackthinkItem } from "../../lib/types";
|
||
import { Icon } from "../../components/Icon";
|
||
import { FlowGraphView } from "./FlowGraph";
|
||
import { adminBackthink } from "../../lib/api";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import { BACKTHINK_SESSION_ID } from "../../store/sessionCache";
|
||
|
||
/** 推送路由的中文标签(后端 PushResult.Route 的取值域)。未知取值原样显示,
|
||
* 绝不猜一个更好听的词。 */
|
||
const PUSH_ROUTE_LABEL: Record<string, string> = {
|
||
active_client: "活跃客户端",
|
||
im_bridge: "IM 桥",
|
||
rate_limited: "限频拦截",
|
||
none: "无投递目标",
|
||
};
|
||
|
||
/** 推送路由的色调:真的送出去了才用 ok,被限频拦住是 warn。 */
|
||
function pushRouteTone(route: string): "ok" | "warn" | "neutral" {
|
||
if (route === "active_client" || route === "im_bridge") return "ok";
|
||
if (route === "rate_limited") return "warn";
|
||
return "neutral";
|
||
}
|
||
|
||
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
||
const markPushed = useAppStore((s) => s.markBackthinkPushed);
|
||
// 裁决 16.4.2:这条思考的链路图取自**后台思考会话**(`sess-backthink`,design/19 §1
|
||
// 的发布会话范围)的快照,而不是管理端 Flow 页那张「当前会话」图 —— 后者属于用户端
|
||
// 会话,与后台思考的完整链路是两件事,且不会被思考图顶掉。
|
||
const graph = useAppStore((s) => s.flowGraphs[BACKTHINK_SESSION_ID] ?? null);
|
||
const wsStatus = useAppStore((s) => s.wsStatus);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
return (
|
||
<Surface className="space-y-2">
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Tag tone="neutral">{new Date(item.at).toLocaleTimeString()}</Tag>
|
||
<span className="font-medium">{item.trigger}</span>
|
||
<Tag tone={item.shouldNotify ? "warn" : "neutral"}>{item.shouldNotify ? "需要打扰" : "静默"}</Tag>
|
||
<Tag tone={item.pushed ? "ok" : "neutral"}>{item.pushed ? "已推送" : "未推送"}</Tag>
|
||
{item.route ? <Tag tone={pushRouteTone(item.route)}>{PUSH_ROUTE_LABEL[item.route] ?? item.route}</Tag> : null}
|
||
{item.bypassed ? <Tag tone="accent">事件直发</Tag> : null}
|
||
<span className="ml-auto">
|
||
<Button size="sm" variant="ghost" disabled={item.pushed} onClick={() => markPushed(item.id)}>
|
||
{item.pushed ? "已推送" : "推送结果"}
|
||
</Button>
|
||
</span>
|
||
</div>
|
||
{item.conclusion ? (
|
||
<div className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
||
{item.conclusion}
|
||
</div>
|
||
) : null}
|
||
{item.detail ? (
|
||
<div className="text-xs text-[var(--mu-text-lo)]">推送说明:{item.detail}</div>
|
||
) : null}
|
||
<button
|
||
className="text-xs text-[var(--mu-accent-hi)]"
|
||
onClick={() => setOpen((v) => !v)}
|
||
>
|
||
{open ? "收起链路" : "查看完整链路导图"}
|
||
</button>
|
||
{open ? (
|
||
graph ? (
|
||
<FlowGraphView graph={graph} />
|
||
) : (
|
||
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-8 text-center text-xs text-[var(--mu-text-lo)]">
|
||
{wsStatus === "connected"
|
||
? "后台思考会话尚未产生链路图 —— 该会话还没有 graph_update 快照(本轮思考未跑 span,或后端未发布)。"
|
||
: `实时通道未连接(${wsStatus}),链路图无法更新 —— 这不等于「本轮思考没有链路」。`}
|
||
</div>
|
||
)
|
||
) : null}
|
||
</Surface>
|
||
);
|
||
}
|
||
|
||
export function BackthinkModule(): React.ReactNode {
|
||
const backthink = useAppStore((s) => s.backthink);
|
||
const log = useAuditStore((s) => s.log);
|
||
const [filter, setFilter] = useState<"all" | "notify" | "silent">("all");
|
||
const [interveneHint, setInterveneHint] = useState(false);
|
||
// Cold-load persisted history once (design/12 §4); real-time updates come from the
|
||
// WS `backthink` events already feeding useAppStore.backthink (head-inserted), so we
|
||
// do NOT poll-replace the array (which would re-render the whole list). Rows are keyed
|
||
// by id → React reconciles incrementally, appending only NEW records.
|
||
const rec = useAsyncData(() => adminBackthink(100), []);
|
||
const byId = new Map<string, BackthinkItem>();
|
||
for (const b of backthink) if (b.id) byId.set(b.id, b);
|
||
for (const b of rec.data ?? []) if (b.id) byId.set(b.id, b);
|
||
const items = Array.from(byId.values())
|
||
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0))
|
||
.filter((b) => {
|
||
if (filter === "notify") return b.shouldNotify === true;
|
||
if (filter === "silent") return b.shouldNotify !== true;
|
||
return true;
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
title="后台思考与主动"
|
||
subtitle="触发源 / 时间 / 结论 / 是否打扰 / 推送结果 · 打扰决策可解释"
|
||
actions={
|
||
<Button
|
||
variant="soft"
|
||
onClick={() => {
|
||
log("backthink.intervene", "error");
|
||
setInterveneHint(true);
|
||
}}
|
||
>
|
||
干预
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
{interveneHint ? (
|
||
<Notice tone="warn">对后台思考的打断 / 重派需后端 M10+ 能力接入,当前未启用;此处仅展示已产生的思考记录。</Notice>
|
||
) : null}
|
||
|
||
<div className="flex items-center gap-2">
|
||
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>全部</FilterPill>
|
||
<FilterPill active={filter === "notify"} onClick={() => setFilter("notify")}>需打扰</FilterPill>
|
||
<FilterPill active={filter === "silent"} onClick={() => setFilter("silent")}>静默</FilterPill>
|
||
</div>
|
||
|
||
{items.length === 0 ? (
|
||
<EmptyState icon={<Icon name="night" size={32} />} title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" />
|
||
) : (
|
||
<div className="space-y-3">
|
||
{items.map((b) => (
|
||
<BackthinkRow key={b.id} item={b} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 两条路径同名同义:历史拉取取 AdminBackthinkItem 的同名键,实时帧取后端原始 PushResult 的 Route / Detail / Bypassed(解析器已读,见 lib/wsParse.ts#parseBackthinkItem,兼容 camelCase 变体),因此本次会话新到的行同样显示推送路由与推送说明,不再只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FilterPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={`rounded-[var(--mu-radius-pill)] px-3 py-1 text-xs transition-all duration-200 ease-[var(--mu-spring)] ${
|
||
active ? "bg-[var(--mu-accent)]/20 font-medium text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|