@baique/pi-web-sky 0.1.59 → 0.1.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +8 -8
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +1 -1
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/app-update/route.js +1 -1
  20. package/.next/server/app/index.html +1 -1
  21. package/.next/server/app/index.rsc +5 -5
  22. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  23. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  24. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  25. package/.next/server/app/page.js +8 -8
  26. package/.next/server/app/page_client-reference-manifest.js +1 -1
  27. package/.next/server/app-paths-manifest.json +8 -8
  28. package/.next/server/middleware-build-manifest.js +1 -1
  29. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  30. package/.next/server/pages/404.html +1 -1
  31. package/.next/server/pages/500.html +1 -1
  32. package/.next/static/chunks/{8088.39866dc5f1d15868.js → 8088.a8a689f302a4f56d.js} +1 -1
  33. package/.next/static/chunks/app/{layout-15b8d7ddfb49a19d.js → layout-772dd85585321723.js} +1 -1
  34. package/.next/static/chunks/app/page-c0fcc5519c551d52.js +256 -0
  35. package/.next/static/chunks/{webpack-1ce35e1249cfd931.js → webpack-4b8e9412e72f86c0.js} +1 -1
  36. package/.next/static/css/21bd3ff21f2866ac.css +1 -0
  37. package/.next/trace +7 -8
  38. package/.next/trace-build +1 -1
  39. package/lib/transcript.test.mjs +186 -0
  40. package/lib/transcript.ts +304 -0
  41. package/lib/turn-merge.ts +1 -1
  42. package/package.json +1 -1
  43. package/.next/static/chunks/app/page-06102d7540ccaf75.js +0 -256
  44. package/.next/static/css/c7d127e07b1a786c.css +0 -1
  45. /package/.next/static/{qD0gvlcxBrjwm2vdTuA1h → 8BGmvqU4qqWNmMv2mieIS}/_buildManifest.js +0 -0
  46. /package/.next/static/{qD0gvlcxBrjwm2vdTuA1h → 8BGmvqU4qqWNmMv2mieIS}/_ssgManifest.js +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-webpack","duration":11699804,"timestamp":191292283381,"id":14,"parentId":1,"tags":{},"startTime":1789900102827,"traceId":"cdfc6ca0ef3552f1"},{"name":"run-typescript","duration":5589684,"timestamp":191303984913,"id":809,"parentId":1,"tags":{},"startTime":1789900114529,"traceId":"cdfc6ca0ef3552f1"},{"name":"static-check","duration":2676964,"timestamp":191309588298,"id":812,"parentId":1,"tags":{},"startTime":1789900120132,"traceId":"cdfc6ca0ef3552f1"},{"name":"static-generation","duration":4034028,"timestamp":191312459363,"id":968,"parentId":1,"tags":{},"startTime":1789900123003,"traceId":"cdfc6ca0ef3552f1"},{"name":"collect-build-traces","duration":28313982,"timestamp":191312267468,"id":965,"parentId":1,"tags":{},"startTime":1789900122811,"traceId":"cdfc6ca0ef3552f1"},{"name":"telemetry-flush","duration":34,"timestamp":191340584252,"id":977,"parentId":1,"tags":{},"startTime":1789900151127,"traceId":"cdfc6ca0ef3552f1"},{"name":"next-build","duration":48482632,"timestamp":191292101660,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1789900102646,"traceId":"cdfc6ca0ef3552f1"}]
