@evandu/super-pi 0.1.132 → 0.1.135
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/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +8 -8
- package/dist/client/assets/{CodeMirrorEditor-ktyL2PcX.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
- package/dist/client/assets/{dist--igDAzRS.js → dist-1jSyapdW.js} +1 -1
- package/dist/client/assets/{dist-CPLCQDqy.js → dist-BGgidsaF.js} +1 -1
- package/dist/client/assets/{dist-DNZMXYNq.js → dist-BYMYC_2I.js} +1 -1
- package/dist/client/assets/{dist-vsMBLoxV.js → dist-BkVaPA_q.js} +1 -1
- package/dist/client/assets/{dist-B8OZ-n_C.js → dist-BoeM5xW7.js} +1 -1
- package/dist/client/assets/{dist-D_Zdnhm8.js → dist-C46474ys.js} +1 -1
- package/dist/client/assets/{dist-Cu75NyQ0.js → dist-CCkdCH6a.js} +1 -1
- package/dist/client/assets/{dist-BJi5HmH4.js → dist-CNuKauOq.js} +1 -1
- package/dist/client/assets/{dist-BAJCrbnX.js → dist-CubYGjAk.js} +1 -1
- package/dist/client/assets/{dist-8pdzh6h1.js → dist-Cwb-Lb5-.js} +1 -1
- package/dist/client/assets/{dist-BRjGckmQ.js → dist-D0Iw5XQA.js} +1 -1
- package/dist/client/assets/{dist-BjZUYxLd.js → dist-DCiuVvGU.js} +1 -1
- package/dist/client/assets/{dist-Hj_dWgPr.js → dist-DHBWUQhM.js} +1 -1
- package/dist/client/assets/{dist-CoszOk5k.js → dist-DOwrSiuY.js} +1 -1
- package/dist/client/assets/{dist-Bg9zLfex.js → dist-DeZsNbFx.js} +1 -1
- package/dist/client/assets/{dist-C6iAHGvb.js → dist-Dk3e6RfY.js} +1 -1
- package/dist/client/assets/{dist-Bv6P2Rcp.js → dist-DoDmoWTn.js} +1 -1
- package/dist/client/assets/{dist-CgmISAMr.js → dist-WkVUFX28.js} +1 -1
- package/dist/client/assets/{dist-DvHghuGd.js → dist-lZUWPQbT.js} +1 -1
- package/dist/client/assets/{dist-Zhdkfvng.js → dist-mPR8uuzk.js} +1 -1
- package/dist/client/assets/{dist-60usx__o.js → dist-spB2Q2CG.js} +1 -1
- package/dist/client/assets/{dist-CMscy2Nn.js → dist-yTTCht0J.js} +1 -1
- package/dist/client/assets/index-Dyta-HkQ.js +100 -0
- package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +1 -0
- package/dist/server/active-memory.js +6 -2
- package/dist/server/agents.js +21 -1
- package/dist/server/application/feishu-service.d.ts +57 -10
- package/dist/server/application/feishu-service.js +118 -24
- package/dist/server/application/run-service.d.ts +4 -0
- package/dist/server/application/run-service.js +123 -88
- package/dist/server/application/workspace-service.d.ts +8 -4
- package/dist/server/application/workspace-service.js +11 -10
- package/dist/server/failure-text.d.ts +18 -0
- package/dist/server/failure-text.js +44 -0
- package/dist/server/feishu-cli.d.ts +18 -2
- package/dist/server/feishu-cli.js +46 -12
- package/dist/server/feishu-config.d.ts +68 -0
- package/dist/server/feishu-config.js +84 -0
- package/dist/server/feishu.d.ts +187 -0
- package/dist/server/feishu.js +695 -0
- package/dist/server/http-server.js +36 -5
- package/dist/server/index.js +5 -2
- package/dist/server/knowledge-embedding-worker.d.ts +1 -0
- package/dist/server/knowledge-embedding-worker.js +27 -0
- package/dist/server/knowledge-vectors.d.ts +5 -3
- package/dist/server/knowledge-vectors.js +49 -47
- package/dist/server/knowledge.d.ts +1 -0
- package/dist/server/knowledge.js +6 -2
- package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
- package/dist/server/persistence/sqlite-state-repository.js +55 -1
- package/dist/server/pi-session-events.js +10 -2
- package/dist/server/pi-session-pool.d.ts +33 -1
- package/dist/server/pi-session-pool.js +20 -2
- package/dist/server/providers.d.ts +13 -0
- package/dist/server/providers.js +27 -2
- package/dist/server/runtime.d.ts +26 -1
- package/dist/server/runtime.js +98 -9
- package/dist/server/store.d.ts +61 -0
- package/dist/server/store.js +112 -14
- package/dist/server/types.d.ts +10 -0
- package/dist/server/ws-server.d.ts +3 -0
- package/dist/server/ws-server.js +35 -6
- package/package.json +25 -2
- package/dist/client/assets/index-ChyemF2I.js +0 -100
|
@@ -6,6 +6,7 @@ import { verifyCapability } from "../capability.js";
|
|
|
6
6
|
import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
|
|
7
7
|
import { ensureNameIndex } from "../file-name-index.js";
|
|
8
8
|
import { formatTurnFailure } from "../turn-failure.js";
|
|
9
|
+
import { describeThrownWithStack } from "../failure-text.js";
|
|
9
10
|
import { execFile } from "node:child_process";
|
|
10
11
|
import { promisify } from "node:util";
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -84,6 +85,7 @@ export class RunService {
|
|
|
84
85
|
extensionUiService;
|
|
85
86
|
agentsDir;
|
|
86
87
|
workflows;
|
|
88
|
+
questionWaiters = new Map();
|
|
87
89
|
memory;
|
|
88
90
|
setMemory(memory) { this.memory = memory; }
|
|
89
91
|
/**
|
|
@@ -238,52 +240,32 @@ export class RunService {
|
|
|
238
240
|
this.broadcaster.broadcastState();
|
|
239
241
|
return row;
|
|
240
242
|
});
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (custom) {
|
|
261
|
-
try {
|
|
262
|
-
await custom.call(this.runtime, task.conversationId, {
|
|
263
|
-
customType: "super-pi-task-result",
|
|
264
|
-
content: body,
|
|
265
|
-
display: "后台任务结果",
|
|
266
|
-
details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
|
|
267
|
-
});
|
|
268
|
-
deliveredViaCustom = true;
|
|
269
|
-
}
|
|
270
|
-
catch (error) {
|
|
271
|
-
console.warn(`Super Pi: custom delivery of the exec result failed, the queue path takes over: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (deliveredViaCustom) {
|
|
275
|
-
await this.store.takeWaitingMessage(task.conversationId, queued.id);
|
|
276
|
-
}
|
|
277
|
-
else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
243
|
+
// THE RESULT WAITS IN SUPER PI'S OWN QUEUE, AND THE QUEUE IS DRAINED ONE TURN AT A TIME.
|
|
244
|
+
//
|
|
245
|
+
// It used to go to Pi's own follow-up queue, and Pi DRAINS that queue all at once when a turn
|
|
246
|
+
// ends - the pending messages are collected into one array and fed to the model as a single
|
|
247
|
+
// turn. So two background results that finished close together were handed over TOGETHER, and
|
|
248
|
+
// the agent answered both at once (ledger TEST-033). That path also had a second defect of its
|
|
249
|
+
// own: Pi's queue knows nothing about the `waiting` row queued above, so delivering through it
|
|
250
|
+
// while leaving the row waiting gave the model the SAME result twice (ledger TEST-011).
|
|
251
|
+
//
|
|
252
|
+
// So the result is not delivered through that channel at all. It stays a `waiting` row, and
|
|
253
|
+
// Super Pi's own loop takes waiting messages ONE PER TURN - which is what that loop was built
|
|
254
|
+
// for, and what the sub-agent result path has always done.
|
|
255
|
+
//
|
|
256
|
+
// IDLE: a turn of its own starts now, so the model processes the result without the user
|
|
257
|
+
// having to send anything. The failure case is included on purpose: the model seeing
|
|
258
|
+
// "[后台任务 X 失败]" is part of the same "after the current answer finishes, the result is
|
|
259
|
+
// processed automatically" contract, and the auto-triggered turn goes through the same
|
|
260
|
+
// `composePrompt` path the success case does.
|
|
261
|
+
if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
278
262
|
void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
|
|
279
263
|
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
280
264
|
});
|
|
281
265
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
await this.submitToPiQueue(task.conversationId, queued);
|
|
286
|
-
}
|
|
266
|
+
// BUSY: the row stays in the waiting queue, and the turn that is running takes it when that
|
|
267
|
+
// turn ends - one message per turn, in the queue's own order. Nothing of a background result
|
|
268
|
+
// goes into Pi's follow-up queue any more.
|
|
287
269
|
}
|
|
288
270
|
catch (error) {
|
|
289
271
|
console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -374,7 +356,7 @@ export class RunService {
|
|
|
374
356
|
* other tool update uses - and the client can only get that index from the row it is already rendering.
|
|
375
357
|
*/
|
|
376
358
|
async answerQuestion(command) {
|
|
377
|
-
const existing = this.store.
|
|
359
|
+
const existing = this.store.question(command.conversationId, command.messageId, command.callIndex);
|
|
378
360
|
if (!existing)
|
|
379
361
|
throw new Error("这个工具调用不是一个问题");
|
|
380
362
|
const calls = await this.store.updateToolCall(command.conversationId, command.messageId, command.callIndex, {
|
|
@@ -385,6 +367,30 @@ export class RunService {
|
|
|
385
367
|
},
|
|
386
368
|
});
|
|
387
369
|
this.broadcaster.broadcast({ type: "message.tools", conversationId: command.conversationId, messageId: command.messageId, toolCalls: calls });
|
|
370
|
+
this.settleQuestion(command.conversationId, command.messageId, command.callIndex, calls[command.callIndex]?.question);
|
|
371
|
+
}
|
|
372
|
+
questionKey(conversationId, messageId, index) {
|
|
373
|
+
return `${conversationId}\0${messageId}\0${index}`;
|
|
374
|
+
}
|
|
375
|
+
answerPayload(current) {
|
|
376
|
+
const answers = (current.answers ?? []).map((answer) => {
|
|
377
|
+
const asked = current.items.find((item) => item.id === answer.id);
|
|
378
|
+
return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
|
|
379
|
+
});
|
|
380
|
+
return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
|
|
381
|
+
}
|
|
382
|
+
settleQuestion(conversationId, messageId, index, question) {
|
|
383
|
+
if (!question || question.status === "pending")
|
|
384
|
+
return;
|
|
385
|
+
const key = this.questionKey(conversationId, messageId, index);
|
|
386
|
+
const waiter = this.questionWaiters.get(key);
|
|
387
|
+
if (!waiter)
|
|
388
|
+
return;
|
|
389
|
+
this.questionWaiters.delete(key);
|
|
390
|
+
if (question.status === "answered")
|
|
391
|
+
waiter.resolve(this.answerPayload(question));
|
|
392
|
+
else
|
|
393
|
+
waiter.reject(new Error("用户没有回答这个问题"));
|
|
388
394
|
}
|
|
389
395
|
/**
|
|
390
396
|
* THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
|
|
@@ -414,28 +420,13 @@ export class RunService {
|
|
|
414
420
|
//
|
|
415
421
|
// So the lookup WAITS. A few hundred milliseconds of polling covers the drain, and a question that
|
|
416
422
|
// genuinely has no pending call still fails - just not instantly.
|
|
417
|
-
const findCall = () => {
|
|
418
|
-
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
419
|
-
const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
|
|
420
|
-
if (!message)
|
|
421
|
-
return undefined;
|
|
422
|
-
for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
|
|
423
|
-
const call = message.toolCalls?.[i];
|
|
424
|
-
if (call?.label === "question" && call.question?.status === "pending")
|
|
425
|
-
return { messageId: message.id, index: i };
|
|
426
|
-
}
|
|
427
|
-
return undefined;
|
|
428
|
-
};
|
|
429
423
|
let found;
|
|
430
424
|
for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
|
|
431
|
-
found =
|
|
425
|
+
found = this.store.pendingQuestion(conversationId);
|
|
432
426
|
if (found)
|
|
433
427
|
break;
|
|
434
428
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
435
429
|
}
|
|
436
|
-
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
437
|
-
if (!conversation?.messages.length)
|
|
438
|
-
throw new Error("No active assistant message");
|
|
439
430
|
if (!found)
|
|
440
431
|
throw new Error("这个会话里没有正在等待回答的 question 工具调用");
|
|
441
432
|
const { messageId, index } = found;
|
|
@@ -445,29 +436,7 @@ export class RunService {
|
|
|
445
436
|
// continuing from a question that was never decided). The run-level teardown (stop / abort /
|
|
446
437
|
// shutdown) clears the underlying ask and the client settles it as cancelled, which lands here
|
|
447
438
|
// and rejects. A question that is simply unanswered waits indefinitely.
|
|
448
|
-
|
|
449
|
-
const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
|
|
450
|
-
if (current?.status === "answered") {
|
|
451
|
-
clearInterval(poll);
|
|
452
|
-
resolve(answerPayload(current));
|
|
453
|
-
}
|
|
454
|
-
else if (current?.status === "cancelled") {
|
|
455
|
-
clearInterval(poll);
|
|
456
|
-
reject(new Error("用户没有回答这个问题"));
|
|
457
|
-
}
|
|
458
|
-
}, 250);
|
|
459
|
-
// WHAT GOES BACK TO THE MODEL IS WRITTEN FOR THE MODEL. The answers pair to their questions by id,
|
|
460
|
-
// which is what stops a person answering the second of two being recorded against the first - but an
|
|
461
|
-
// id is a UUID, and a model handed `{answers:[{id:"671b0f0b-…", selected:["发"]}]}` has to guess at
|
|
462
|
-
// what that means. It guessed, and reported a real answer as a cancellation. So the answer goes back
|
|
463
|
-
// as its questions and its choices, with a sentence naming the outcome.
|
|
464
|
-
const answerPayload = (current) => {
|
|
465
|
-
const answers = (current.answers ?? []).map((answer) => {
|
|
466
|
-
const asked = current.items.find((item) => item.id === answer.id);
|
|
467
|
-
return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
|
|
468
|
-
});
|
|
469
|
-
return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
|
|
470
|
-
};
|
|
439
|
+
this.questionWaiters.set(this.questionKey(conversationId, messageId, index), { resolve, reject });
|
|
471
440
|
});
|
|
472
441
|
}
|
|
473
442
|
/**
|
|
@@ -571,6 +540,7 @@ export class RunService {
|
|
|
571
540
|
// Abort immediately before waiting for the conversation lane; otherwise a stop queued behind a
|
|
572
541
|
// long streaming command could never reach the controller that it is meant to cancel.
|
|
573
542
|
this.activeRuns.get(conversationId)?.abort();
|
|
543
|
+
this.broadcaster.broadcast({ type: "run.stopping", conversationId });
|
|
574
544
|
await this.runtime.stop?.(conversationId);
|
|
575
545
|
return this.serializeConversation(conversationId, () => this.stopRunUnsafe(conversationId));
|
|
576
546
|
}
|
|
@@ -591,9 +561,9 @@ export class RunService {
|
|
|
591
561
|
continue;
|
|
592
562
|
const calls = await this.store.updateToolCall(conversationId, message.id, index, { question: { items: question.items, status: "cancelled" } });
|
|
593
563
|
this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: message.id, toolCalls: calls });
|
|
564
|
+
this.settleQuestion(conversationId, message.id, index, calls[index]?.question);
|
|
594
565
|
}
|
|
595
566
|
}
|
|
596
|
-
await this.store.setStatus(conversationId, "stopped");
|
|
597
567
|
if (this.subagents.stopConversation(conversationId)) {
|
|
598
568
|
console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
|
|
599
569
|
}
|
|
@@ -603,6 +573,17 @@ export class RunService {
|
|
|
603
573
|
if (this.workflows.stopConversation(conversationId)) {
|
|
604
574
|
console.log(`Super Pi: stopped the running orchestration plans of conversation ${conversationId}.`);
|
|
605
575
|
}
|
|
576
|
+
// AWAIT RUN SLOT RELEASE BEFORE CLAIMING "STOPPED".
|
|
577
|
+
// A stop previously called setStatus("stopped") immediately, while activeRuns still held the
|
|
578
|
+
// conversation slot and the streaming lane was still draining. This produced the contradiction:
|
|
579
|
+
// the UI displayed 「已停止」 and offered 「发送」, but sending a new message hit an active
|
|
580
|
+
// run slot and was falsely placed into 「排队中」, while the previous turn might still produce text.
|
|
581
|
+
await this.awaitRunSlotReleased(conversationId, false, 3_000);
|
|
582
|
+
if (this.activeRuns.has(conversationId)) {
|
|
583
|
+
console.warn(`Super Pi: run slot for conversation ${conversationId} not released after stop deadline; force-releasing.`);
|
|
584
|
+
this.releaseRunSlot(conversationId);
|
|
585
|
+
}
|
|
586
|
+
await this.store.setStatus(conversationId, "stopped");
|
|
606
587
|
this.broadcaster.broadcastState();
|
|
607
588
|
}
|
|
608
589
|
/**
|
|
@@ -777,14 +758,29 @@ export class RunService {
|
|
|
777
758
|
// long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
|
|
778
759
|
// every delta would be a per-token status write.
|
|
779
760
|
let firstSignal = true;
|
|
761
|
+
/**
|
|
762
|
+
* Promotes the turn to `running`, and ANSWERS WHETHER IT PUBLISHED A STATE SNAPSHOT.
|
|
763
|
+
*
|
|
764
|
+
* The answer matters because a state snapshot and a live event are two carriers of the same
|
|
765
|
+
* information: the snapshot is the whole truth (it carries the text, the thinking and the block
|
|
766
|
+
* boundaries), and a live `message.delta` / `message.thinking` event is an increment on top of it.
|
|
767
|
+
* A caller that has ALREADY put a delta into the store must therefore not also publish it as a
|
|
768
|
+
* live event, or the browser receives it twice and counts it twice - the reasoning the reader
|
|
769
|
+
* sees is longer than the reasoning the model wrote.
|
|
770
|
+
*
|
|
771
|
+
* So the rule is: WHOEVER PUTS THE DELTA IN THE STORE OWNS THE DELIVERY. The promotion publishes
|
|
772
|
+
* a snapshot only when it actually flips the status, and the caller asks.
|
|
773
|
+
*/
|
|
780
774
|
const promoteToRunning = async () => {
|
|
781
775
|
if (!firstSignal)
|
|
782
|
-
return;
|
|
776
|
+
return false;
|
|
783
777
|
firstSignal = false;
|
|
784
778
|
if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
|
|
785
779
|
await this.store.setStatus(conversation.id, "running");
|
|
786
780
|
this.broadcaster.broadcastState();
|
|
781
|
+
return true;
|
|
787
782
|
}
|
|
783
|
+
return false;
|
|
788
784
|
};
|
|
789
785
|
// Live events are independent from durable checkpoints. Persisting every token made the stdout
|
|
790
786
|
// reader wait for one disk transaction per token; over a remote browser connection that backlog
|
|
@@ -1132,9 +1128,17 @@ export class RunService {
|
|
|
1132
1128
|
// `active: true` on every delta is not a field Pi sent - it is the conclusion Super Pi draws from
|
|
1133
1129
|
// having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
|
|
1134
1130
|
// the delta, so these two halves together are the whole lifecycle.
|
|
1135
|
-
onThinking: (delta) => {
|
|
1131
|
+
onThinking: async (delta) => {
|
|
1136
1132
|
if (!delta)
|
|
1137
1133
|
return;
|
|
1134
|
+
// THINKING IS THE MODEL PRODUCING SOMETHING, SO THE TURN IS RUNNING. The contract above
|
|
1135
|
+
// says a turn moves from `waiting` to `running` once the model has produced ANY signal -
|
|
1136
|
+
// "regardless of shape" - and a thinking delta is the same signal as a text delta: it is Pi
|
|
1137
|
+
// yielding data back. Only two shapes promoted the turn (text and a tool-call notice), so a
|
|
1138
|
+
// model that thinks before it answers sat in `waiting` for the whole of its reasoning: the
|
|
1139
|
+
// panel said 「等待 LLM 返回」 while the model was plainly working, and - because the stop
|
|
1140
|
+
// button appears while a turn is in flight - the reader had no way to stop it for exactly
|
|
1141
|
+
// as long as the thinking lasted.
|
|
1138
1142
|
const newBlock = thinkingAnswerAt !== spoken || thinkingInterrupted;
|
|
1139
1143
|
if (newBlock) {
|
|
1140
1144
|
thinkingBlock++;
|
|
@@ -1144,8 +1148,27 @@ export class RunService {
|
|
|
1144
1148
|
thinkingActive = true;
|
|
1145
1149
|
this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
|
|
1146
1150
|
thought += delta.length;
|
|
1151
|
+
// THE DELTA GOES INTO THE STORE FIRST, and the promotion that follows broadcasts a state
|
|
1152
|
+
// snapshot that therefore CARRIES it - the text, the thinking and the block boundary the
|
|
1153
|
+
// browser needs to put this fragment in the right fold. That snapshot is the delta's
|
|
1154
|
+
// delivery, so the live event is not sent as well: sent twice, the browser appends the
|
|
1155
|
+
// first fragment to a copy that already has it, and the reasoning the reader sees is longer
|
|
1156
|
+
// than the reasoning the model wrote.
|
|
1157
|
+
//
|
|
1158
|
+
// THE OTHER ORDER HAD ITS OWN FAILURE, and it is why the snapshot is not simply left to
|
|
1159
|
+
// carry everything: promoting BEFORE the append publishes a snapshot WITHOUT this delta,
|
|
1160
|
+
// and the browser - which rebuilds its fold blocks from the snapshot's boundaries - then
|
|
1161
|
+
// starts a second block for the same stretch of reasoning, so one continuous train of
|
|
1162
|
+
// thought arrives as two fold bars and the tool call inside it falls out of the block it
|
|
1163
|
+
// belonged to.
|
|
1164
|
+
//
|
|
1165
|
+
// So: the snapshot carries the delta, and only the deltas the snapshot did not carry are
|
|
1166
|
+
// published live. `promoteToRunning` answers whether it published one.
|
|
1147
1167
|
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
|
|
1148
|
-
|
|
1168
|
+
const promoted = await promoteToRunning();
|
|
1169
|
+
if (!promoted) {
|
|
1170
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
1171
|
+
}
|
|
1149
1172
|
scheduleCheckpoint();
|
|
1150
1173
|
},
|
|
1151
1174
|
// AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
|
|
@@ -1310,6 +1333,12 @@ export class RunService {
|
|
|
1310
1333
|
// itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
|
|
1311
1334
|
// going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
|
|
1312
1335
|
// what two fast sends looks like from the inside.
|
|
1336
|
+
// An aborted run is unwinding its slot. Wait out that brief cleanup rather than falsely
|
|
1337
|
+
// treating it as an active turn and sending this message to a dying follow-up queue.
|
|
1338
|
+
const active = this.activeRuns.get(conversation.id);
|
|
1339
|
+
if (active?.signal.aborted) {
|
|
1340
|
+
await this.awaitRunSlotReleased(conversation.id, false);
|
|
1341
|
+
}
|
|
1313
1342
|
const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
|
|
1314
1343
|
const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
|
|
1315
1344
|
this.broadcaster.broadcastState();
|
|
@@ -1361,6 +1390,9 @@ export class RunService {
|
|
|
1361
1390
|
// waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
|
|
1362
1391
|
// survive a retry into the same conversation.
|
|
1363
1392
|
await this.store.setFailureReason(conversationId, formatTurnFailure(error));
|
|
1393
|
+
// The refusal is written for the reader, but the reader is not the only one who needs to know
|
|
1394
|
+
// why a turn never started: the server log is where the stack survives.
|
|
1395
|
+
console.warn("Super Pi turn refused before it started:", describeThrownWithStack(error));
|
|
1364
1396
|
// Status is restored here, not relied on the outer catch to handle: a preflight failure never
|
|
1365
1397
|
// opened a turn, so the right state is the one the conversation had a moment ago (idle). The
|
|
1366
1398
|
// outer catch would otherwise flip it to `failed`, which the reader would render as a turn
|
|
@@ -1410,6 +1442,9 @@ export class RunService {
|
|
|
1410
1442
|
if (controller.signal.aborted)
|
|
1411
1443
|
return;
|
|
1412
1444
|
await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
|
|
1445
|
+
// A turn that broke is the failure the reader will complain about, and the banner only carries
|
|
1446
|
+
// the sentence. The stack goes to the server log, where it can still be found.
|
|
1447
|
+
console.error("Super Pi turn failed:", describeThrownWithStack(error));
|
|
1413
1448
|
throw error;
|
|
1414
1449
|
}
|
|
1415
1450
|
finally {
|
|
@@ -1446,8 +1481,8 @@ export class RunService {
|
|
|
1446
1481
|
* and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
|
|
1447
1482
|
* whenever the current answer finishes", which is what the queue is for.
|
|
1448
1483
|
*/
|
|
1449
|
-
async awaitRunSlotReleased(conversationId, onlyWhileStopped = false) {
|
|
1450
|
-
const deadline = Date.now() +
|
|
1484
|
+
async awaitRunSlotReleased(conversationId, onlyWhileStopped = false, timeoutMs = 15_000) {
|
|
1485
|
+
const deadline = Date.now() + timeoutMs;
|
|
1451
1486
|
while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
|
|
1452
1487
|
if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
|
|
1453
1488
|
return;
|
|
@@ -22,9 +22,8 @@ export declare class WorkspaceService {
|
|
|
22
22
|
private readonly catalogService;
|
|
23
23
|
private readonly runService;
|
|
24
24
|
private readonly extensionUiService;
|
|
25
|
-
private readonly uploadsDir;
|
|
26
25
|
private agentsDir;
|
|
27
|
-
constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService,
|
|
26
|
+
constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService, agentsDir: string | string[]);
|
|
28
27
|
setAgentsDir(dir: string | string[]): void;
|
|
29
28
|
/**
|
|
30
29
|
* Creates a new workspace.
|
|
@@ -35,8 +34,13 @@ export declare class WorkspaceService {
|
|
|
35
34
|
*/
|
|
36
35
|
renameWorkspace(workspaceId: string, name: string): Promise<void>;
|
|
37
36
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Takes a workspace out of the sidebar. NOTHING IS DELETED.
|
|
38
|
+
*
|
|
39
|
+
* The conversations under it, the uploads attached to them and the workspace's own defaults all
|
|
40
|
+
* stay on disk: removing a workspace from the list is a soft unmount, and adding the same
|
|
41
|
+
* folder back brings the whole history with it. What does stop is the WORK: a turn that was still
|
|
42
|
+
* running cannot keep a model session alive behind a workspace the user can no longer see, and a
|
|
43
|
+
* pending question would wait for an answer the sidebar can no longer reach.
|
|
40
44
|
*/
|
|
41
45
|
deleteWorkspace(workspaceId: string): Promise<void>;
|
|
42
46
|
/**
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { assertModelAllowed } from "../model-filter.js";
|
|
2
|
-
import { deleteConversationUploads } from "../upload.js";
|
|
3
2
|
/**
|
|
4
3
|
* Service managing workspaces: creation, rename, cascade deletion, and defaults configuration.
|
|
5
4
|
*/
|
|
@@ -10,16 +9,14 @@ export class WorkspaceService {
|
|
|
10
9
|
catalogService;
|
|
11
10
|
runService;
|
|
12
11
|
extensionUiService;
|
|
13
|
-
uploadsDir;
|
|
14
12
|
agentsDir;
|
|
15
|
-
constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService,
|
|
13
|
+
constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService, agentsDir) {
|
|
16
14
|
this.store = store;
|
|
17
15
|
this.runtime = runtime;
|
|
18
16
|
this.broadcaster = broadcaster;
|
|
19
17
|
this.catalogService = catalogService;
|
|
20
18
|
this.runService = runService;
|
|
21
19
|
this.extensionUiService = extensionUiService;
|
|
22
|
-
this.uploadsDir = uploadsDir;
|
|
23
20
|
this.agentsDir = agentsDir;
|
|
24
21
|
}
|
|
25
22
|
setAgentsDir(dir) {
|
|
@@ -41,21 +38,25 @@ export class WorkspaceService {
|
|
|
41
38
|
this.broadcaster.broadcastState();
|
|
42
39
|
}
|
|
43
40
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
41
|
+
* Takes a workspace out of the sidebar. NOTHING IS DELETED.
|
|
42
|
+
*
|
|
43
|
+
* The conversations under it, the uploads attached to them and the workspace's own defaults all
|
|
44
|
+
* stay on disk: removing a workspace from the list is a soft unmount, and adding the same
|
|
45
|
+
* folder back brings the whole history with it. What does stop is the WORK: a turn that was still
|
|
46
|
+
* running cannot keep a model session alive behind a workspace the user can no longer see, and a
|
|
47
|
+
* pending question would wait for an answer the sidebar can no longer reach.
|
|
46
48
|
*/
|
|
47
49
|
async deleteWorkspace(workspaceId) {
|
|
48
50
|
const snapshot = this.store.snapshot();
|
|
49
|
-
const
|
|
50
|
-
for (const c of
|
|
51
|
+
const affected = snapshot.conversations.filter((c) => c.workspaceId === workspaceId);
|
|
52
|
+
for (const c of affected) {
|
|
51
53
|
this.extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
52
54
|
if (this.runService.isRunning(c.id)) {
|
|
53
55
|
await this.runService.stopRun(c.id);
|
|
54
56
|
}
|
|
55
57
|
this.runService.stopConversationSubagents(c.id);
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
await Promise.all(doomedIds.map((id) => deleteConversationUploads(this.uploadsDir, id)));
|
|
59
|
+
await this.store.deleteWorkspace(workspaceId);
|
|
59
60
|
this.broadcaster.broadcastState();
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TEXT OF A FAILURE.
|
|
3
|
+
*
|
|
4
|
+
* A server failure reaches the browser as a sentence, and the reader is entitled to know what
|
|
5
|
+
* actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
|
|
6
|
+
* string, a plain object, a rejected promise with no reason at all. The old code answered
|
|
7
|
+
* `command failed` in those cases - the one sentence that says nothing about anything - so a real
|
|
8
|
+
* cause that had reached the server was replaced by a sentence with no cause in it.
|
|
9
|
+
*
|
|
10
|
+
* This module is the one place that decides what a thrown value turns into. It never invents
|
|
11
|
+
* text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
|
|
12
|
+
* JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
|
|
13
|
+
* reads as "no reason was given", and says so out loud instead of pretending.
|
|
14
|
+
*/
|
|
15
|
+
/** The failure as a person should read it. Empty when the thrown value carries no information. */
|
|
16
|
+
export declare function describeThrown(error: unknown): string;
|
|
17
|
+
/** The failure with its stack, for the log file. */
|
|
18
|
+
export declare function describeThrownWithStack(error: unknown): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TEXT OF A FAILURE.
|
|
3
|
+
*
|
|
4
|
+
* A server failure reaches the browser as a sentence, and the reader is entitled to know what
|
|
5
|
+
* actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
|
|
6
|
+
* string, a plain object, a rejected promise with no reason at all. The old code answered
|
|
7
|
+
* `command failed` in those cases - the one sentence that says nothing about anything - so a real
|
|
8
|
+
* cause that had reached the server was replaced by a sentence with no cause in it.
|
|
9
|
+
*
|
|
10
|
+
* This module is the one place that decides what a thrown value turns into. It never invents
|
|
11
|
+
* text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
|
|
12
|
+
* JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
|
|
13
|
+
* reads as "no reason was given", and says so out loud instead of pretending.
|
|
14
|
+
*/
|
|
15
|
+
/** The failure as a person should read it. Empty when the thrown value carries no information. */
|
|
16
|
+
export function describeThrown(error) {
|
|
17
|
+
if (error instanceof Error)
|
|
18
|
+
return error.message || error.name;
|
|
19
|
+
if (typeof error === "string")
|
|
20
|
+
return error;
|
|
21
|
+
if (error === null || error === undefined)
|
|
22
|
+
return "";
|
|
23
|
+
if (typeof error === "object") {
|
|
24
|
+
const message = error.message;
|
|
25
|
+
if (typeof message === "string" && message.trim())
|
|
26
|
+
return message;
|
|
27
|
+
try {
|
|
28
|
+
const text = JSON.stringify(error);
|
|
29
|
+
if (text && text !== "{}")
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Circular or otherwise unserializable: there is nothing better to say than "no reason".
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
return String(error);
|
|
38
|
+
}
|
|
39
|
+
/** The failure with its stack, for the log file. */
|
|
40
|
+
export function describeThrownWithStack(error) {
|
|
41
|
+
if (error instanceof Error && error.stack)
|
|
42
|
+
return error.stack;
|
|
43
|
+
return describeThrown(error);
|
|
44
|
+
}
|
|
@@ -64,6 +64,16 @@ export type FeishuCliStatus = {
|
|
|
64
64
|
identity?: FeishuIdentity;
|
|
65
65
|
/** Feishu's own "what to do about it", when the CLI had something to say. */
|
|
66
66
|
hint?: string;
|
|
67
|
+
/** The CLI's own summary of the situation, when it has one. */
|
|
68
|
+
note?: string;
|
|
69
|
+
/** The state of each identity, as the CLI words it: ready, missing, verify_failed, ... */
|
|
70
|
+
botStatus?: string;
|
|
71
|
+
userStatus?: string;
|
|
72
|
+
/** What the CLI says about each identity, in its own words. */
|
|
73
|
+
userMessage?: string;
|
|
74
|
+
botMessage?: string;
|
|
75
|
+
/** Whether the check actually reached Feishu, rather than only reading the local credential. */
|
|
76
|
+
verified?: boolean;
|
|
67
77
|
};
|
|
68
78
|
/** The outcome of a CLI call, in the shape this product speaks in. */
|
|
69
79
|
export declare class FeishuCliError extends Error {
|
|
@@ -139,8 +149,14 @@ export declare class FeishuCli {
|
|
|
139
149
|
/**
|
|
140
150
|
* WHAT THE CLI KNOWS ABOUT ITSELF.
|
|
141
151
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
152
|
+
* THE VERIFY IS ON, and that is the whole point of asking the CLI at all. Without `--verify` the
|
|
153
|
+
* CLI only reads its local credential and reports the application as "ready" — which is what it
|
|
154
|
+
* said on a machine whose App Secret had expired, and which would have left the panel claiming
|
|
155
|
+
* everything was fine while every document call failed. With `--verify` the CLI asks Feishu, and
|
|
156
|
+
* the answer distinguishes the states a person can act on: `ready`, `missing`, `verify_failed`.
|
|
157
|
+
*
|
|
158
|
+
* When the verification cannot reach Feishu at all (no network), the plain status is read
|
|
159
|
+
* instead, and `verified: false` says so rather than pretending.
|
|
144
160
|
*/
|
|
145
161
|
status(): Promise<FeishuCliStatus>;
|
|
146
162
|
/**
|
|
@@ -198,38 +198,72 @@ export class FeishuCli {
|
|
|
198
198
|
/**
|
|
199
199
|
* WHAT THE CLI KNOWS ABOUT ITSELF.
|
|
200
200
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
201
|
+
* THE VERIFY IS ON, and that is the whole point of asking the CLI at all. Without `--verify` the
|
|
202
|
+
* CLI only reads its local credential and reports the application as "ready" — which is what it
|
|
203
|
+
* said on a machine whose App Secret had expired, and which would have left the panel claiming
|
|
204
|
+
* everything was fine while every document call failed. With `--verify` the CLI asks Feishu, and
|
|
205
|
+
* the answer distinguishes the states a person can act on: `ready`, `missing`, `verify_failed`.
|
|
206
|
+
*
|
|
207
|
+
* When the verification cannot reach Feishu at all (no network), the plain status is read
|
|
208
|
+
* instead, and `verified: false` says so rather than pretending.
|
|
203
209
|
*/
|
|
204
210
|
async status() {
|
|
205
211
|
const version = await this.version();
|
|
206
212
|
if (!version)
|
|
207
213
|
return { installed: false, userReady: false, botReady: false };
|
|
214
|
+
const read = async (args) => this.run(args, { timeoutMs: 30_000 });
|
|
208
215
|
let envelope;
|
|
216
|
+
let verified = true;
|
|
209
217
|
try {
|
|
210
|
-
envelope = await
|
|
218
|
+
envelope = await read(["auth", "status", "--verify", "--json"]);
|
|
219
|
+
// The CLI answers a failed verification with an error envelope. That is not the same as a
|
|
220
|
+
// credential that Feishu refused - it means the question was never answered - so the local
|
|
221
|
+
// status is read instead and the panel is told that Feishu was never asked.
|
|
222
|
+
if (envelope.ok === false) {
|
|
223
|
+
envelope = await read(["auth", "status", "--json"]);
|
|
224
|
+
verified = false;
|
|
225
|
+
}
|
|
211
226
|
}
|
|
212
227
|
catch (error) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
228
|
+
try {
|
|
229
|
+
envelope = await read(["auth", "status", "--json"]);
|
|
230
|
+
verified = false;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return {
|
|
234
|
+
installed: true,
|
|
235
|
+
version,
|
|
236
|
+
userReady: false,
|
|
237
|
+
botReady: false,
|
|
238
|
+
...(error instanceof FeishuCliError ? { hint: error.message } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
220
241
|
}
|
|
221
242
|
const identities = pick(envelope.data ?? envelope, "identities");
|
|
222
243
|
const user = identities?.user;
|
|
223
244
|
const bot = identities?.bot;
|
|
224
245
|
const identity = asString(envelope.identity) ?? pick(envelope.data ?? envelope, "identity");
|
|
246
|
+
const appId = asString(pick(envelope.data ?? envelope, "appId", "app_id"));
|
|
247
|
+
const note = asString(pick(envelope.data ?? envelope, "note"));
|
|
248
|
+
// THE HINT OF THE IDENTITY THAT IS FAILING. Both identities carry a hint, and the wrong one
|
|
249
|
+
// sends the person to the wrong command: the application's hint says "log in" when the real
|
|
250
|
+
// problem is that the credential was refused, and the user's hint says "check the credential"
|
|
251
|
+
// when all that is missing is a login.
|
|
252
|
+
const failing = bot?.available !== true ? bot : user;
|
|
225
253
|
return {
|
|
226
254
|
installed: true,
|
|
227
255
|
version,
|
|
228
|
-
...(
|
|
256
|
+
...(appId ? { appId } : {}),
|
|
229
257
|
userReady: user?.available === true,
|
|
230
258
|
botReady: bot?.available === true,
|
|
231
259
|
...(identity === "user" || identity === "bot" ? { identity } : {}),
|
|
232
|
-
...(asString(user?.
|
|
260
|
+
...(asString(user?.status) ? { userStatus: asString(user?.status) } : {}),
|
|
261
|
+
...(asString(bot?.status) ? { botStatus: asString(bot?.status) } : {}),
|
|
262
|
+
...(asString(user?.message) ? { userMessage: asString(user?.message) } : {}),
|
|
263
|
+
...(asString(bot?.message) ? { botMessage: asString(bot?.message) } : {}),
|
|
264
|
+
...(asString(failing?.hint) ? { hint: asString(failing?.hint) } : {}),
|
|
265
|
+
...(note ? { note } : {}),
|
|
266
|
+
verified,
|
|
233
267
|
};
|
|
234
268
|
}
|
|
235
269
|
/**
|