Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/design/2026-09-11-req160-empty-turn-row/design.md
Original file line number Diff line number Diff line change
@@ -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. 本目录就地冻结。
121 changes: 121 additions & 0 deletions docs/design/2026-09-11-req160-empty-turn-row/frame.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>空回合行 · 时间线内同族增量</title>
<link rel="stylesheet" href="../current/conversation-timeline/design.css" />
<style>
/* 本稿自有类一律 et- 前缀;基础样式与 --a-* token 来自链接的 conversation-timeline/design.css。 */
body{padding:0 0 72px}
.et-bar{position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:12px;padding:11px 18px;border-bottom:1px solid var(--a-border);background:color-mix(in srgb,var(--a-bg-canvas) 94%,transparent);backdrop-filter:blur(14px)}
.et-bar b{font-size:var(--a-text-sm)}.et-bar span{font-size:var(--a-text-xs);color:var(--a-text-tertiary)}.et-grow{flex:1}
.et-wrap{max-width:1000px;margin:0 auto;padding:26px 24px 0}
.et-intro h1{margin:0 0 8px;font-size:24px}
.et-intro p{margin:0;color:var(--a-text-secondary);line-height:1.65;max-width:760px}
.et-stage{margin-top:10px;padding:20px 22px;border:1px solid var(--a-border);border-radius:var(--a-radius-lg);background:var(--a-bg-canvas);box-shadow:var(--a-shadow-xs);display:flex;flex-direction:column;gap:14px}
.et-user{align-self:flex-end;max-width:70%;padding:9px 13px;border-radius:var(--a-radius-lg);background:var(--a-bg-muted);font-size:var(--a-text-base);color:var(--a-text)}
.et-shield{flex:none;width:12px;height:12px;fill:none;stroke:var(--a-warning);stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.et-lbl{font-size:var(--a-text-2xs);font-weight:var(--a-weight-semibold);letter-spacing:var(--a-tracking-wide);text-transform:uppercase;color:var(--a-text-tertiary);margin:22px 0 8px;display:flex;align-items:center;gap:8px}
.et-lbl::after{content:"";flex:1;height:1px;background:var(--a-border-faint)}
.et-fail{color:var(--a-error)}
.interrupted{align-items:flex-start}
.et-txt{display:flex;flex-direction:column;gap:2px;min-width:0}
.et-fact{color:var(--a-text-secondary)}
.et-why{color:var(--a-text-tertiary);font-size:var(--a-text-2xs);line-height:1.5}
.interrupted .dot,.interrupted .cont,.et-fail{margin-top:1px}
.et-note{margin:14px 0 0;padding:12px 14px;background:var(--a-bg-canvas);border:1px solid var(--a-border);border-left:3px solid var(--a-accent);border-radius:var(--a-radius-md);font-size:var(--a-text-sm);color:var(--a-text-secondary)}
.et-note b{color:var(--a-text);font-weight:var(--a-weight-semibold)}
.et-cols{display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;margin-top:16px}
.et-cols>.et-stage{flex:1;min-width:330px}
.et-spec{width:100%;border-collapse:collapse;margin-top:14px;font-size:var(--a-text-sm)}
.et-spec th{background:var(--a-bg-subtle);text-align:left;font-size:var(--a-text-2xs);text-transform:uppercase;letter-spacing:var(--a-tracking-wide);color:var(--a-text-tertiary);font-weight:var(--a-weight-semibold);padding:9px 12px}
.et-spec td{padding:10px 12px;border-top:1px solid var(--a-border-faint);color:var(--a-text-secondary);vertical-align:top}
</style>
</head>
<body>
<div class="et-bar"><b>会话时间线 · 空回合行</b><span>组件增量 · 与中断行同族</span><div class="et-grow"></div>
<div class="seg" id="themeSeg"><button data-theme="light">浅色</button><button data-theme="dark">深色</button></div>
</div>

<div class="et-wrap">
<div class="et-intro">
<h1>模型一个字都没回时,把这件事说在它发生的地方</h1>
<p>助手回合跑完了,却没有任何正文。页面上看起来像卡住,用户不知道发生了什么。这一行就挨着那个空回合,说清事实并给出下一步 —— 它不是通知,是那一回合的事实,所以没有关闭按钮。</p>
</div>

<div class="et-lbl">常态</div>
<div class="et-stage">
<div class="et-user">帮我把这段话改得更礼貌一些</div>
<div class="interrupted">
<svg class="et-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6z"/></svg>
<span class="et-txt"><span class="et-fact">模型没有返回内容</span><span class="et-why">可能未通过内容安全审核,也可能是模型或网络异常</span></span>
<span class="dot" style="width:3px;height:3px;border-radius:50%;background:var(--a-text-disabled)"></span>
<span class="cont">修改后再试</span>
</div>
</div>

<div class="et-lbl">同屏对照:它与既有的中断行是同一族</div>
<div class="et-stage">
<div class="et-user">继续写下去</div>
<div class="interrupted">
<svg class="ic xs stop" viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>
<span>已由你停止</span>
<span class="dot" style="width:3px;height:3px;border-radius:50%;background:var(--a-text-disabled)"></span>
<span class="cont">继续生成</span>
</div>
<div class="et-user">换个说法再试一次</div>
<div class="interrupted">
<svg class="et-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6z"/></svg>
<span class="et-txt"><span class="et-fact">模型没有返回内容</span><span class="et-why">可能未通过内容安全审核,也可能是模型或网络异常</span></span>
<span class="dot" style="width:3px;height:3px;border-radius:50%;background:var(--a-text-disabled)"></span>
<span class="cont">修改后再试</span>
</div>
</div>

<div class="et-cols">
<div class="et-stage">
<div class="et-lbl" style="margin-top:0">接不上输入框时(fail-closed)</div>
<div class="interrupted">
<svg class="et-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6z"/></svg>
<span class="et-txt"><span class="et-fact">模型没有返回内容</span><span class="et-why">可能未通过内容安全审核,也可能是模型或网络异常</span></span>
</div>
</div>
<div class="et-stage">
<div class="et-lbl" style="margin-top:0">动作失败,就地重试</div>
<div class="interrupted">
<svg class="et-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6z"/></svg>
<span class="et-txt"><span class="et-fact">模型没有返回内容</span><span class="et-why">可能未通过内容安全审核,也可能是模型或网络异常</span></span>
<span class="dot" style="width:3px;height:3px;border-radius:50%;background:var(--a-text-disabled)"></span>
<span class="cont">修改后再试</span>
<span class="et-fail">发送失败,请重试</span>
</div>
</div>
</div>

<table class="et-spec">
<tr><th>项</th><th>规则</th></tr>
<tr><td>位置</td><td>时间线内,紧挨那个空回合;与中断行同层同族。</td></tr>
<tr><td>形态</td><td>左对齐安静行:盾形 12px 描边图标(警示色描边)+ 三级灰事实句 + 点分隔 + 强调色文字按钮。不是横幅,不是居中 pill。</td></tr>
<tr><td>数量</td><td>一个元素。不再另加气泡内提示 —— 两个元素说同一件事只会分散。</td></tr>
<tr><td>动作</td><td>「修改后再试」聚焦输入框。接不上就只剩事实句,不给点不动的按钮。</td></tr>
<tr><td>失败</td><td>就地出提示,再点即重试。</td></tr>
<tr><td>关闭</td><td>没有关闭按钮。回合还在,这行就在。</td></tr>
<tr><td>多条</td><td>每个空回合各有自己那一行,不需要「只显示最新一条」。</td></tr>
</table>

<div class="et-note"><b>为什么不用顶部横幅</b>(帧外):事件是一个回合的,横幅是整个会话的 —— 你滚到别处它还在,它说的那条却已不在视野里。票面为此不得不发明「按消息记住已关闭」,那正是元素挂错了地方的症状。而会话顶栏现在已经放着沙箱与只读这类**会话级持续状态**,再塞进一条「这一次回复是空的」,会让那个位置什么都说,于是什么都不说。</div>
<div class="et-note"><b>文案:把安全审核放在明面上,但不断言</b>(owner 2026-09-11 裁):四条判据同时成立时提示,原因以**可能性**给出、安全审核排第一位 —— 既满足备案叙事(产品确有内容安全这一环且对用户可见),又不在猜错时指责用户。这一形态没有任何错误码、也没有内容安全枚举落在存储里,是推断出来的;真实原因也可能是上游抖动或网络中断。</div>
</div>

<script>
(function(){
var seg=document.getElementById('themeSeg');
var mq=window.matchMedia('(prefers-color-scheme: dark)');
function mark(t){seg.querySelectorAll('button').forEach(function(b){b.classList.toggle('on',b.dataset.theme===t)});}
mark(mq.matches?'dark':'light');
seg.querySelectorAll('button').forEach(function(b){b.onclick=function(){document.documentElement.setAttribute('data-theme',b.dataset.theme);mark(b.dataset.theme);};});
})();
</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export interface TimelineIntents {
* 通道呈现不了)—— 由中断行就地给出失败提示,不得静默吞掉。
*/
continueTurn?: () => void | Promise<void>
/**
* 空回合行「修改后再试」(REQ-160 AC3):把焦点交回输入框,让用户就地改提问重发。
* 缺席即那一行只剩事实陈述,不出按钮(fail-closed,同中断行 —— 不给一个点不动的按钮)。
* 返回 Promise 时,拒绝 = 失败,由该行就地提示,不得静默吞掉。
*/
focusPrompt?: () => void | Promise<void>
}

export const TimelineIntentsContext = createContext<TimelineIntents>({})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// REQ-160 AC3(`#1318` / `#1325`)—— 空回合判据。
//
// 四条**同时**成立才算。这张票最容易做假的地方就是「少一条也触发」——那会把正常回复标成异常。
// 所以每一条都有一个**只差它一条**的反例。
import { describe, expect, test } from "bun:test"
import { isEmptyUnknownTurn } from "./timeline-model"

type A = Parameters<typeof isEmptyUnknownTurn>[0]
const turn = (over: Record<string, unknown> = {}): 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) // 替身放行 —— 这正是上面那些断言要抓的形态
}
})
})
Loading
Loading