1
+ [{"name":"run-webpack","duration":17112532,"timestamp":23154317271,"id":14,"parentId":1,"tags":{},"startTime":1789988434698,"traceId":"c2ff66a5e4c7d328"},{"name":"run-typescript","duration":7106132,"timestamp":23171432664,"id":821,"parentId":1,"tags":{},"startTime":1789988451813,"traceId":"c2ff66a5e4c7d328"},{"name":"static-check","duration":4315677,"timestamp":23178556343,"id":824,"parentId":1,"tags":{},"startTime":1789988458937,"traceId":"c2ff66a5e4c7d328"},{"name":"static-generation","duration":4932951,"timestamp":23183121940,"id":980,"parentId":1,"tags":{},"startTime":1789988463502,"traceId":"c2ff66a5e4c7d328"},{"name":"collect-build-traces","duration":31653292,"timestamp":23182874446,"id":977,"parentId":1,"tags":{},"startTime":1789988463255,"traceId":"c2ff66a5e4c7d328"},{"name":"telemetry-flush","duration":38,"timestamp":23214530709,"id":989,"parentId":1,"tags":{},"startTime":1789988494910,"traceId":"c2ff66a5e4c7d328"},{"name":"next-build","duration":60439935,"timestamp":23154090818,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1789988434472,"traceId":"c2ff66a5e4c7d328"}]
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createJiti } from "jiti";
4
+
5
+ const jiti = createJiti(import.meta.url);
6
+ const {
7
+ createTranscriptState,
8
+ transcriptReducer,
9
+ transcriptMessages,
10
+ transcriptEntryIds,
11
+ } = await jiti.import("./transcript.ts");
12
+
13
+ /** 造一条用户消息(提交时客户端自己的形态)。 */
14
+ const user = (text, timestamp) => ({ role: "user", content: [{ type: "text", text }], timestamp });
15
+ const assistant = (text, timestamp) => ({ role: "assistant", content: [{ type: "text", text }], timestamp });
16
+ const system = (text, timestamp) => ({ role: "system", content: text, timestamp });
17
+
18
+ /** 服务端窗口里的一条 entry。 */
19
+ const entry = (entryId, message, parentId = null) => ({ entryId, parentId, message });
20
+
21
+ const reduce = (state, ...actions) => actions.reduce(transcriptReducer, state);
22
+
23
+ test("提交把消息作为 pending 条目放进列表,并给出稳定的客户端 id", () => {
24
+ const state = reduce(
25
+ createTranscriptState(),
26
+ { type: "submit", id: "c1", message: user("你好", 100), runId: 1, submittedAt: 100 },
27
+ );
28
+ assert.equal(state.items.length, 1);
29
+ assert.deepEqual(
30
+ { id: state.items[0].id, state: state.items[0].state, serverEntryId: state.items[0].serverEntryId },
31
+ { id: "c1", state: "pending", serverEntryId: null },
32
+ );
33
+ assert.deepEqual(transcriptMessages(state).map((m) => m.role), ["user"]);
34
+ });
35
+
36
+ // 回归:当前实现里 message_end(user) 只有在"乐观气泡刚好还在末尾"时才认它;
37
+ // 中间夹一条别的 message_end 就会改走追加分支 → 界面上两个一样的用户气泡。
38
+ test("回声到达时原地升级,绝不新增条目(即使前面先夹了一条 system)", () => {
39
+ let state = reduce(
40
+ createTranscriptState(),
41
+ { type: "submit", id: "c1", message: user("重复测试", 100), runId: 1, submittedAt: 100 },
42
+ );
43
+ // pi 先推一条 system(历史上就是把乐观气泡挤出末尾的那条)
44
+ state = reduce(state, { type: "server", message: system("system prompt 更新", 101) });
45
+ // 紧接着才是这条用户消息的回声
46
+ state = reduce(state, { type: "echo", runId: 1, message: user("重复测试", 102) });
47
+
48
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 1, "用户消息必须只有一条");
49
+ assert.equal(state.items[0].id, "c1", "必须是同一条目(id 不变 → 渲染不重挂)");
50
+ assert.equal(state.items[0].state, "confirmed");
51
+ assert.equal(state.items[0].serverEntryId, null, "回声不带 entryId,等合并时补");
52
+ });
53
+
54
+ test("合并服务端整表:同一位置沿用原条目 id,不新增、不重挂", () => {
55
+ let state = reduce(
56
+ createTranscriptState(),
57
+ { type: "submit", id: "c1", message: user("合并测试", 100), runId: 1, submittedAt: 100 },
58
+ { type: "echo", runId: 1, message: user("合并测试", 102) },
59
+ { type: "server", message: assistant("收到", 110) },
60
+ );
61
+ const idsBefore = state.items.map((i) => i.id);
62
+
63
+ state = reduce(state, {
64
+ type: "mergeTail",
65
+ entries: [
66
+ entry("e1", user("更早的问题", 10)),
67
+ entry("e2", assistant("更早的回答", 20)),
68
+ entry("e3", user("合并测试", 102), "e2"),
69
+ entry("e4", assistant("收到", 110), "e3"),
70
+ ],
71
+ });
72
+
73
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 2, "用户消息两条:历史一条 + 本次一条");
74
+ // 已存在的条目沿用原 id(命中位置不重挂),更早的历史作为新条目补在前面
75
+ assert.deepEqual(state.items.map((i) => i.id).slice(-2), idsBefore, "已存在的条目 id 一个都不能变");
76
+ assert.deepEqual(transcriptEntryIds(state), ["e1", "e2", "e3", "e4"]);
77
+ assert.ok(state.items.every((i) => i.state === "confirmed"));
78
+ });
79
+
80
+ // 回归:当前实现里 loadSession 整表替换会把还没落盘的乐观气泡抹掉("消息消失、AI 直接开始回复")。
81
+ test("确认前合并:服务端还没有这一条,pending 条目原位保留", () => {
82
+ let state = reduce(
83
+ createTranscriptState(),
84
+ { type: "submit", id: "c1", message: user("还没落盘", 100), runId: 1, submittedAt: 100 },
85
+ );
86
+ state = reduce(state, {
87
+ type: "mergeTail",
88
+ entries: [entry("e1", user("历史问题", 10)), entry("e2", assistant("历史回答", 20))],
89
+ });
90
+
91
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 2);
92
+ const pending = state.items.find((i) => i.id === "c1");
93
+ assert.equal(pending?.state, "pending", "未确认的条目必须还在");
94
+ assert.equal(state.items[state.items.length - 1].id, "c1", "pending 排在末尾");
95
+ });
96
+
97
+ test("合并时空窗口不清空列表(避免瞬态空响应把界面刷白)", () => {
98
+ let state = reduce(
99
+ createTranscriptState(),
100
+ { type: "mergeTail", entries: [entry("e1", user("已有", 10))] },
101
+ );
102
+ state = reduce(state, { type: "mergeTail", entries: [] });
103
+ assert.equal(state.items.length, 1);
104
+ });
105
+
106
+ test("同文本两条 steer:各自认领,不互相替换、不丢", () => {
107
+ let state = reduce(
108
+ createTranscriptState(),
109
+ { type: "submit", id: "c1", message: user("同样的话", 100), runId: 1, submittedAt: 100 },
110
+ );
111
+ state = reduce(
112
+ state,
113
+ { type: "echo", runId: 1, message: user("同样的话", 102) },
114
+ { type: "submit", id: "c2", message: user("同样的话", 200), runId: 1, submittedAt: 200 },
115
+ { type: "echo", runId: 1, message: user("同样的话", 202) },
116
+ );
117
+ assert.deepEqual(state.items.map((i) => i.id), ["c1", "c2"]);
118
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 2);
119
+ });
120
+
121
+ test("没有本地回显的服务端用户消息(别的客户端/扩展发的)作为新条目追加", () => {
122
+ const state = reduce(
123
+ createTranscriptState(),
124
+ { type: "echo", runId: 3, message: user("来自别处", 500) },
125
+ );
126
+ assert.equal(state.items.length, 1);
127
+ assert.equal(state.items[0].state, "confirmed");
128
+ });
129
+
130
+ test("提交被拒:按 id 移除 pending,别的条目不受影响", () => {
131
+ let state = reduce(
132
+ createTranscriptState(),
133
+ { type: "mergeTail", entries: [entry("e1", assistant("历史", 10))] },
134
+ { type: "submit", id: "c1", message: user("被拒的消息", 100), runId: 1, submittedAt: 100 },
135
+ );
136
+ state = reduce(state, { type: "fail", id: "c1" });
137
+ assert.deepEqual(state.items.map((i) => i.id), [state.items[0].id]);
138
+ assert.equal(state.items.length, 1);
139
+ assert.equal(transcriptMessages(state)[0].role, "assistant");
140
+ });
141
+
142
+ test("更早一页 prepend:重复 prepend 同一页只算一次", () => {
143
+ let state = reduce(
144
+ createTranscriptState(),
145
+ { type: "mergeTail", entries: [entry("e3", user("最新", 30))] },
146
+ );
147
+ const older = [entry("e1", user("最早", 10)), entry("e2", assistant("中间", 20))];
148
+ state = reduce(state, { type: "prependOlder", entries: older });
149
+ const once = state.items.map((i) => i.serverEntryId);
150
+ state = reduce(state, { type: "prependOlder", entries: older });
151
+ assert.deepEqual(state.items.map((i) => i.serverEntryId), once, "同一页重复加载不得重复插入");
152
+ assert.equal(state.items.length, 3);
153
+ });
154
+
155
+ // 回归:回退(navigate_tree)/「编辑从此处」会把 leaf 移到更早的 entry,服务端窗口随之变短。
156
+ // 旧的 loadSession 是整表替换,落在窗口之外的条目会消失;合并实现里必须同样把它们判为"离开当前分支"丢弃,
157
+ // 否则回退后旧消息仍然留在消息流里。
158
+ test("回退到更早的 leaf:窗口之外的已落盘条目必须消失,pending 保留", () => {
159
+ const e1 = entry("e1", user("第一问", 10));
160
+ const e2 = entry("e2", assistant("第一答", 20), "e1");
161
+ const e3 = entry("e3", user("第二问", 30), "e2");
162
+ const e4 = entry("e4", assistant("第二答", 40), "e3");
163
+
164
+ let state = reduce(
165
+ createTranscriptState(),
166
+ { type: "mergeTail", entries: [e1, e2, e3, e4] },
167
+ { type: "submit", id: "c1", message: user("新提交还没落盘", 500), runId: 2, submittedAt: 500 },
168
+ );
169
+ assert.equal(state.items.length, 5);
170
+
171
+ // 回退到 e2:服务端窗口只到 e2
172
+ state = reduce(state, { type: "mergeTail", entries: [e1, e2] });
173
+
174
+ assert.deepEqual(transcriptEntryIds(state).filter(Boolean), ["e1", "e2"], "离分支的 e3/e4 必须移除");
175
+ assert.deepEqual(state.items.map((i) => i.id).filter((id) => id.startsWith("srv")), ["srv:e1", "srv:e2"]);
176
+ assert.deepEqual(state.items.filter((i) => i.state === "pending").map((i) => i.id), ["c1"], "未落盘的提交不能被顺手丢掉");
177
+ });
178
+
179
+ test("切分支 reset:显式清空(唯一允许清空列表的动作)", () => {
180
+ let state = reduce(
181
+ createTranscriptState(),
182
+ { type: "submit", id: "c1", message: user("旧分支", 100), runId: 1, submittedAt: 100 },
183
+ );
184
+ state = reduce(state, { type: "reset" });
185
+ assert.equal(state.items.length, 0);
186
+ });
@@ -0,0 +1,304 @@
1
+ import type { AgentMessage } from "@/lib/types";
2
+
3
+ /**
4
+ * 会话消息列表的单一 owner(纯函数 reducer)。
5
+ *
6
+ * 为什么要它:消息列表原先由 `messages` / `entryIds` / `parentIds` **三个并行数组**维护,
7
+ * 长度还刻意不一致(发送时先乐观追加 messages、entryIds 不动),于是"对齐"变成每个写入者的义务,
8
+ * 而"刚发出的消息是哪一个"只能靠「是不是最后一条 + 文本相等」去猜 —— 猜错就重复,被整表替换就消失,
9
+ * 身份变化就卸载重挂(闪烁)。上游 issue #23 / #24 就是同一类症状,当时的修法(乐观 key + 相邻判定)
10
+ * 是启发式补丁,我们 fork 时继承的就是那个形态。
11
+ *
12
+ * 这里的规矩只有两条:
13
+ * 1. 每条消息在列表里出现且仅出现一次;
14
+ * 2. 一条条目的身份(`id`)从创建到销毁不变 —— 渲染 key、滚动锚点、认领回声都用它,
15
+ * 服务端 entry id 只是落盘后挂上去的元数据。
16
+ *
17
+ * 所有"谁是谁"的判断都换成确定性依据:
18
+ * - 已有 serverEntryId 的条目 ↔ 服务端窗口:按 entryId 配对;
19
+ * - 无 serverEntryId 的条目(SSE 直接追加的、或回声后的 pending):按 role + timestamp 配对;
20
+ * - pending 用户条目 ↔ 服务端用户条目:按提交顺序 FIFO,且服务端 timestamp 不早于提交时刻。
21
+ * 不出现任何"文本相等"的猜测。
22
+ */
23
+
24
+ export interface TranscriptItem {
25
+ /** 客户端 id:从创建到销毁不变(渲染 key 用它)。 */
26
+ id: string;
27
+ /** 服务端落盘后的 entry id;未落盘(pending / 仅经 SSE 到达)时为 null。 */
28
+ serverEntryId: string | null;
29
+ parentId: string | null;
30
+ message: AgentMessage;
31
+ /** pending = 已提交、尚未在服务端列表里确认;confirmed = 已确认(或不经本地回显直接到达)。 */
32
+ state: "pending" | "confirmed";
33
+ /** 提交时的运行序号(把回声认领回正确的 pending 条目)。 */
34
+ runId: number | null;
35
+ /** 提交时刻(ms)。合并时用它判断"服务端这条是不是我提交的那条"。 */
36
+ submittedAt: number;
37
+ }
38
+
39
+ /** 服务端窗口里的一条 entry(loadSession / loadContext 的返回形态)。 */
40
+ export interface ServerEntry {
41
+ entryId: string;
42
+ parentId: string | null;
43
+ message: AgentMessage;
44
+ }
45
+
46
+ export interface TranscriptState {
47
+ items: TranscriptItem[];
48
+ }
49
+
50
+ export type TranscriptAction =
51
+ /** 用户提交一条消息(本地立即上屏,等回声确认)。 */
52
+ | { type: "submit"; id: string; message: AgentMessage; runId: number; submittedAt: number }
53
+ /** SSE message_end 到达的非用户消息(assistant / toolResult / system …),服务端已经产出。 */
54
+ | { type: "server"; message: AgentMessage }
55
+ /** SSE message_end 到达的用户消息:认领对应的 pending 条目并**原位升级**;没有 pending 才追加。 */
56
+ | { type: "echo"; runId: number; message: AgentMessage }
57
+ /** 服务端尾部窗口(loadSession):按身份合并,pending 原位保留。 */
58
+ | { type: "mergeTail"; entries: ServerEntry[] }
59
+ /** 更早一页历史(loadContext before):前置插入并按 entryId 去重。 */
60
+ | { type: "prependOlder"; entries: ServerEntry[] }
61
+ /** 提交被拒:按 id 移除该 pending。 */
62
+ | { type: "fail"; id: string }
63
+ /** 切换分支 / 会话:显式清空(唯一允许清空列表的动作)。 */
64
+ | { type: "reset" };
65
+
66
+ export function createTranscriptState(): TranscriptState {
67
+ return { items: [] };
68
+ }
69
+
70
+ export function transcriptMessages(state: TranscriptState): AgentMessage[] {
71
+ return state.items.map((item) => item.message);
72
+ }
73
+
74
+ /** 与服务端 entry 一一对应;pending 条目为 null。 */
75
+ export function transcriptEntryIds(state: TranscriptState): (string | null)[] {
76
+ return state.items.map((item) => item.serverEntryId);
77
+ }
78
+
79
+ export function transcriptParentIds(state: TranscriptState): (string | null)[] {
80
+ return state.items.map((item) => item.parentId);
81
+ }
82
+
83
+ /** 渲染用:条目 id 顺序(ChatWindow 的 key / 滚动锚点)。 */
84
+ export function transcriptItemIds(state: TranscriptState): string[] {
85
+ return state.items.map((item) => item.id);
86
+ }
87
+
88
+ function timestampOf(message: AgentMessage): number | null {
89
+ const value = (message as { timestamp?: unknown }).timestamp;
90
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
91
+ }
92
+
93
+ /** 无 entryId 的条目与服务端 entry 的确定性配对依据:角色 + 时间戳。 */
94
+ function sameRoleAndTimestamp(a: AgentMessage, b: AgentMessage): boolean {
95
+ if (a.role !== b.role) return false;
96
+ const at = timestampOf(a);
97
+ const bt = timestampOf(b);
98
+ return at !== null && bt !== null && at === bt;
99
+ }
100
+
101
+ function confirmedFromEntry(entry: ServerEntry, id: string): TranscriptItem {
102
+ return {
103
+ id,
104
+ serverEntryId: entry.entryId,
105
+ parentId: entry.parentId,
106
+ message: entry.message,
107
+ state: "confirmed",
108
+ runId: null,
109
+ submittedAt: 0,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * 尾部窗口合并。
115
+ *
116
+ * 语义与旧的 `loadSession` 一致(尾部窗口 + 保留已加载的更早历史),但:
117
+ * - 命中已有条目的位置**沿用原 id**(不重挂、不闪);
118
+ * - 本地 pending 条目服务端还没落盘时**原位保留**(不消失);
119
+ * - 空窗口不动列表(避免瞬态空响应把界面刷白)。
120
+ */
121
+ function mergeTail(state: TranscriptState, entries: ServerEntry[]): TranscriptState {
122
+ if (entries.length === 0) return state;
123
+
124
+ const items = state.items;
125
+ const claimedItem = new Set<number>();
126
+ const matchedItemByServerIdx = new Map<number, number>();
127
+
128
+ // ① 已有 entryId 的条目:按 entryId 配对
129
+ const itemIdxByEntryId = new Map<string, number>();
130
+ items.forEach((item, idx) => {
131
+ if (item.serverEntryId) itemIdxByEntryId.set(item.serverEntryId, idx);
132
+ });
133
+ entries.forEach((entry, serverIdx) => {
134
+ const itemIdx = itemIdxByEntryId.get(entry.entryId);
135
+ if (itemIdx === undefined || claimedItem.has(itemIdx)) return;
136
+ claimedItem.add(itemIdx);
137
+ matchedItemByServerIdx.set(serverIdx, itemIdx);
138
+ });
139
+
140
+ // ② 无 entryId 的条目(SSE 直接追加的 / 回声后的 pending):按角色 + 时间戳配对
141
+ entries.forEach((entry, serverIdx) => {
142
+ if (matchedItemByServerIdx.has(serverIdx)) return;
143
+ const itemIdx = items.findIndex((item, idx) => (
144
+ !claimedItem.has(idx) && !item.serverEntryId && sameRoleAndTimestamp(item.message, entry.message)
145
+ ));
146
+ if (itemIdx === -1) return;
147
+ claimedItem.add(itemIdx);
148
+ matchedItemByServerIdx.set(serverIdx, itemIdx);
149
+ });
150
+
151
+ // ③ pending 用户条目 ↔ 未认领的服务端用户条目:提交顺序 FIFO,服务端时间不早于提交时刻
152
+ const pendingIdxList = items
153
+ .map((item, idx) => ({ item, idx }))
154
+ .filter(({ item, idx }) => item.state === "pending" && item.message.role === "user" && !claimedItem.has(idx))
155
+ .sort((a, b) => a.item.submittedAt - b.item.submittedAt)
156
+ .map(({ idx }) => idx);
157
+ entries.forEach((entry, serverIdx) => {
158
+ if (matchedItemByServerIdx.has(serverIdx) || entry.message.role !== "user") return;
159
+ const serverTimestamp = timestampOf(entry.message);
160
+ const itemIdx = pendingIdxList.find((idx) => (
161
+ !claimedItem.has(idx)
162
+ && (serverTimestamp === null || serverTimestamp >= items[idx].submittedAt)
163
+ ));
164
+ if (itemIdx === undefined) return;
165
+ claimedItem.add(itemIdx);
166
+ matchedItemByServerIdx.set(serverIdx, itemIdx);
167
+ });
168
+
169
+ // ④ 组装:锚点之前的本地条目 = 已加载的更早历史,原样保留在前。
170
+ // 没有锚点时不能把本地条目当"更早历史"——它们是还没落盘的 pending,应该接在窗口之后。
171
+ const hasAnchor = matchedItemByServerIdx.size > 0;
172
+ const anchorItemIdx = hasAnchor
173
+ ? Math.min(...matchedItemByServerIdx.values())
174
+ : Number.POSITIVE_INFINITY;
175
+
176
+ const out: TranscriptItem[] = [];
177
+ if (hasAnchor) {
178
+ items.forEach((item, idx) => {
179
+ if (idx < anchorItemIdx && !claimedItem.has(idx)) out.push(item);
180
+ });
181
+ }
182
+
183
+ const consumedItemIdx = new Set<number>();
184
+ for (let serverIdx = 0; serverIdx < entries.length; serverIdx++) {
185
+ const entry = entries[serverIdx];
186
+ const itemIdx = matchedItemByServerIdx.get(serverIdx);
187
+ if (itemIdx === undefined) {
188
+ out.push(confirmedFromEntry(entry, `srv:${entry.entryId}`));
189
+ continue;
190
+ }
191
+ const item = items[itemIdx];
192
+ consumedItemIdx.add(itemIdx);
193
+ // 原位升级:id 不变,只把服务端信息补上
194
+ out.push({
195
+ ...item,
196
+ serverEntryId: entry.entryId,
197
+ parentId: entry.parentId,
198
+ message: entry.message,
199
+ state: "confirmed",
200
+ });
201
+ }
202
+
203
+ // ⑤ 尾巴:只接回**还没落盘**的本地条目(pending / 仅经 SSE 到达)。
204
+ // 有 serverEntryId 却不在窗口里的,说明它们已不在当前 leaf 的分支上(回退 / 「编辑从此处」/ 切分支)——
205
+ // 服务端窗口是当前分支的权威尾部,这些必须丢弃;否则回退后旧消息会继续挂在消息流里。
206
+ items.forEach((item, idx) => {
207
+ if (consumedItemIdx.has(idx) || claimedItem.has(idx)) return;
208
+ if (item.serverEntryId) return;
209
+ if (hasAnchor && idx < anchorItemIdx) return; // 已在 ④ 前置保留
210
+ out.push(item);
211
+ });
212
+
213
+ return { items: out };
214
+ }
215
+
216
+ function prependOlder(state: TranscriptState, entries: ServerEntry[]): TranscriptState {
217
+ if (entries.length === 0) return state;
218
+ const known = new Set(state.items.map((item) => item.serverEntryId).filter(Boolean) as string[]);
219
+ const fresh = entries.filter((entry) => !known.has(entry.entryId));
220
+ if (fresh.length === 0) return state;
221
+ return {
222
+ items: [
223
+ ...fresh.map((entry) => confirmedFromEntry(entry, `srv:${entry.entryId}`)),
224
+ ...state.items,
225
+ ],
226
+ };
227
+ }
228
+
229
+ export function transcriptReducer(state: TranscriptState, action: TranscriptAction): TranscriptState {
230
+ switch (action.type) {
231
+ case "submit":
232
+ return {
233
+ items: [...state.items, {
234
+ id: action.id,
235
+ serverEntryId: null,
236
+ parentId: null,
237
+ message: action.message,
238
+ state: "pending",
239
+ runId: action.runId,
240
+ submittedAt: action.submittedAt,
241
+ }],
242
+ };
243
+
244
+ case "server":
245
+ return {
246
+ items: [...state.items, {
247
+ id: `srv-live:${state.items.length}:${action.message.role}`,
248
+ serverEntryId: null,
249
+ parentId: null,
250
+ message: action.message,
251
+ state: "confirmed",
252
+ runId: null,
253
+ submittedAt: 0,
254
+ }],
255
+ };
256
+
257
+ case "echo": {
258
+ // 认领:优先同一 run 的 pending,其次最早的 pending(FIFO)
259
+ const pending = state.items
260
+ .map((item, idx) => ({ item, idx }))
261
+ .filter(({ item }) => item.state === "pending" && item.message.role === "user")
262
+ .sort((a, b) => a.item.submittedAt - b.item.submittedAt);
263
+ const target = pending.find(({ item }) => item.runId === action.runId) ?? pending[0];
264
+ if (target) {
265
+ // 原位升级:id 不变 → 渲染不重挂;服务端内容为准
266
+ const items = [...state.items];
267
+ items[target.idx] = {
268
+ ...target.item,
269
+ message: action.message,
270
+ state: "confirmed",
271
+ runId: action.runId,
272
+ };
273
+ return { items };
274
+ }
275
+ // 没有本地回显(别的客户端/扩展发的)→ 作为新条目追加
276
+ return {
277
+ items: [...state.items, {
278
+ id: `echo:${action.runId}:${state.items.length}`,
279
+ serverEntryId: null,
280
+ parentId: null,
281
+ message: action.message,
282
+ state: "confirmed",
283
+ runId: action.runId,
284
+ submittedAt: 0,
285
+ }],
286
+ };
287
+ }
288
+
289
+ case "mergeTail":
290
+ return mergeTail(state, action.entries);
291
+
292
+ case "prependOlder":
293
+ return prependOlder(state, action.entries);
294
+
295
+ case "fail":
296
+ return { items: state.items.filter((item) => item.id !== action.id) };
297
+
298
+ case "reset":
299
+ return { items: [] };
300
+
301
+ default:
302
+ return state;
303
+ }
304
+ }
package/lib/turn-merge.ts CHANGED
@@ -71,7 +71,7 @@ export function getAssistantAnswerMarkdown(message: AgentMessage | Partial<Agent
71
71
  */
72
72
  export function buildDomTurns(
73
73
  allMessages: (AgentMessage | Partial<AgentMessage>)[],
74
- entryIds: string[],
74
+ entryIds: (string | null)[],
75
75
  ): DomTurn[] {
76
76
  const turns: DomTurn[] = [];
77
77
  let current: DomTurn | null = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baique/pi-web-sky",
3
- "version": "0.1.59",
3
+ "version": "0.1.60",
4
4
  "description": "Web UI for the pi coding agent — Sky skin edition (fork of @agegr/pi-web / earendil-labs pi-web, MIT)",
5
5
  "homepage": "https://github.com/baique/pi-web-sky#readme",
6
6
  "repository": {