feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)

X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
This commit is contained in:
AskaEth committed 2026-10-06 21:16:23 +08:00
1 parent 2bf3b536dd
commit 2d211c2d16
20 files changed
+1568 -176

No files matched your search

+78 -14
View File
@@ -23,6 +23,42 @@ const PUSH_ROUTE_LABEL: Record<string, string> = {
none: "无投递目标",
};
/** 触发源的中文标签(后端 `Record.Trigger` / `Trigger.Source` 的取值域,design/12 §C.6)。
* `self_wake` 是她自己排起的一轮(`request_think`);其余是周期性轮与事件主题。 */
export const TRIGGER_LABEL: Record<string, string> = {
heartbeat: "例行心跳",
startup: "启动问候",
self_wake: "自我唤醒",
};
/** 触发源的显示文本:**未知取值原样返回**,与 `PUSH_ROUTE_LABEL` 同口径 —— 绝不猜一个
* 更好听的词,也绝不把不认识的取值藏起来(事件主题 `iot.device_update` 等就走这一条)。
* 抽成函数是为了让「未知原样显示」这条口径可被直接钉住,而不是靠读 JSX 里的 `??`。 */
export function triggerLabel(trigger: string): string {
return TRIGGER_LABEL[trigger] ?? trigger;
}
/** 触发源筛选轴。它与「打扰」筛选轴**取交集**,两轴语义互斥、不挤进同一个 enum。 */
export type TriggerFilter = "all" | "self_wake" | "heartbeat";
/** 打扰筛选轴的取值。 */
export type DisturbFilter = "all" | "notify" | "silent";
/** 一行是否通过**两轴**筛选(纯函数:组件只做接线,判定可以直接钉住)。
*
* 两轴取**交集**而不是并集,也不是把四个语义挤进一个 enum —— 「静默 + 自我唤醒」
* 这类组合因此是可表达的。筛选作用在**客户端已拉取的那一页**上(见下方 Notice)。 */
export function backthinkVisible(
item: BackthinkItem,
filter: DisturbFilter,
triggerFilter: TriggerFilter
): boolean {
const notifyOK =
filter === "all" ||
(filter === "notify" ? item.shouldNotify === true : item.shouldNotify !== true);
return notifyOK && (triggerFilter === "all" || item.trigger === triggerFilter);
}
/** 推送路由的色调:真的送出去了才用 ok,被限频拦住是 warn。 */
function pushRouteTone(route: string): "ok" | "warn" | "neutral" {
if (route === "active_client" || route === "im_bridge") return "ok";
@@ -30,7 +66,13 @@ function pushRouteTone(route: string): "ok" | "warn" | "neutral" {
return "neutral";
}
function BackthinkRow({
/** 单行:时间 / 触发源标签 / 打扰与推送结果 / 紧急度标记 / 结论 / 链路导图入口。
*
* 导出是为了让「触发源标签」这条口径能被**真实渲染**直接钉住(design/12 §C.6 要求
* `self_wake` 显示成「自我唤醒」、未知取值原样显示)—— 只喂 `item` prop 即可渲染;
* 它从 store 读的三处(`flowGraphs` / `wsStatus` / `markBackthinkPushed`)取初始值即可
* 工作,不依赖任何运行期数据。 */
export function BackthinkRow({
item,
graphSource,
}: {
@@ -49,7 +91,7 @@ function BackthinkRow({
<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>
<span className="font-medium">{triggerLabel(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}
@@ -130,7 +172,10 @@ export function BackthinkModule(): React.ReactNode {
// 先只读拉一次 sess-backthink 的**最近一轮**图(否则每一行都只显示「尚未产生链路图」)。
// 一次拉取在模块级做(不是每行一个 hook)—— 行数多时不会打出 N 个重复请求。
const graphSource = useFlowSnapshot(BACKTHINK_SESSION_ID);
const [filter, setFilter] = useState<"all" | "notify" | "silent">("all");
const [filter, setFilter] = useState<DisturbFilter>("all");
// 触发源是**独立的第二筛选轴**(design/12 §C.6):与「打扰」轴取交集,而不是挤进
// 同一个 enum(那样「静默 + 自我唤醒」这类组合就不可表达了)。
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("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
@@ -140,13 +185,10 @@ export function BackthinkModule(): React.ReactNode {
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())
const allItems = Array.from(byId.values());
const items = allItems
.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;
});
.filter((b) => backthinkVisible(b, filter, triggerFilter));
return (
<div className="space-y-5">
@@ -172,14 +214,34 @@ export function BackthinkModule(): React.ReactNode {
<QuietHoursFactsCard />
<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 className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-xs text-[var(--mu-text-lo)]">打扰</span>
<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>
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-xs text-[var(--mu-text-lo)]">触发源</span>
<FilterPill active={triggerFilter === "all"} onClick={() => setTriggerFilter("all")}>全部</FilterPill>
<FilterPill active={triggerFilter === "self_wake"} onClick={() => setTriggerFilter("self_wake")}>自我唤醒</FilterPill>
<FilterPill active={triggerFilter === "heartbeat"} onClick={() => setTriggerFilter("heartbeat")}>例行心跳</FilterPill>
<span className="text-xs text-[var(--mu-text-lo)]">
显示 {items.length} / {allItems.length} 条 · 两个筛选轴取交集,且只筛当前已拉取的这一页(不是全部历史)
</span>
</div>
</div>
{items.length === 0 ? (
<EmptyState icon={<Icon name="night" size={32} />} title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" />
<EmptyState
icon={<Icon name="night" size={32} />}
title={allItems.length === 0 ? "暂无后台思考" : "当前筛选下没有匹配的记录"}
hint={
allItems.length === 0
? "后台任务完成时按 WS 事件实时呈现(backthink / thinking)。"
: `本页共 ${allItems.length} 条记录,没有一条落在当前筛选(打扰 / 触发源)里 —— 这不等于「这件事没发生」,换个筛选或看「全部」即可。`
}
/>
) : (
<div className="space-y-3">
{items.map((b) => (
@@ -189,6 +251,8 @@ export function BackthinkModule(): React.ReactNode {
)}
<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+ 提供,当前为只读展示。
<br />
触发源标签(契约 §C.6,形状不变、仅值域扩展):`self_wake` 显示为「自我唤醒」= 她**自己**用 `request_think` 给自己排起的一轮(不是心跳、也不是外部事件),`heartbeat` / `startup` 分别显示为「例行心跳」/「启动问候」;其余取值(`iot.device_update` / `reminder.trigger` / `system.event` 等事件主题)与将来新增的未知取值**一律原样显示**,绝不猜一个更好听的词。⚠️ 「触发源」筛选是**客户端**筛选,只作用于下表**已拉取的这一页**(历史最多 100 条 + 本次会话实时帧),因此「筛不到」不等于「没发生」;服务端 `?trigger=` 筛选与面板写路径属 C2,尚未提供。同理,「例行心跳」这一档只匹配 `heartbeat` 本身,`startup`(启动问候)只在「全部」下可见。
<br />
新增的「穿透安静时段 / 紧急度」标记(契约 v0.70):穿透 = 这一轮落在安静时段窗口内、仍然被放行发出(只有 high 及以上档位做得到);紧急度**三态画得不一样** —— 把下限当精确值、把未知当「例常」都是把猜当事实:① **精确档位**(「例常」/「紧急(高)」/「紧急·安全域」)= 该轮实际生效的档位,随记录落库;② **≥ 下限**(带 `urgencyLowerBound` 的旧行:该行没有存档档位,此值由安静时段窗口规则推导 —— 「≥ 高」只表示至少 high、可能更高);③ **档位未知** = 存档里没有这个事实,**不是**「例常」。⚠️ 实时帧带上记录里的精确档位(`backthink.Record` 自 X2a 起带 `urgency`),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(后两者由网关按安静时段窗口推导)⇒ 本次会话新到的行先显示精确档位与推送结果,穿透标记与旧行的下限标记要等重开页面由历史拉取补齐。</Notice>
</div>