@baique/pi-web-sky 0.1.60 → 0.1.61
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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +6 -6
- package/.next/build-manifest.json +2 -2
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/agent/running/route.js +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +6 -6
- package/.next/server/chunks/1937.js +1 -1
- package/.next/server/chunks/402.js +8 -8
- package/.next/server/chunks/542.js +1 -1
- package/.next/server/chunks/6429.js +1 -1
- package/.next/server/chunks/6525.js +4 -4
- package/.next/server/chunks/6583.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/app/{layout-772dd85585321723.js → layout-58dd6111497e161d.js} +1 -1
- package/.next/static/chunks/app/{page-c0fcc5519c551d52.js → page-9bbf25bd34fa91f1.js} +2 -2
- package/.next/trace +7 -7
- package/.next/trace-build +1 -1
- package/lib/board-types.ts +6 -0
- package/lib/rpc-manager.title.test.mjs +128 -0
- package/lib/rpc-manager.ts +25 -0
- package/lib/session-index-scanner.test.mjs +25 -0
- package/lib/session-index-scanner.ts +9 -0
- package/lib/session-list-signal.test.mjs +50 -0
- package/lib/session-list-signal.ts +32 -0
- package/lib/session-reader.ts +5 -6
- package/lib/task-store.ts +5 -3
- package/package.json +1 -1
- /package/.next/static/{8BGmvqU4qqWNmMv2mieIS → ImiqQqbpF4KxeBm1KeB5N}/_buildManifest.js +0 -0
- /package/.next/static/{8BGmvqU4qqWNmMv2mieIS → ImiqQqbpF4KxeBm1KeB5N}/_ssgManifest.js +0 -0
package/.next/trace-build
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
[{"name":"run-webpack","duration":
|
|
1
|
+
[{"name":"run-webpack","duration":14533465,"timestamp":272072983657,"id":14,"parentId":1,"tags":{},"startTime":1790237349471,"traceId":"80188c259035d77e"},{"name":"run-typescript","duration":13931446,"timestamp":272087518906,"id":824,"parentId":1,"tags":{},"startTime":1790237364006,"traceId":"80188c259035d77e"},{"name":"static-check","duration":3363686,"timestamp":272101465709,"id":827,"parentId":1,"tags":{},"startTime":1790237377953,"traceId":"80188c259035d77e"},{"name":"static-generation","duration":4133250,"timestamp":272105054082,"id":983,"parentId":1,"tags":{},"startTime":1790237381541,"traceId":"80188c259035d77e"},{"name":"collect-build-traces","duration":30667433,"timestamp":272104831548,"id":980,"parentId":1,"tags":{},"startTime":1790237381319,"traceId":"80188c259035d77e"},{"name":"telemetry-flush","duration":38,"timestamp":272135501894,"id":992,"parentId":1,"tags":{},"startTime":1790237411989,"traceId":"80188c259035d77e"},{"name":"next-build","duration":62752361,"timestamp":272072749578,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1790237349237,"traceId":"80188c259035d77e"}]
|
package/lib/board-types.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface RunningSnapshot {
|
|
|
40
40
|
* 带 ?boardId=&cardIds=:返回这批可见卡的**全量**状态(含 failed/done/not_started),供画布徽章用。
|
|
41
41
|
*/
|
|
42
42
|
taskCards: TaskCardRunningState[];
|
|
43
|
+
/**
|
|
44
|
+
* 会话列表代次(服务端任何影响列表的写入都自增)。
|
|
45
|
+
* 前端比对代次:变了就重拉列表——标题/首条消息/最后回复/外部新建会话不必等
|
|
46
|
+
* 下次本地动作或刷新页面。见 lib/session-list-signal.ts。
|
|
47
|
+
*/
|
|
48
|
+
listGeneration: number;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/** 任务卡状态(running 快照透传的画布可见卡状态;DB 是唯一真相源,此为展示镜像) */
|
|
@@ -150,3 +150,131 @@ test("空名字仍然报错(既有校验不变),不碰 pi 也不碰库", a
|
|
|
150
150
|
assert.deepEqual(nameCalls, []);
|
|
151
151
|
assert.equal(rowOf(db, "sess-name").title, null);
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
/** 假 inner:prompt 提交的接受/拒绝由调用方控制(SDK 通过 preflightResult 同步 ack)。
|
|
155
|
+
* `entries` 模拟 transcript 已有内容(判定「是不是真首条」靠它)。 */
|
|
156
|
+
function makePromptWrapper(sessionId, { preflight = "accept", entries = [] } = {}) {
|
|
157
|
+
const inner = {
|
|
158
|
+
sessionId,
|
|
159
|
+
isBashRunning: false,
|
|
160
|
+
isStreaming: false,
|
|
161
|
+
isCompacting: false,
|
|
162
|
+
extensionRunner: {},
|
|
163
|
+
sessionManager: { getCwd: () => "/tmp", getEntries: () => entries },
|
|
164
|
+
agent: { state: {} },
|
|
165
|
+
subscribe() {
|
|
166
|
+
return () => {};
|
|
167
|
+
},
|
|
168
|
+
dispose() {},
|
|
169
|
+
prompt(_message, options) {
|
|
170
|
+
if (preflight === "reject") return Promise.reject(new Error("prompt rejected"));
|
|
171
|
+
options.preflightResult(true);
|
|
172
|
+
return Promise.resolve();
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const wrapper = new AgentSessionWrapper(inner);
|
|
176
|
+
wrapper.start();
|
|
177
|
+
return wrapper;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
test("首条用户消息在 prompt 被接受时即落库:标题不必等整轮跑完", async (t) => {
|
|
181
|
+
const db = freshDb();
|
|
182
|
+
seedSession(db, "sess-title");
|
|
183
|
+
const wrapper = makePromptWrapper("sess-title");
|
|
184
|
+
t.after(() => wrapper.destroy());
|
|
185
|
+
const generationBefore = globalThis.__piSessionListGeneration ?? 0;
|
|
186
|
+
|
|
187
|
+
await wrapper.send({ type: "prompt", message: " 会话标题为什么不及时 " });
|
|
188
|
+
|
|
189
|
+
assert.equal(rowOf(db, "sess-title").first_message, "会话标题为什么不及时", "trim 后入库");
|
|
190
|
+
assert.ok(
|
|
191
|
+
(globalThis.__piSessionListGeneration ?? 0) > generationBefore,
|
|
192
|
+
"同一次写库推列表代次:正在看侧栏的客户端 ≤2.5s 内重拉,标题立刻可见",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("首条用户消息截断到 300 字符(与扫描器同一上限)", async (t) => {
|
|
197
|
+
const db = freshDb();
|
|
198
|
+
seedSession(db, "sess-title");
|
|
199
|
+
const wrapper = makePromptWrapper("sess-title");
|
|
200
|
+
t.after(() => wrapper.destroy());
|
|
201
|
+
|
|
202
|
+
await wrapper.send({ type: "prompt", message: "长".repeat(500) });
|
|
203
|
+
|
|
204
|
+
assert.equal(rowOf(db, "sess-title").first_message.length, 300);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("prompt 没被接受(preflight 拒绝):不写 first_message", async (t) => {
|
|
208
|
+
const db = freshDb();
|
|
209
|
+
seedSession(db, "sess-title");
|
|
210
|
+
const wrapper = makePromptWrapper("sess-title", { preflight: "reject" });
|
|
211
|
+
t.after(() => wrapper.destroy());
|
|
212
|
+
|
|
213
|
+
await assert.rejects(() => wrapper.send({ type: "prompt", message: "会被拒绝的提交" }), /prompt rejected/);
|
|
214
|
+
assert.equal(rowOf(db, "sess-title").first_message, null);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("prompt 分支接线:await preflight 之后才写 first_message(拒绝的提交不留标题)", async () => {
|
|
218
|
+
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
|
219
|
+
const caseSource = caseSourceOf(source, "prompt");
|
|
220
|
+
|
|
221
|
+
assert.match(caseSource, /await preflight;[\s\S]*?fillFirstMessageIfEmpty\(/);
|
|
222
|
+
assert.match(caseSource, /command\.message[\s\S]*?slice\(0, FIRST_MESSAGE_PREVIEW_LENGTH\)/);
|
|
223
|
+
assert.match(caseSource, /try \{[\s\S]*?fillFirstMessageIfEmpty\([\s\S]*?catch \(error\)/, "库写失败不得冒泡(否则路由把已接受的 prompt 报成 prompt_rejected)");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("transcript 里已有用户消息(老会话):prompt 不写 first_message(不把最新消息写成首条)", async (t) => {
|
|
227
|
+
const db = freshDb();
|
|
228
|
+
seedSession(db, "sess-title");
|
|
229
|
+
// 模拟 CLI 建的老会话:transcript 有历史,但 first_message 还空着(扫描器拿不到)
|
|
230
|
+
const wrapper = makePromptWrapper("sess-title", {
|
|
231
|
+
entries: [{ type: "message", message: { role: "user", content: [{ type: "text", text: "历史里的真首条消息" }] } }],
|
|
232
|
+
});
|
|
233
|
+
t.after(() => wrapper.destroy());
|
|
234
|
+
const generationBefore = globalThis.__piSessionListGeneration ?? 0;
|
|
235
|
+
|
|
236
|
+
await wrapper.send({ type: "prompt", message: "这是这条会话里最新的一句" });
|
|
237
|
+
|
|
238
|
+
assert.equal(rowOf(db, "sess-title").first_message, null, "留给 agent_settled/扫描器按真首条回填");
|
|
239
|
+
assert.equal(globalThis.__piSessionListGeneration ?? 0, generationBefore, "没写库就不推代次");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("空的 message(图片-only 等):不写库、不推代次", async (t) => {
|
|
243
|
+
const db = freshDb();
|
|
244
|
+
seedSession(db, "sess-title");
|
|
245
|
+
const wrapper = makePromptWrapper("sess-title");
|
|
246
|
+
t.after(() => wrapper.destroy());
|
|
247
|
+
const generationBefore = globalThis.__piSessionListGeneration ?? 0;
|
|
248
|
+
|
|
249
|
+
await wrapper.send({ type: "prompt", message: " ", images: [] });
|
|
250
|
+
|
|
251
|
+
assert.equal(rowOf(db, "sess-title").first_message, null);
|
|
252
|
+
assert.equal(globalThis.__piSessionListGeneration ?? 0, generationBefore);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("写库失败不拖累 send:命令照常成功响应(库是旁路索引)", async (t) => {
|
|
256
|
+
const db = freshDb();
|
|
257
|
+
seedSession(db, "sess-title");
|
|
258
|
+
const wrapper = makePromptWrapper("sess-title");
|
|
259
|
+
const errors = [];
|
|
260
|
+
const original = console.error;
|
|
261
|
+
t.after(() => {
|
|
262
|
+
wrapper.destroy();
|
|
263
|
+
console.error = original;
|
|
264
|
+
globalThis.__piWebDb = db;
|
|
265
|
+
});
|
|
266
|
+
console.error = (...args) => errors.push(args.map(String).join(" "));
|
|
267
|
+
// 让库写入抛错:stub 掉全局库句柄(真库仍在 db 变量里,未被触碰)
|
|
268
|
+
globalThis.__piWebDb = {
|
|
269
|
+
prepare() {
|
|
270
|
+
throw new Error("database is locked");
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const result = await wrapper.send({ type: "prompt", message: "阻塞的库也不能让 prompt 变失败" });
|
|
275
|
+
|
|
276
|
+
assert.equal(result, null, "send 正常返回:路由才会写 promptAccepted=true(否则客户端回填草稿、诱导重发)");
|
|
277
|
+
assert.equal(errors.length, 1, "写库失败必须可见");
|
|
278
|
+
assert.match(errors[0], /\[pi-web\] 首条消息写库失败/);
|
|
279
|
+
assert.match(errors[0], /database is locked/);
|
|
280
|
+
});
|
package/lib/rpc-manager.ts
CHANGED
|
@@ -333,6 +333,8 @@ export class AgentSessionWrapper {
|
|
|
333
333
|
}
|
|
334
334
|
recordSessionOutcome(sessionId, effect);
|
|
335
335
|
// 首条用户消息此刻已在内存 entries 里(不读文件,读取路径零扫盘)。
|
|
336
|
+
// 正常链路上它更早就写好了(prompt 一被接受就写,见 send 的 prompt 分支);
|
|
337
|
+
// 这里兑底扩展/外部注入的首条用户消息(那一轮的起点不是 RPC prompt)。
|
|
336
338
|
fillFirstMessageIfEmpty(sessionId, firstUserMessageOf(this.inner.sessionManager.getEntries()));
|
|
337
339
|
}
|
|
338
340
|
|
|
@@ -613,6 +615,29 @@ export class AgentSessionWrapper {
|
|
|
613
615
|
});
|
|
614
616
|
|
|
615
617
|
await preflight;
|
|
618
|
+
// 首条用户消息:prompt 一被接受就落库,**不等整轮跑完**。标题/列表的默认
|
|
619
|
+
// 展示就是「用户发送的第一句话」(title || first_message),只在 agent_settled
|
|
620
|
+
// 写的话,长首轮(几分钟~几十分钟)期间刷新页面/换客户端/看板卡上标题一直空着
|
|
621
|
+
// (-> "(no messages)")。也不能指望 agent_start:实测那一刻用户消息还没进
|
|
622
|
+
// 内存 entries(拿不到文本)。
|
|
623
|
+
// 只在「transcript 里还没有用户消息」时写(= 这才是真首条):已有历史的会话
|
|
624
|
+
// (CLI 建的、first_message 还空着)走 agent_settled/扫描器按真实首条回填,
|
|
625
|
+
// prompt 路径写「这一条最新消息」会被 fill-empty 钉死成永久错值。
|
|
626
|
+
const promptText = typeof command.message === "string" ? command.message.trim() : "";
|
|
627
|
+
const sessionId = this.inner.sessionId;
|
|
628
|
+
if (promptText && sessionId && !firstUserMessageOf(this.inner.sessionManager.getEntries())) {
|
|
629
|
+
try {
|
|
630
|
+
fillFirstMessageIfEmpty(sessionId, promptText.slice(0, FIRST_MESSAGE_PREVIEW_LENGTH));
|
|
631
|
+
} catch (error) {
|
|
632
|
+
// 库写失败绝不能冒泡:send() 抛出会让路由把「已经接受的 prompt」报成
|
|
633
|
+
// prompt_rejected(客户端回填草稿、诱导重发,而 agent 其实在跑)。
|
|
634
|
+
// 这条写只是旁路索引,失败记账即可(同 persistSessionActivity 口径)。
|
|
635
|
+
console.error(
|
|
636
|
+
`[pi-web] 首条消息写库失败 session=${sessionId}:`,
|
|
637
|
+
error instanceof Error ? error.message : String(error),
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
616
641
|
return null;
|
|
617
642
|
} finally {
|
|
618
643
|
releaseAdmission();
|
|
@@ -69,6 +69,31 @@ test("runSessionIndexScan:幂等(重复扫描不重复建行)", async () =
|
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("runSessionIndexScan:列表代次只在本轮真变更时自增(有变更推一次,空轮不推)", async () => {
|
|
73
|
+
freshDb();
|
|
74
|
+
const { root, projDir } = makeRoot();
|
|
75
|
+
try {
|
|
76
|
+
const generation = () => globalThis.__piSessionListGeneration ?? 0;
|
|
77
|
+
const before = generation();
|
|
78
|
+
|
|
79
|
+
await runSessionIndexScan(root); // 磁盘空:无变更
|
|
80
|
+
assert.equal(generation(), before, "空轮不推代次(否则每 30s 白刷一次前端列表)");
|
|
81
|
+
|
|
82
|
+
writeFileSync(join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl"), sessionFileLines("aaa", "/home/u/project/alpha"));
|
|
83
|
+
await runSessionIndexScan(root);
|
|
84
|
+
assert.equal(generation(), before + 1, "外部/CLI 新建的会话入索引 → 推一次(侧栏 ≤2.5s 就能看到它)");
|
|
85
|
+
|
|
86
|
+
const afterInsert = generation();
|
|
87
|
+
await runSessionIndexScan(root); // 再扫:幂等无变更
|
|
88
|
+
assert.equal(generation(), afterInsert, "幂等轮不推");
|
|
89
|
+
|
|
90
|
+
await indexSessionFileNow(join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl"), null, null);
|
|
91
|
+
assert.equal(generation(), afterInsert + 1, "单文件立即索引 → 也推一次(fork/引用分支当场进列表)");
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(root, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
72
97
|
test("runSessionIndexScan:mtime 变化刷 modified + 回填 first_message(文件是内容事实源)", async () => {
|
|
73
98
|
freshDb();
|
|
74
99
|
const { root, projDir } = makeRoot();
|
|
@@ -34,6 +34,7 @@ import { getDb } from "./sqlite-db";
|
|
|
34
34
|
import { projectIdentityKey } from "./project-identity";
|
|
35
35
|
import { sessionPathKey } from "./session-path";
|
|
36
36
|
import { scanOneSessionHead, scanSessionFileMeta, readSessionTail, type SessionFileMeta } from "./session-scanner";
|
|
37
|
+
import { markSessionListChanged } from "./session-list-signal";
|
|
37
38
|
import { resolveProject } from "./worktree";
|
|
38
39
|
|
|
39
40
|
/** 一轮扫描的变更摘要(日志/测试断言用)。 */
|
|
@@ -97,6 +98,8 @@ export async function indexSessionFileNow(
|
|
|
97
98
|
head.created.getTime(),
|
|
98
99
|
Date.now(),
|
|
99
100
|
);
|
|
101
|
+
// 行已落库 → 列表可能变了(会话当场出现在列表/派生列被补上)。
|
|
102
|
+
markSessionListChanged();
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/** 一轮全量扫描。`sessionsDir` 仅在测试注入(默认走 agentDir)。 */
|
|
@@ -319,6 +322,12 @@ export async function runSessionIndexScan(
|
|
|
319
322
|
}
|
|
320
323
|
}
|
|
321
324
|
|
|
325
|
+
// 本轮真改了什么 → 通知列表读者(外部/CLI 新建的会话、首条消息/最后回复回填、
|
|
326
|
+
// 删除都走这里;无变更不 mark,避免每 30s 空推一次前端重拉)。
|
|
327
|
+
if (summary.inserted > 0 || summary.updated > 0 || summary.deleted > 0) {
|
|
328
|
+
markSessionListChanged();
|
|
329
|
+
}
|
|
330
|
+
|
|
322
331
|
return summary;
|
|
323
332
|
}
|
|
324
333
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
|
|
6
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
|
|
7
|
+
const { markSessionListChanged, getSessionListGeneration } = await jiti.import("./session-list-signal.ts");
|
|
8
|
+
|
|
9
|
+
const routeSource = await readFile(new URL("../app/api/agent/running/route.ts", import.meta.url), "utf8");
|
|
10
|
+
const sidebarSource = await readFile(new URL("../components/SessionSidebar.tsx", import.meta.url), "utf8");
|
|
11
|
+
const readerSource = await readFile(new URL("./session-reader.ts", import.meta.url), "utf8");
|
|
12
|
+
const sessionsRouteSource = await readFile(new URL("../app/api/sessions/route.ts", import.meta.url), "utf8");
|
|
13
|
+
|
|
14
|
+
test("mark 自增代次,get 读同一处(globalThis:热重载后仍是同一个计数器)", () => {
|
|
15
|
+
const before = getSessionListGeneration();
|
|
16
|
+
markSessionListChanged();
|
|
17
|
+
markSessionListChanged();
|
|
18
|
+
assert.equal(getSessionListGeneration(), before + 2);
|
|
19
|
+
assert.equal(globalThis.__piSessionListGeneration, before + 2);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("接线:写路径 invalidateSessionListCache 委托 mark(改名/pin/新建/事件写库都算列表变更)", () => {
|
|
23
|
+
assert.match(
|
|
24
|
+
readerSource,
|
|
25
|
+
/export function invalidateSessionListCache\(\): void \{\n markSessionListChanged\(\);/,
|
|
26
|
+
);
|
|
27
|
+
assert.match(readerSource, /from "\.\/session-list-signal"/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("接线:running 快照把代次带给前端(侧栏本来就在轮询它,不新增请求)", () => {
|
|
31
|
+
assert.match(routeSource, /listGeneration: getSessionListGeneration\(\)/);
|
|
32
|
+
assert.match(routeSource, /from "@\/lib\/session-list-signal"/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("接线:侧栏轮询见代次变化就全量刷新;首个快照只做种子(挂载时已拉过列表,不空刷)", () => {
|
|
36
|
+
assert.match(
|
|
37
|
+
sidebarSource,
|
|
38
|
+
/if \(shouldRefreshForListGeneration\(listGenerationRef\.current, generation\)\) \{\n\s+onRefreshRef\.current\?\.\(\);/,
|
|
39
|
+
);
|
|
40
|
+
// 列表响应把自己的代次记下来(不靠轮询的「首个快照」去猜)。
|
|
41
|
+
assert.match(sidebarSource, /if \(typeof data\.listGeneration === "number"\) listGenerationRef\.current = data\.listGeneration;/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("接线:/api/sessions 两份列表响应都带上自己的代次(作为「我的列表对应哪个代次」的种子)", () => {
|
|
45
|
+
assert.match(sessionsRouteSource, /sessions: \[\.\.\.persisted, \.\.\.extraRuntime\],[\s\S]*?listGeneration: getSessionListGeneration\(\),/);
|
|
46
|
+
assert.match(
|
|
47
|
+
sessionsRouteSource,
|
|
48
|
+
/\{ sessions, runningSessionIds: getRunningRpcSessionIds\(\), listGeneration: getSessionListGeneration\(\) \}/,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 会话列表「已变更」信号
|
|
3
|
+
//
|
|
4
|
+
// 谁需要知道:列表不再有服务端缓存(每次读取 = 一次单表查询),但**前端**的列表
|
|
5
|
+
// 只在挂载/项目切换/本地动作(新建、删除、改名、拖拽)时重拉——服务端写进去的
|
|
6
|
+
// 新标题、首条消息、最后回复、外部(CLI)新建的会话,都要等下一次重拉才现身。
|
|
7
|
+
//
|
|
8
|
+
// 机制:任何影响列表读取的写路径都 mark 一次(自增计数器),`/api/agent/running`
|
|
9
|
+
// 的轮询把它带给前端(侧栏本来就每 2.5s 拉一次),前端见变化就重拉列表。
|
|
10
|
+
//
|
|
11
|
+
// **进程内计数器**(与库同进程的写入才可见):同时跑多个 pi-web 实例、共用一个
|
|
12
|
+
// `pi-web.db` 时,A 进程的写不会推给 B 进程的侧栏——B 只能靠自己的 30s 扫描器按
|
|
13
|
+
// 文件 mtime 收敛。跨进程唯一可靠的事实源是库本身(真要做就是库里的代次行)。
|
|
14
|
+
//
|
|
15
|
+
// 单独成文件只为打断循环依赖:`session-reader` 已经 import 扫描器(ensureSessionIndexReady),
|
|
16
|
+
// 扫描器不能再反向 import `session-reader` 的 invalidateSessionListCache。globalThis
|
|
17
|
+
// 存活热重载(模块级变量不会)。
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
var __piSessionListGeneration: number | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 列表内容已变(标题/首条消息/最后回复/排序/新建/删除)。写路径调用,幂等无副作用。 */
|
|
25
|
+
export function markSessionListChanged(): void {
|
|
26
|
+
globalThis.__piSessionListGeneration = (globalThis.__piSessionListGeneration ?? 0) + 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 当前代次:前端比对「我上次看到的」与「这次拿到的」,不等即重拉列表。 */
|
|
30
|
+
export function getSessionListGeneration(): number {
|
|
31
|
+
return globalThis.__piSessionListGeneration ?? 0;
|
|
32
|
+
}
|
package/lib/session-reader.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { projectIdentityKey } from "./project-identity";
|
|
|
16
16
|
import { sessionPathKey } from "./session-path";
|
|
17
17
|
import { resolveProject, type ProjectInfo } from "./worktree";
|
|
18
18
|
import { scanOneSessionFile } from "./session-scanner";
|
|
19
|
+
import { markSessionListChanged } from "./session-list-signal";
|
|
19
20
|
import { ensureSessionIndexReady } from "./session-index-scanner";
|
|
20
21
|
import type { TurnIndexItem } from "./api-types";
|
|
21
22
|
|
|
@@ -264,17 +265,15 @@ export async function loadTaskSessionsPage(
|
|
|
264
265
|
declare global {
|
|
265
266
|
var __piSessionPathCache: Map<string, string> | undefined;
|
|
266
267
|
var __piPathToSessionIdCache: Map<string, string> | undefined;
|
|
267
|
-
var __piSessionListGeneration: number | undefined;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
/** 写路径的「数据变了」信号:归属/pin/改名/新建/删除/事件都会调它。
|
|
271
271
|
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* 属待清理项,本次不动。 */
|
|
272
|
+
* 计数器本体在 `session-list-signal`(扫描器也要 mark,不能反向 import 本模块);
|
|
273
|
+
* 读者是 `/api/agent/running` 的 `listGeneration` —— 侧栏 2.5s 轮询见代次变化就
|
|
274
|
+
* 重拉列表,标题/首条消息/最后回复/外部新建会话因此能在几秒内现身。 */
|
|
276
275
|
export function invalidateSessionListCache(): void {
|
|
277
|
-
|
|
276
|
+
markSessionListChanged();
|
|
278
277
|
}
|
|
279
278
|
|
|
280
279
|
function getPathCache(): Map<string, string> {
|
package/lib/task-store.ts
CHANGED
|
@@ -613,14 +613,16 @@ export function recordSessionOutcome(sessionId: string, outcome: { lastReply: st
|
|
|
613
613
|
invalidateSessionListCache();
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
/** 首条用户消息回填(读取路径不再读文件,改由事件补齐;只补空行)。
|
|
616
|
+
/** 首条用户消息回填(读取路径不再读文件,改由事件补齐;只补空行)。
|
|
617
|
+
* 只在真的改了行时推列表代次:这个函数在一条会话的一生里会被调多次(每次 prompt 一次),
|
|
618
|
+
* 无变化的空推会让前端白刷一轮会话/任务/看板。「空轮不推」与扫描器同一口径。 */
|
|
617
619
|
export function fillFirstMessageIfEmpty(sessionId: string, firstMessage: string): void {
|
|
618
620
|
const text = firstMessage.trim();
|
|
619
621
|
if (!text) return;
|
|
620
|
-
getDb()
|
|
622
|
+
const result = getDb()
|
|
621
623
|
.prepare(
|
|
622
624
|
"UPDATE session_meta SET first_message = ? WHERE session_id = ? AND (first_message IS NULL OR first_message = '')",
|
|
623
625
|
)
|
|
624
626
|
.run(text, sessionId);
|
|
625
|
-
invalidateSessionListCache();
|
|
627
|
+
if (Number(result.changes) > 0) invalidateSessionListCache();
|
|
626
628
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baique/pi-web-sky",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
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": {
|
|
File without changes
|
|
File without changes
|