From ca82ae150d807dad838f14e8c604b1338517ea93 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Fri, 11 Sep 2026 00:57:16 -0400 Subject: [PATCH] =?UTF-8?q?[ac#1318][ac#1325]=20=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=A1=8C:=E6=8A=8A=E3=80=8C=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=AD=97=E9=83=BD=E6=B2=A1=E5=9B=9E=E3=80=8D?= =?UTF-8?q?=E8=AF=B4=E5=9C=A8=E5=AE=83=E5=8F=91=E7=94=9F=E7=9A=84=E5=9C=B0?= =?UTF-8?q?=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit owner 2026-09-10 指出票面指定的前端元素类型不对。查证后四条理由: ① 事件是**一个回合**的,而票面要的常驻顶部横幅是整个会话的 —— 滚到别处它还在,它说的那条 却已不在视野;② 票面为此不得不发明「按 messageId 记住已关闭」,那正是元素挂错地方的症状; ③ 会话顶栏已被 REQ-159 的沙箱 / 只读这类**会话级持续状态**占着,再塞一条「这次回复是空的」 会让那个位置什么都说于是什么都不说;④ 本仓早有这一族的既定形态 —— 中断行,源码注释明写 「左对齐安静行,不是居中告警 pill」且 fail-closed。 另:票面 §Scope 写的 `packages/app/src/pages/session/timeline/` 是**上游**叶子,alpha 早在 REQ-125 C5/C6 自持时间线,北极星会拦下对它的改动 —— 该句一并作废。 设计稿 docs/design/2026-09-11-req160-empty-turn-row(owner 2026-09-11 批准形态与文案)。 - 判据 `isEmptyUnknownTurn`(纯函数,可单测):回合结束 + finish=unknown + 零可见正文 (投影层 emitted===0)+ tokens.output===0,**四条同时成立**才算;被中止的归中断行。 - 投影层紧挨中断行同处决定;视图 `EmptyTurnRow` 复用中断行骨架,**一个元素两层文字** (票面原本是横幅 + 气泡提示两个说同一件事)。无关闭按钮 —— 它不是通知,是那一回合的事实。 - 文案(owner 2026-09-11 裁):**把内容安全审核放在明面上但不断言**。事实句只说确知的 「模型没有返回内容」,次级说明给可能性且安全审核排第一。存储里没有 error、也没有任何内容 安全枚举(票面 §Context),断言「你没通过安全审核」在猜错时是在指责用户 —— 与 REQ-159 「不许宣称比实际更大的保护面」同源。 - 动作 fail-closed:新增 focusPrompt intent,缺席即只剩事实句,不给点不动的按钮(同中断行)。 门:typecheck rc=0 · ui-mac 全量 4990 tests / 2 fail → 两条均为**新建 worktree 缺 electron 二进制**(今日刚写进 governance/local-verification-traps.md 的陷阱),按该条修法软链后 process-fence-apply + network-egress-fence 15/0 全绿,与本改动无关 · north-star rc=0 · docs 链接 rc=0 · 新判据 7/0(每条判据各有只差一条的反例 + 恒答 true 的控制臂)。 Refs jinjunnn/alpha-code#1318 Refs jinjunnn/alpha-code#1325 Refs jinjunnn/alpha-work#101 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HE3Jmp5ZDzEvnCNhgCDreN --- .../design.md | 92 +++++++++++++ .../frame.html | 121 ++++++++++++++++++ .../cards/timeline-intents.ts | 6 + .../session-timeline/empty-turn.test.ts | 54 ++++++++ .../session-timeline-view.tsx | 52 ++++++++ .../session-timeline/session-timeline.css | 42 ++++++ .../session-timeline/timeline-model.ts | 33 +++++ packages/ui-mac/src/renderer/i18n/en.ts | 5 + packages/ui-mac/src/renderer/i18n/zh.ts | 5 + scripts/gate-files.tsv | 1 + 10 files changed, 411 insertions(+) create mode 100644 docs/design/2026-09-11-req160-empty-turn-row/design.md create mode 100644 docs/design/2026-09-11-req160-empty-turn-row/frame.html create mode 100644 packages/ui-mac/src/renderer/alpha-ui/session-timeline/empty-turn.test.ts diff --git a/docs/design/2026-09-11-req160-empty-turn-row/design.md b/docs/design/2026-09-11-req160-empty-turn-row/design.md new file mode 100644 index 000000000000..243d24ec8ad3 --- /dev/null +++ b/docs/design/2026-09-11-req160-empty-turn-row/design.md @@ -0,0 +1,92 @@ +--- +type: design +slug: req160-empty-turn-row +date: 2026-09-11 +status: accepted +relates: + - jinjunnn/alpha-code#1318(本增量是其实现前的形态修正) + - jinjunnn/alpha-code#1325(REQ-160 AC3) + - jinjunnn/alpha-work#101 AC3 +--- + +# 模型一个字都没回时,把这件事说在它发生的地方 + +> **owner 2026-09-11 批准**(呈现形态 + 文案裁决)。帧见同目录 [`frame.html`](frame.html)(常态 / 无重试入口 / 重试失败 / 与既有中断行同屏,浅深两色)。 +> 批准后并入 [`current/conversation-timeline/design.html`](../current/conversation-timeline/design.html) +> 的 `#struct`,铸锚 `#empty-turn`;台账见 +> [`current/conversation-timeline/components.md`](../current/conversation-timeline/components.md)。 + +## 1. 与上一稿的关系 + +**继承**:时间线现行的**中断行**形态(`design ②` 的 `.interrupted` 帧)—— 左对齐安静行、 +12px 描边图标、三级灰正文、点分隔、就地的强调色文字按钮、失败提示就地出现。 + +**新增**:同族的第二个成员 —— 助手回合结束但一个字都没回时的那一行。 + +**取代**:`#1318` 票面 §Scope 里的 +「Sticky **top** banner under session header」+ AC1(常驻顶部横幅)+ AC4(按 `messageId` 记住已关闭)。 + +**为什么**(owner 2026-09-10 提出「这个前端元素的类型好像不对」,以下是查证后的四条): + +| # | 顶部横幅的问题 | +|---|---| +| 1 | **事件是一个回合的,横幅是整个会话的。** 你滚到别处它还在,它描述的那条消息却已经不在视野里。 | +| 2 | **票面自己露了馅**:为了一个全局元素,它不得不发明「按 `messageId` 记住已关闭」这种状态。要给全局元素挂逐条记忆,说明它本来就该挂在那一条上。 | +| 3 | **顶栏这个位置已经有人了。** REQ-159 刚把「沙箱开启」「这个项目只读」放进会话顶栏 —— 那些是**会话/应用级**的持续状态。把「这一条回复是空的」也塞进去,会让这个位置什么都说,于是什么都不说。 | +| 4 | **这个仓早有这一族的既定形态**:中断行。它的源码注释明写「左对齐安静行,**不是居中告警 pill**」,并且是 fail-closed 的(动作接不上就只剩事实陈述,不给一个点不动的按钮)。同一族的新成员没有理由另起一种。 | + +另外:票面 §Scope 写「Likely touch: `packages/app/src/pages/session/timeline/`」—— 那是**上游**的叶子, +alpha 早在 REQ-125 C5/C6 就自持了时间线,而北极星守卫会拦下对它的改动。这一句也一并作废。 + +## 2. 动笔前的地面真相(本轮实读) + +| 事实 | 坐标 | +| --- | --- | +| 中断行的形态与 fail-closed 取舍 | `session-timeline-view.tsx:797-830` `InterruptedRow` | +| 该族样式:左对齐、三级灰、12px 描边图标、强调色文字按钮、失败就地 | `session-timeline.css:176-230` | +| 既有文案三条 | `i18n/zh.ts:1368-1371`(`interrupted` / `continueTurn` / `continueFailed`) | +| 会话顶栏现有常驻元素 | `session-workspace-shell.tsx`:运行状态胶囊 · 沙箱 · 只读(REQ-159) | +| alpha 自持时间线,上游那棵不再消费 | `renderer/alpha-ui/session-timeline/`;北极星守卫辖区 | +| 空回合的判据(票面 §Scope,未改) | 完成 + `finish`/`reason` 为 `unknown` + 无非空 text + `tokens.output === 0` | + +## 3. 契约(批准后即 AC 字面量锚点) + +| 项 | 规则 | +| --- | --- | +| 位置 | 时间线内,**紧挨那个空回合**,与中断行同族同层 | +| 形态 | 左对齐安静行:盾形 12px 描边图标(`--a-warning` 描边)+ 三级灰事实句 + 点分隔 + 强调色文字按钮 | +| 数量 | **一个元素**,两层文字(事实 + 可能性)。票面原本是「顶部横幅 + 气泡内提示」两个说同一件事 | +| 动作 | 「修改后再试」聚焦输入框。**fail-closed**:intent 缺席就只剩事实句,不给点不动的按钮(同中断行) | +| 失败 | 动作失败就地出提示,再点即重试(同中断行) | +| 关闭 | **没有关闭按钮**。它不是通知,是那一回合的事实;回合还在,这行就在 | +| 多条 | 每个空回合各有自己那一行。不需要「只显示最新一条」—— 那是全局元素才有的问题 | +| 判据钩子 | `data-alpha-timeline-row="empty-turn"` | + +## 4. 文案(owner 2026-09-11 已裁) + +**裁决:把内容安全审核放在明面上,但不断言它就是原因。** 四条判据同时成立时提示;原因以 +**可能性**给出,安全审核排第一位 —— 既满足备案叙事(产品确有内容安全这一环并对用户可见), +又不在猜错时指责用户。 + +为什么不能断言:票面自己的 §Context 写着这一形态**没有** error、**没有**任何内容安全枚举落在 +存储里 —— 它是从「跑完了 + 无正文 + 零 token」推断的。真实原因也可能是上游抖动、供应商 bug、 +网络中断。与 REQ-159 的「不许宣称比实际更大的保护面」同源:那次别把保护说大,这次别把原因说死。 + +因此这一行是**两层**:上层是确知的事实,下层是可能性与出路。仍然是一个元素、一行的重量。 + +| 槽位 | zh | en | +| --- | --- | --- | +| 事实句 | 模型没有返回内容 | The model returned no reply | +| 次级说明 | 可能未通过内容安全审核,也可能是模型或网络异常 | It may not have passed content safety review, or the model or network may have failed | +| 动作 | 修改后再试 | Edit and retry | +| 动作失败 | (沿用既有 `alpha.timeline.continueFailed`) | (同) | + +被否决的票面原文:标题直接写「内容未通过安全审核」/「Content blocked by safety review」—— +它把一个推断当判决说给用户听。 + +## 5. 批准后的落点 + +1. 帧并入 `current/conversation-timeline/design.html` 的 `#struct`,铸锚 `#empty-turn`; +2. 台账加一行(组件 / 锚 / 增量稿 / 实现票 `ac#1318` · `ac#1325`); +3. `#1318` 的 AC1 / AC4 按本稿改写(**AC 改写归 owner**,实现票不自行改); +4. 本目录就地冻结。 diff --git a/docs/design/2026-09-11-req160-empty-turn-row/frame.html b/docs/design/2026-09-11-req160-empty-turn-row/frame.html new file mode 100644 index 000000000000..6e1725d6a37f --- /dev/null +++ b/docs/design/2026-09-11-req160-empty-turn-row/frame.html @@ -0,0 +1,121 @@ + + + + + +空回合行 · 时间线内同族增量 + + + + +
会话时间线 · 空回合行组件增量 · 与中断行同族
+
+
+ +
+
+

模型一个字都没回时,把这件事说在它发生的地方

+

助手回合跑完了,却没有任何正文。页面上看起来像卡住,用户不知道发生了什么。这一行就挨着那个空回合,说清事实并给出下一步 —— 它不是通知,是那一回合的事实,所以没有关闭按钮。

+
+ +
常态
+
+
帮我把这段话改得更礼貌一些
+
+ + 模型没有返回内容可能未通过内容安全审核,也可能是模型或网络异常 + + 修改后再试 +
+
+ +
同屏对照:它与既有的中断行是同一族
+
+
继续写下去
+
+ + 已由你停止 + + 继续生成 +
+
换个说法再试一次
+
+ + 模型没有返回内容可能未通过内容安全审核,也可能是模型或网络异常 + + 修改后再试 +
+
+ +
+
+
接不上输入框时(fail-closed)
+
+ + 模型没有返回内容可能未通过内容安全审核,也可能是模型或网络异常 +
+
+
+
动作失败,就地重试
+
+ + 模型没有返回内容可能未通过内容安全审核,也可能是模型或网络异常 + + 修改后再试 + 发送失败,请重试 +
+
+
+ + + + + + + + + + +
规则
位置时间线内,紧挨那个空回合;与中断行同层同族。
形态左对齐安静行:盾形 12px 描边图标(警示色描边)+ 三级灰事实句 + 点分隔 + 强调色文字按钮。不是横幅,不是居中 pill。
数量一个元素。不再另加气泡内提示 —— 两个元素说同一件事只会分散。
动作「修改后再试」聚焦输入框。接不上就只剩事实句,不给点不动的按钮。
失败就地出提示,再点即重试。
关闭没有关闭按钮。回合还在,这行就在。
多条每个空回合各有自己那一行,不需要「只显示最新一条」。
+ +
为什么不用顶部横幅(帧外):事件是一个回合的,横幅是整个会话的 —— 你滚到别处它还在,它说的那条却已不在视野里。票面为此不得不发明「按消息记住已关闭」,那正是元素挂错了地方的症状。而会话顶栏现在已经放着沙箱与只读这类**会话级持续状态**,再塞进一条「这一次回复是空的」,会让那个位置什么都说,于是什么都不说。
+
文案:把安全审核放在明面上,但不断言(owner 2026-09-11 裁):四条判据同时成立时提示,原因以**可能性**给出、安全审核排第一位 —— 既满足备案叙事(产品确有内容安全这一环且对用户可见),又不在猜错时指责用户。这一形态没有任何错误码、也没有内容安全枚举落在存储里,是推断出来的;真实原因也可能是上游抖动或网络中断。
+
+ + + + diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/cards/timeline-intents.ts b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/cards/timeline-intents.ts index f7d9f7fb1717..629796053886 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/cards/timeline-intents.ts +++ b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/cards/timeline-intents.ts @@ -68,6 +68,12 @@ export interface TimelineIntents { * 通道呈现不了)—— 由中断行就地给出失败提示,不得静默吞掉。 */ continueTurn?: () => void | Promise + /** + * 空回合行「修改后再试」(REQ-160 AC3):把焦点交回输入框,让用户就地改提问重发。 + * 缺席即那一行只剩事实陈述,不出按钮(fail-closed,同中断行 —— 不给一个点不动的按钮)。 + * 返回 Promise 时,拒绝 = 失败,由该行就地提示,不得静默吞掉。 + */ + focusPrompt?: () => void | Promise } export const TimelineIntentsContext = createContext({}) diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/empty-turn.test.ts b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/empty-turn.test.ts new file mode 100644 index 000000000000..4d8e012c2ee7 --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/empty-turn.test.ts @@ -0,0 +1,54 @@ +// REQ-160 AC3(`#1318` / `#1325`)—— 空回合判据。 +// +// 四条**同时**成立才算。这张票最容易做假的地方就是「少一条也触发」——那会把正常回复标成异常。 +// 所以每一条都有一个**只差它一条**的反例。 +import { describe, expect, test } from "bun:test" +import { isEmptyUnknownTurn } from "./timeline-model" + +type A = Parameters[0] +const turn = (over: Record = {}): A => + ({ id: "msg_1", role: "assistant", finish: "unknown", tokens: { input: 0, output: 0, reasoning: 0 }, ...over }) as unknown as A + +describe("REQ-160 AC3 空回合判据:四条同时成立才算", () => { + test("正样本:回合结束 + finish=unknown + 零可见正文 + tokens.output=0", () => { + expect(isEmptyUnknownTurn(turn(), 0)).toBe(true) + }) + + test("反例①:有可见正文 ⇒ 不算(票面 Non-goals:finish=unknown 但有正文的不是这一类)", () => { + expect(isEmptyUnknownTurn(turn(), 1)).toBe(false) + }) + + test("反例②:finish 不是 unknown ⇒ 不算(stop + 明确拒答文本是另一回事)", () => { + for (const finish of ["stop", "length", "tool-calls", undefined]) { + expect(isEmptyUnknownTurn(turn({ finish }), 0)).toBe(false) + } + }) + + test("反例③:tokens.output 非 0 ⇒ 不算(模型确实产出了,只是没落成可见正文)", () => { + expect(isEmptyUnknownTurn(turn({ tokens: { input: 12, output: 7, reasoning: 0 } }), 0)).toBe(false) + }) + + test("反例④:被用户中止 ⇒ 不算,那是中断行的辖区(两行不得同时出现)", () => { + expect(isEmptyUnknownTurn(turn({ error: { name: "MessageAbortedError" } }), 0)).toBe(false) + }) + + test("反例⑤:tokens 缺席或形状不对 ⇒ 不算(拿不准就不标,不猜)", () => { + for (const tokens of [undefined, null, 0, "0", {}]) { + expect(isEmptyUnknownTurn(turn({ tokens }), 0)).toBe(false) + } + }) + + test("控制臂:恒答 true 的替身会把上面每一个反例都标成空回合", () => { + const alwaysTrue = () => true + const counterExamples: [A, number][] = [ + [turn(), 1], + [turn({ finish: "stop" }), 0], + [turn({ tokens: { input: 0, output: 7, reasoning: 0 } }), 0], + [turn({ error: { name: "MessageAbortedError" } }), 0], + ] + for (const [a, emitted] of counterExamples) { + expect(isEmptyUnknownTurn(a, emitted)).toBe(false) + expect(alwaysTrue()).toBe(true) // 替身放行 —— 这正是上面那些断言要抓的形态 + } + }) +}) diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline-view.tsx b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline-view.tsx index 163df572f426..3f2af34e8c0c 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline-view.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline-view.tsx @@ -749,6 +749,7 @@ function DividerRow(props: { row: Extract }) { // label 随行身份定格;compaction 的 rev 在完成态 summary 到达时变化,两种形态互不重建。 const row = props.row if (row.label === "interrupted") return + if (row.label === "emptyTurn") return const [open, setOpen] = createSignal(false) const expandable = () => row.summaryParts.length > 0 return ( @@ -794,6 +795,57 @@ function DividerRow(props: { row: Extract }) { ) } +// REQ-160 AC3(`#1318` / `#1325`,设计稿 2026-09-11-req160-empty-turn-row)—— +// 助手回合跑完了却一个字都没回。**与中断行同族**:左对齐安静行,不是顶部横幅、不是居中 pill。 +// +// 票面原本要的是「会话头下的常驻顶部横幅 + 气泡内提示」两个元素,owner 2026-09-11 裁掉: +// 事件是**一个回合**的,横幅是整个会话的 —— 票面为此不得不发明「按 messageId 记住已关闭」, +// 那正是元素挂错地方的症状;而会话顶栏已被 REQ-159 的沙箱 / 只读这类**会话级持续状态**占着。 +// +// 两层文字:上层是确知的事实,下层把内容安全审核放在**明面上但不断言**(owner 同日裁决)—— +// 这一形态没有 error、也没有任何内容安全枚举落在存储里,断言「你没通过安全审核」在猜错时 +// 是在指责用户。与 REQ-159「不许宣称比实际更大的保护面」同源。 +// +// 没有关闭按钮:它不是通知,是那一回合的事实,回合还在这行就在。 +// 动作 fail-closed:intent 缺席就只剩事实,不给一个点不动的按钮(同中断行)。 +function EmptyTurnRow() { + const intents = useTimelineIntents() + const [sendFailed, setSendFailed] = createSignal(false) + return ( +
+ + + {t("alpha.timeline.emptyTurn")} + {t("alpha.timeline.emptyTurnWhy")} + + + {(handler) => ( + <> + +
+ ) +} + // 中断态(design ② .interrupted 帧):左对齐安静行,不是居中告警 pill。 // 「继续生成」经 continueTurn intent 接绑定层的现有会话发送入口;intent 缺席即只剩事实陈述 // (fail-closed,不给一个点不动的按钮)。发送失败(admission 前被拒/网络断开)不产生任何 diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline.css b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline.css index c00ed2e80cb6..70b3b170f617 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline.css +++ b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/session-timeline.css @@ -193,6 +193,48 @@ stroke-linejoin: round; } +/* REQ-160 AC3:空回合行 —— 复用中断行骨架(.a-tl-interrupted),只多两层文字与盾形图标。 + 顶对齐:次级说明换行时图标与按钮仍跟首行基线走。 */ +.a-tl-empty { + align-items: flex-start; +} + +.a-tl-empty-shield { + flex: none; + width: 12px; + height: 12px; + margin-top: 1px; + fill: none; + stroke: var(--a-warning); + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.a-tl-empty-text { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.a-tl-empty-fact { + color: var(--a-text-secondary); +} + +/* 原因是**可能性**不是判决,所以比事实句轻一档 —— 视觉上也不许它读起来像结论。 */ +.a-tl-empty-why { + color: var(--a-text-tertiary); + font-size: var(--a-text-2xs); + line-height: 1.5; +} + +.a-tl-empty .a-tl-int-dot, +.a-tl-empty .a-tl-int-continue, +.a-tl-empty .a-tl-int-failed { + margin-top: 1px; +} + .a-tl-int-dot { width: 3px; height: 3px; diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/timeline-model.ts b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/timeline-model.ts index dc9ed1cdde6d..c5e9c6638e53 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/session-timeline/timeline-model.ts +++ b/packages/ui-mac/src/renderer/alpha-ui/session-timeline/timeline-model.ts @@ -201,6 +201,7 @@ export type TimelineRow = summaryParts: TextPart[] } | { kind: "divider"; key: string; rev: string; userMessageID: string; label: "interrupted" } + | { kind: "divider"; key: string; rev: string; userMessageID: string; label: "emptyTurn" } | { kind: "thinking"; key: string; rev: string; userMessageID: string } | { kind: "footnote" @@ -488,6 +489,30 @@ function cappedField(value: unknown, max = FOOTNOTE_FIELD_MAX_CHARS): string | u * 无错误,否则无脚注;不回溯早先的完成助手(成功→流式、成功→失败序列一律零脚注, * 旧指标不得与未终结/失败内容混用;复制动作随行同门)。字段独立诚实缺席(I2)。 */ +/** + * REQ-160 AC3(`#1318` / `#1325`)—— 「助手回合跑完了,却一个字都没回」。 + * + * 四条**同时**成立才算(票面 §Scope,owner 2026-09-11 复述确认): + * ① 回合已结束(有 `finish`,且不是被用户中止 —— 那是中断行的辖区); + * ② `finish` 为 `unknown`; + * ③ 这一回合没有渲染出任何可见正文(投影层的 `emitted === 0`); + * ④ `tokens.output === 0`。 + * + * **少一条都不算。** 尤其 ③:`finish=unknown` 但有正文的回合**不是**这一类(票面 Non-goals + * 明写),把它算进来会把正常回复标成异常。 + * + * 这是**启发式**:存储里没有 error、也没有任何内容安全枚举(票面 §Context)。所以呈现层只 + * 陈述确知的事实,原因以可能性给出 —— 见设计稿 2026-09-11-req160-empty-turn-row §4。 + */ +export function isEmptyUnknownTurn(assistant: AssistantMessage, emitted: number): boolean { + if (emitted > 0) return false + if (assistant.error) return false // 被中止 / 真错误各有自己的行 + const finish = (assistant as { finish?: unknown }).finish + if (finish !== "unknown") return false + const tokens = (assistant as { tokens?: { output?: unknown } }).tokens + return typeof tokens === "object" && tokens !== null && tokens.output === 0 +} + export function footnoteOf(assistants: readonly AssistantMessage[]): TimelineFootnote | undefined { const source = assistants.at(-1) if (!source || typeof source.time.completed !== "number" || source.error) return undefined @@ -870,6 +895,14 @@ export function projectTimelineRows(input: TimelineProjectionInput): TimelineRow userMessageID: userMessage.id, label: "interrupted", }) + else if (isEmptyUnknownTurn(assistant, emitted)) + rows.push({ + kind: "divider", + key: `emptyTurn:${assistant.id}`, + rev: "emptyTurn", + userMessageID: userMessage.id, + label: "emptyTurn", + }) } // 回合末富脚注(A6):只在回合尾态成功完成且有可见内容时出行;当前活跃回合 diff --git a/packages/ui-mac/src/renderer/i18n/en.ts b/packages/ui-mac/src/renderer/i18n/en.ts index ba2cfab88b27..0cd2a9c28e4d 100644 --- a/packages/ui-mac/src/renderer/i18n/en.ts +++ b/packages/ui-mac/src/renderer/i18n/en.ts @@ -1398,6 +1398,11 @@ export const dict = { "alpha.timeline.interrupted": "Stopped by you", "alpha.timeline.continueTurn": "Continue", "alpha.timeline.continuePrompt": "Continue", + // REQ-160 AC3 (`#1318`): the turn finished with no reply. Content safety review is named as a + // possibility, never asserted — there is no error and no safety enum in storage. + "alpha.timeline.emptyTurn": "The model returned no reply", + "alpha.timeline.emptyTurnWhy": "It may not have passed content safety review, or the model or network may have failed", + "alpha.timeline.emptyTurnRetry": "Edit and retry", "alpha.timeline.continueFailed": "Couldn't send. Try again.", "alpha.timeline.loadOlder": "Load earlier messages", "alpha.timeline.loadingOlder": "Loading earlier messages…", diff --git a/packages/ui-mac/src/renderer/i18n/zh.ts b/packages/ui-mac/src/renderer/i18n/zh.ts index a0b788990e82..00fe4b99bf69 100644 --- a/packages/ui-mac/src/renderer/i18n/zh.ts +++ b/packages/ui-mac/src/renderer/i18n/zh.ts @@ -1368,6 +1368,11 @@ export const dict = { "alpha.timeline.interrupted": "已由你停止", "alpha.timeline.continueTurn": "继续生成", "alpha.timeline.continuePrompt": "继续", + // REQ-160 AC3(`#1318`):助手回合跑完却零正文。**把内容安全审核放在明面上但不断言** + //(owner 2026-09-11 裁决)—— 存储里没有 error、也没有内容安全枚举,原因只能给可能性。 + "alpha.timeline.emptyTurn": "模型没有返回内容", + "alpha.timeline.emptyTurnWhy": "可能未通过内容安全审核,也可能是模型或网络异常", + "alpha.timeline.emptyTurnRetry": "修改后再试", "alpha.timeline.continueFailed": "发送失败,请重试", "alpha.timeline.loadOlder": "加载更早的消息", "alpha.timeline.loadingOlder": "正在加载更早的消息…", diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index 9949d1eb47cf..e92918e3f4f3 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -279,3 +279,4 @@ 4 packages/ui-mac src/main/network-egress-disclosure.test.ts - REQ-137/#1337 AC5 覆盖面如实声明:终端「沙箱开启」悬停卡 zh / en 与勘破文档锚点段落三处都要逐字含「只罩引擎那棵树 / 已登记地址 / 其它地址被拒 / 应用自身联网(模型目录、登录、更新)不在其内」的独立字面量,且不许出现「全部 / 所有出网都被限制」一类过度声明;反向 ①把声明改成全部出网受限 ⇒ 同一判据红并点名过度声明,反向 ②只删掉排除句 ⇒ 红并点名缺哪一句,锚点整段删除 ⇒ 红。全平台纯逻辑 14 packages/ui-mac src/main/workspace-write-probe.test.ts packages/ui-mac/src/main/workspace-write-probe-fence.test.ts REQ-159/#1322 AC2/AC3 工作区写探针的全平台一半:main↔sidecar 合同形状 fail-closed(命令/应答不认形就当没有)、errno 分类只认 EPERM 为围栏的拒绝(EACCES/ENOENT/ENOTDIR 都 unknown,不替围栏背书)、真文件系统上写-删不留残且 wx 不覆盖用户文件、main 侧请求簿(超时 / 子进程退出 / 无关消息 / 对不上号 / postMessage 抛 都答 unknown,不猜);AC3 判据(workspace-write-probe-judge.ts)先自证 —— 恒答「不可写」的替身、恒答「可写」的替身各被拒在一边。真围栏臂委派给 fence 那份。下界=实际条数 14,不留余量 2 packages/ui-mac src/main/workspace-write-probe-fence.test.ts - REQ-159/#1322 AC3 真探针在真围栏下:真 .node(生产构建脚本现编)+ 真 seatbelt + 真子进程(bun)。围栏臂(= sidecar 形态):集合内 writable、集合外 denied(EPERM)、不存在的目录 unknown、两边不留探针文件、pid ≠ 测试进程、buildId = 本次编译,判据过;bare 臂(= main 进程形态,不套围栏):集合外也答 writable ⇒ 判据把它当假探针拒掉 —— 「在 main 里 fs.writeFile 探测恒答可写」那一格的实测。[平台:darwin] 非 darwin 上文件自报 skip,由本登记簿按「自报 0 条」验证标注 +7 packages/ui-mac src/renderer/alpha-ui/session-timeline/empty-turn.test.ts - REQ-160 AC3(`#1318`/`#1325`)空回合判据:四条(回合结束 / finish=unknown / 零可见正文 / tokens.output=0)同时成立才算,每条各有一个**只差它一条**的反例(有正文 / stop / output≠0 / 被中止 / tokens 形状不对);控制臂证明恒答 true 的替身会把每个反例都标成空回合。全平台纯逻辑