Files
Cyrene_v2/frontend/web/src/admin/modules/Backthink.tsx
T
AskaEth c96a7db7f1
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,config,web,docs): backthink 实时帧显示打扰原因 + 图片上限可配 + api-reference 端点表补齐
- 前端:`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 事件帧表仍未补全
2026-10-04 16:24:36 +08:00

158 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}