@evandu/super-pi 0.1.84 → 0.1.86
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/agents/super-pi-knowledge/index.ts +40 -0
- package/agents/super-pi-task/index.ts +2 -2
- package/assets/knowledge-model/README.md +37 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
- package/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +10 -7
- package/dist/client/assets/index-B3QVoYJY.css +1 -0
- package/dist/client/assets/index-C_giUkvT.js +72 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +50 -11
- package/dist/server/application/run-service.js +206 -77
- package/dist/server/command-handler.js +16 -4
- package/dist/server/http-server.js +218 -8
- package/dist/server/index.js +15 -1
- package/dist/server/knowledge-vectors.d.ts +133 -0
- package/dist/server/knowledge-vectors.js +302 -0
- package/dist/server/knowledge.d.ts +263 -0
- package/dist/server/knowledge.js +743 -0
- package/dist/server/persistence/app-state-repository.d.ts +25 -1
- package/dist/server/persistence/sqlite-state-repository.js +47 -4
- package/dist/server/runtime.d.ts +82 -1
- package/dist/server/runtime.js +128 -7
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/store.d.ts +153 -68
- package/dist/server/store.js +405 -187
- package/dist/server/tool-llm.d.ts +65 -0
- package/dist/server/tool-llm.js +139 -0
- package/dist/server/tool-registry.d.ts +65 -0
- package/dist/server/tool-registry.js +129 -6
- package/dist/server/tool-runner.d.ts +70 -0
- package/dist/server/tool-runner.js +210 -10
- package/dist/server/types.d.ts +97 -65
- package/package.json +166 -155
- package/dist/client/assets/index-8Pf3MG2d.js +0 -72
- package/dist/client/assets/index-Jnj15dhT.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-C_giUkvT.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B3QVoYJY.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -175,39 +175,78 @@ export declare class RunService {
|
|
|
175
175
|
private stopRunUnsafe;
|
|
176
176
|
/**
|
|
177
177
|
* Handles queue snapshot updates from Pi RPC.
|
|
178
|
+
*
|
|
179
|
+
* NOW PURELY OBSERVATIONAL, and that is the fix rather than a simplification. It used to INFER a
|
|
180
|
+
* delivery from a drop in `steering.length` and act on it by deleting the oldest `sending` row - which is
|
|
181
|
+
* how a steered message could reach the model, be answered at length, and leave no trace in the
|
|
182
|
+
* transcript the user was reading. A count also cannot say WHICH row, so a snapshot arriving out of
|
|
183
|
+
* order could delete a message that had not been delivered at all.
|
|
184
|
+
*
|
|
185
|
+
* Delivery is now announced by Pi itself, through `onSteerDelivered`, and applied by `streamTurn`. What
|
|
186
|
+
* is left here is the one thing this event is genuinely good for: telling the browser what Pi holds, at
|
|
187
|
+
* the moment Pi holds it.
|
|
178
188
|
*/
|
|
179
189
|
onPiQueue(conversationId: string, snapshot: PiQueueSnapshot): void;
|
|
180
190
|
/**
|
|
181
|
-
* Opens a turn
|
|
191
|
+
* Opens a turn on a message that is ALREADY IN THE TRANSCRIPT.
|
|
192
|
+
*
|
|
193
|
+
* It creates no user message. The words were written when the user pressed send - that is the whole
|
|
194
|
+
* promise the client is making - so opening a turn can only mark that message as no longer waiting and
|
|
195
|
+
* put the answer beneath it. `store.openTurnOnMessage` does both in one write, and splices the assistant
|
|
196
|
+
* message in directly AFTER rather than appending it, which is what keeps a second waiting message below
|
|
197
|
+
* the reply to the first.
|
|
182
198
|
*/
|
|
183
|
-
openTurn(conversationId: string,
|
|
199
|
+
openTurn(conversationId: string, messageId: string): Promise<{
|
|
184
200
|
user: Message;
|
|
185
201
|
assistant: Message;
|
|
186
202
|
}>;
|
|
187
203
|
/**
|
|
188
204
|
* Streams a turn via AgentRuntime.
|
|
205
|
+
*
|
|
206
|
+
* A TURN CAN CONTAIN MORE THAN ONE ANSWER. When Pi delivers a steered message it announces the
|
|
207
|
+
* boundary (`message_start`), and everything streamed after it is the answer to THAT message rather than
|
|
208
|
+
* more of the same one. So this function can be handed a different `assistant` mid-flight: `assistant`
|
|
209
|
+
* is a parameter rather than a const precisely so one assignment redirects every handler below, each of
|
|
210
|
+
* which closes over the binding rather than over the value it saw when it was made.
|
|
211
|
+
*
|
|
212
|
+
* Before this, a delivered steer's answer was appended to the message that was already streaming. The
|
|
213
|
+
* model was answering words that existed in no message at all, and the transcript showed a reply with
|
|
214
|
+
* nothing above it - the user could not find what they had typed, while the agent quoted it back.
|
|
189
215
|
*/
|
|
190
|
-
streamTurn(conversation: Conversation, workspace: Workspace, prompt: string,
|
|
216
|
+
streamTurn(conversation: Conversation, workspace: Workspace, prompt: string, assistantMessage: Message, controller: AbortController, userMessageId: string, images?: MessageImage[], agentIdentity?: Message["agentIdentity"]): Promise<void>;
|
|
191
217
|
/**
|
|
192
|
-
* Loops running turns for as long as
|
|
218
|
+
* Loops running turns for as long as waiting messages exist.
|
|
219
|
+
*
|
|
220
|
+
* The FIRST message is passed in rather than opened here, because it was written into the transcript
|
|
221
|
+
* before this method was called: the user pressed send, and their words were on screen in that frame. The
|
|
222
|
+
* loop then does the same thing for each message after it - take the next waiting one, open its turn,
|
|
223
|
+
* stream it - which is why the two cases are expressed identically and cannot drift apart.
|
|
193
224
|
*/
|
|
194
|
-
runConversationTurns(conversation: Conversation, workspace: Workspace, prompt: string, controller: AbortController, initialImages?: MessageImage[]
|
|
225
|
+
runConversationTurns(conversation: Conversation, workspace: Workspace, prompt: string, controller: AbortController, firstMessageId: string, initialImages?: MessageImage[]): Promise<void>;
|
|
195
226
|
/**
|
|
196
227
|
* Enqueues or starts execution for a user message.
|
|
197
228
|
*/
|
|
198
|
-
enqueueAndRun(conversationId: string, content: string, images?: MessageImage[], attachments?: import("../types.js").MessageAttachments): Promise<void>;
|
|
229
|
+
enqueueAndRun(conversationId: string, content: string, images?: MessageImage[], attachments?: import("../types.js").MessageAttachments, clientId?: string): Promise<void>;
|
|
199
230
|
/**
|
|
200
231
|
* Steers a queued message into the in-flight turn.
|
|
201
232
|
*/
|
|
202
|
-
steer(conversationId: string,
|
|
203
|
-
/** Starts a chosen
|
|
204
|
-
sendQueued(conversationId: string,
|
|
233
|
+
steer(conversationId: string, messageId: string): Promise<void>;
|
|
234
|
+
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
235
|
+
sendQueued(conversationId: string, messageId: string): Promise<void>;
|
|
205
236
|
private steerUnsafe;
|
|
206
237
|
/**
|
|
207
|
-
*
|
|
238
|
+
* Withdraws a waiting message.
|
|
208
239
|
*/
|
|
209
|
-
cancelQueued(conversationId: string,
|
|
240
|
+
cancelQueued(conversationId: string, messageId: string): Promise<void>;
|
|
210
241
|
private cancelQueuedUnsafe;
|
|
242
|
+
/**
|
|
243
|
+
* Rewrites a waiting message, in place, where the user can see it change.
|
|
244
|
+
*/
|
|
245
|
+
editQueued(conversationId: string, messageId: string, content: string): Promise<void>;
|
|
246
|
+
/**
|
|
247
|
+
* Moves a waiting message within the waiting set.
|
|
248
|
+
*/
|
|
249
|
+
reorderQueued(conversationId: string, messageId: string, toIndex: number): Promise<void>;
|
|
211
250
|
/**
|
|
212
251
|
* Dispatches a subagent from client command (human dispatch).
|
|
213
252
|
*/
|
|
@@ -377,34 +377,55 @@ export class RunService {
|
|
|
377
377
|
}
|
|
378
378
|
/**
|
|
379
379
|
* Handles queue snapshot updates from Pi RPC.
|
|
380
|
+
*
|
|
381
|
+
* NOW PURELY OBSERVATIONAL, and that is the fix rather than a simplification. It used to INFER a
|
|
382
|
+
* delivery from a drop in `steering.length` and act on it by deleting the oldest `sending` row - which is
|
|
383
|
+
* how a steered message could reach the model, be answered at length, and leave no trace in the
|
|
384
|
+
* transcript the user was reading. A count also cannot say WHICH row, so a snapshot arriving out of
|
|
385
|
+
* order could delete a message that had not been delivered at all.
|
|
386
|
+
*
|
|
387
|
+
* Delivery is now announced by Pi itself, through `onSteerDelivered`, and applied by `streamTurn`. What
|
|
388
|
+
* is left here is the one thing this event is genuinely good for: telling the browser what Pi holds, at
|
|
389
|
+
* the moment Pi holds it.
|
|
380
390
|
*/
|
|
381
391
|
onPiQueue(conversationId, snapshot) {
|
|
382
392
|
this.broadcaster.broadcast({ type: "pi.queue", conversationId, ...snapshot });
|
|
383
|
-
const held = this.store.listQueuedMessages(conversationId).filter((message) => message.state === "sending").length;
|
|
384
|
-
const delivered = held - snapshot.steering.length;
|
|
385
|
-
if (delivered > 0) {
|
|
386
|
-
void this.store.dropSendingMessages(conversationId, delivered).then(() => this.broadcaster.broadcastState());
|
|
387
|
-
}
|
|
388
393
|
}
|
|
389
394
|
/**
|
|
390
|
-
* Opens a turn
|
|
395
|
+
* Opens a turn on a message that is ALREADY IN THE TRANSCRIPT.
|
|
396
|
+
*
|
|
397
|
+
* It creates no user message. The words were written when the user pressed send - that is the whole
|
|
398
|
+
* promise the client is making - so opening a turn can only mark that message as no longer waiting and
|
|
399
|
+
* put the answer beneath it. `store.openTurnOnMessage` does both in one write, and splices the assistant
|
|
400
|
+
* message in directly AFTER rather than appending it, which is what keeps a second waiting message below
|
|
401
|
+
* the reply to the first.
|
|
391
402
|
*/
|
|
392
|
-
async openTurn(conversationId,
|
|
403
|
+
async openTurn(conversationId, messageId) {
|
|
393
404
|
const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
|
|
394
405
|
if (!conversation)
|
|
395
406
|
throw new Error("Conversation not found");
|
|
396
407
|
const agent = await this.runtime.agentFor?.(conversation);
|
|
397
408
|
const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
|
|
398
|
-
const
|
|
399
|
-
const assistant = await this.store.beginAssistantMessage(conversationId, identity);
|
|
409
|
+
const opened = await this.store.openTurnOnMessage(conversationId, messageId, identity);
|
|
400
410
|
this.broadcaster.broadcastState();
|
|
401
|
-
return
|
|
411
|
+
return opened;
|
|
402
412
|
}
|
|
403
413
|
/**
|
|
404
414
|
* Streams a turn via AgentRuntime.
|
|
415
|
+
*
|
|
416
|
+
* A TURN CAN CONTAIN MORE THAN ONE ANSWER. When Pi delivers a steered message it announces the
|
|
417
|
+
* boundary (`message_start`), and everything streamed after it is the answer to THAT message rather than
|
|
418
|
+
* more of the same one. So this function can be handed a different `assistant` mid-flight: `assistant`
|
|
419
|
+
* is a parameter rather than a const precisely so one assignment redirects every handler below, each of
|
|
420
|
+
* which closes over the binding rather than over the value it saw when it was made.
|
|
421
|
+
*
|
|
422
|
+
* Before this, a delivered steer's answer was appended to the message that was already streaming. The
|
|
423
|
+
* model was answering words that existed in no message at all, and the transcript showed a reply with
|
|
424
|
+
* nothing above it - the user could not find what they had typed, while the agent quoted it back.
|
|
405
425
|
*/
|
|
406
|
-
async streamTurn(conversation, workspace, prompt,
|
|
426
|
+
async streamTurn(conversation, workspace, prompt, assistantMessage, controller, userMessageId, images, agentIdentity) {
|
|
407
427
|
this.extensionUiService.cancelPendingUi(conversation.id, "expired");
|
|
428
|
+
let assistant = assistantMessage;
|
|
408
429
|
let toolCalls = [];
|
|
409
430
|
let pendingTools = Promise.resolve();
|
|
410
431
|
// Live events are independent from durable checkpoints. Persisting every token made the stdout
|
|
@@ -615,6 +636,51 @@ export class RunService {
|
|
|
615
636
|
return;
|
|
616
637
|
enqueueTools("a tool call outcome", () => this.store.settleToolCall(conversation.id, assistant.id, index, { status: call.status, durationMs: call.durationMs, ...(call.result ? { result: call.result } : {}) }));
|
|
617
638
|
};
|
|
639
|
+
/**
|
|
640
|
+
* PI DELIVERED A STEERED MESSAGE. Everything it streams from here belongs to that message, not to the
|
|
641
|
+
* one this turn opened with.
|
|
642
|
+
*
|
|
643
|
+
* The work goes on the SAME lane as tool writes, which is what makes it safe: the delta handler above
|
|
644
|
+
* awaits that lane before its first line, so by the time another token is handled the reassignment
|
|
645
|
+
* below has already happened. Running it inline instead would put a `store` round trip in the middle
|
|
646
|
+
* of the stdout drain, which is precisely the latency the streaming lane exists to avoid.
|
|
647
|
+
*
|
|
648
|
+
* EVERY PER-MESSAGE COUNTER IS RESET, and each for its own reason. `spoken` and `thought` are OFFSETS
|
|
649
|
+
* WITHIN ONE MESSAGE - the `at` a tool call anchors at, and where a thinking block begins - so
|
|
650
|
+
* carrying them across would anchor this message's first tool call halfway down the previous answer.
|
|
651
|
+
* `rows` numbers tool blocks within a message, so it must restart at zero or the new message's first
|
|
652
|
+
* block is numbered after the old one's. The two maps bind rows and call ids to those numbers and are
|
|
653
|
+
* cleared rather than reassigned for the same reason. `thinkingBlock` continues numbering on purpose:
|
|
654
|
+
* it is a global fold sequence, and restarting it would make the browser render two folds numbered 0.
|
|
655
|
+
*/
|
|
656
|
+
const onSteerDelivered = () => {
|
|
657
|
+
enqueueTools("a steer being delivered", async () => {
|
|
658
|
+
const previous = assistant;
|
|
659
|
+
// Checkpoint first: the message being left behind is complete - Pi drains steering at a turn
|
|
660
|
+
// boundary - and this is the last chance to write it durably with the lane it belongs to.
|
|
661
|
+
await this.store.checkpointMessage(conversation.id, previous.id);
|
|
662
|
+
const delivered = await this.store.deliverWaitingMessage(conversation.id, agentIdentity);
|
|
663
|
+
// NOTHING TO DELIVER MEANS NOTHING TO SPLIT. Pi may announce a user `message_start` that this
|
|
664
|
+
// server never handed it - the prompt that opened the turn is the obvious case - and inventing an
|
|
665
|
+
// assistant message for one of those would open an empty bubble before every single turn. The
|
|
666
|
+
// question "was anything `sending`?" is the server's own state, not something the event decides,
|
|
667
|
+
// which is the same reason the delivery is matched by position rather than by text.
|
|
668
|
+
if (!delivered)
|
|
669
|
+
return [];
|
|
670
|
+
const next = delivered.assistant;
|
|
671
|
+
this.broadcaster.broadcastState();
|
|
672
|
+
assistant = next;
|
|
673
|
+
spoken = 0;
|
|
674
|
+
thought = 0;
|
|
675
|
+
thinkingActive = false;
|
|
676
|
+
thinkingInterrupted = true;
|
|
677
|
+
rows = 0;
|
|
678
|
+
rowByToolCallId.clear();
|
|
679
|
+
preparingRows.clear();
|
|
680
|
+
toolCalls = [];
|
|
681
|
+
return [];
|
|
682
|
+
});
|
|
683
|
+
};
|
|
618
684
|
try {
|
|
619
685
|
for await (const delta of this.runtime.answer({
|
|
620
686
|
conversation,
|
|
@@ -623,6 +689,7 @@ export class RunService {
|
|
|
623
689
|
cwd: workspace.path,
|
|
624
690
|
host: this.extensionUiService.createHost(conversation.id),
|
|
625
691
|
onPiQueue: (snapshot) => this.onPiQueue(conversation.id, snapshot),
|
|
692
|
+
onSteerDelivered,
|
|
626
693
|
onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
|
|
627
694
|
onSessionFile: (path) => this.store.setConversationPiSessionFile(conversation.id, path),
|
|
628
695
|
onToolCall,
|
|
@@ -664,12 +731,19 @@ export class RunService {
|
|
|
664
731
|
},
|
|
665
732
|
images,
|
|
666
733
|
})) {
|
|
734
|
+
// AWAITED FIRST, BEFORE THE LIVE WRITE, because `assistant` may be reassigned by a steer
|
|
735
|
+
// delivery that is still in flight on this same lane. It used to be awaited after
|
|
736
|
+
// `appendAssistantDeltaLive`, which is harmless for tool writes - a tool write never changes
|
|
737
|
+
// which message text belongs to - and is NOT harmless for a split: a token would land in the
|
|
738
|
+
// message that just stopped being answered, in front of the reader, and then the split would
|
|
739
|
+
// publish a new message underneath it. One `await` at the top covers both, and costs nothing:
|
|
740
|
+
// the lane is empty most of the time.
|
|
741
|
+
await pendingTools;
|
|
667
742
|
this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
|
|
668
743
|
spoken += delta.length;
|
|
669
744
|
// Tool events are structural transcript boundaries. Publish any boundary Pi announced before
|
|
670
745
|
// this text before publishing the text itself; this is a handful of writes per call, never one
|
|
671
746
|
// write per token, and preserves the event order a reconnecting client reconstructs.
|
|
672
|
-
await pendingTools;
|
|
673
747
|
this.broadcaster.broadcast({ type: "message.delta", conversationId: conversation.id, messageId: assistant.id, delta });
|
|
674
748
|
scheduleCheckpoint();
|
|
675
749
|
}
|
|
@@ -710,29 +784,33 @@ export class RunService {
|
|
|
710
784
|
}
|
|
711
785
|
}
|
|
712
786
|
/**
|
|
713
|
-
* Loops running turns for as long as
|
|
787
|
+
* Loops running turns for as long as waiting messages exist.
|
|
788
|
+
*
|
|
789
|
+
* The FIRST message is passed in rather than opened here, because it was written into the transcript
|
|
790
|
+
* before this method was called: the user pressed send, and their words were on screen in that frame. The
|
|
791
|
+
* loop then does the same thing for each message after it - take the next waiting one, open its turn,
|
|
792
|
+
* stream it - which is why the two cases are expressed identically and cannot drift apart.
|
|
714
793
|
*/
|
|
715
|
-
async runConversationTurns(conversation, workspace, prompt, controller,
|
|
794
|
+
async runConversationTurns(conversation, workspace, prompt, controller, firstMessageId, initialImages) {
|
|
716
795
|
try {
|
|
717
|
-
const
|
|
718
|
-
|
|
796
|
+
const agent = await this.runtime.agentFor?.(conversation);
|
|
797
|
+
const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
|
|
798
|
+
const opened = await this.openTurn(conversation.id, firstMessageId);
|
|
799
|
+
await this.streamTurn(conversation, workspace, prompt, opened.assistant, controller, opened.user.id, initialImages, identity);
|
|
719
800
|
for (;;) {
|
|
720
801
|
if (controller.signal.aborted)
|
|
721
802
|
break;
|
|
722
|
-
|
|
803
|
+
// Taken AND opened, in that order, by two separate writes. The take is what bounds this loop -
|
|
804
|
+
// every pass strictly shrinks the waiting set - and it is what makes a crash honest, because a
|
|
805
|
+
// server that dies between the two has a message that is neither waiting nor answered, and the
|
|
806
|
+
// next boot leaves it alone rather than re-sending words Pi may already have seen.
|
|
807
|
+
const next = await this.store.takeNextWaitingMessage(conversation.id);
|
|
723
808
|
if (!next)
|
|
724
809
|
break;
|
|
810
|
+
const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
|
|
725
811
|
this.broadcaster.broadcastState();
|
|
726
|
-
let followUp;
|
|
727
|
-
try {
|
|
728
|
-
followUp = await this.openTurn(conversation.id, next.content, next.images, next.attachments);
|
|
729
|
-
}
|
|
730
|
-
catch (error) {
|
|
731
|
-
await this.store.enqueueMessage(conversation.id, next.content, next.images, next.attachments);
|
|
732
|
-
throw error;
|
|
733
|
-
}
|
|
734
812
|
const current = this.turnContext(conversation.id).conversation;
|
|
735
|
-
await this.streamTurn(current, workspace, next.content, followUp.assistant, controller, followUp.user.id, next.images);
|
|
813
|
+
await this.streamTurn(current, workspace, next.content, followUp.assistant, controller, followUp.user.id, next.images, identity);
|
|
736
814
|
}
|
|
737
815
|
}
|
|
738
816
|
finally {
|
|
@@ -742,22 +820,30 @@ export class RunService {
|
|
|
742
820
|
/**
|
|
743
821
|
* Enqueues or starts execution for a user message.
|
|
744
822
|
*/
|
|
745
|
-
async enqueueAndRun(conversationId, content, images, attachments) {
|
|
746
|
-
// The run itself must not occupy the mutation lane: a second send needs to observe the active run
|
|
747
|
-
// and enqueue immediately while the first model turn is still streaming.
|
|
823
|
+
async enqueueAndRun(conversationId, content, images, attachments, clientId) {
|
|
748
824
|
this.memory?.cancel(conversationId);
|
|
749
825
|
const trimmed = content.trim();
|
|
750
826
|
const validImages = Array.isArray(images) && images.length > 0 ? images : undefined;
|
|
751
827
|
if (!trimmed && !validImages?.length)
|
|
752
828
|
return;
|
|
753
829
|
const { conversation, workspace } = this.turnContext(conversationId);
|
|
830
|
+
// THE MESSAGE IS WRITTEN FIRST, ALWAYS, AND THE RUN GUARD IS ASKED SECOND.
|
|
831
|
+
//
|
|
832
|
+
// This ordering is the fix for a bug that was not a slip but a consequence: the words used to be
|
|
833
|
+
// written by `openTurn`, at the moment the turn started, so a send into a running turn had nowhere to
|
|
834
|
+
// put them until a turn began. The browser was therefore told to draw nothing on a send-while-busy,
|
|
835
|
+
// and the composer emptied into silence for as long as the round trip took - a state the reader reads
|
|
836
|
+
// as "it did not send", not as "it is queued".
|
|
837
|
+
//
|
|
838
|
+
// Written first, the message is in the transcript in the same frame, whether it starts a turn or
|
|
839
|
+
// waits for one, and the guard below only decides WHICH. It also means the client-supplied `clientId`
|
|
840
|
+
// is attached to a real message either way, so there is nothing to reconcile afterwards.
|
|
841
|
+
const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, delivery: "waiting" });
|
|
842
|
+
this.broadcaster.broadcastState();
|
|
754
843
|
// Claim the run slot synchronously (no await between has() and set()) so two
|
|
755
844
|
// rapid sends can never both pass the guard and start concurrent turns.
|
|
756
|
-
if (this.activeRuns.has(conversation.id))
|
|
757
|
-
await this.store.enqueueMessage(conversation.id, trimmed, validImages, attachments);
|
|
758
|
-
this.broadcaster.broadcastState();
|
|
845
|
+
if (this.activeRuns.has(conversation.id))
|
|
759
846
|
return;
|
|
760
|
-
}
|
|
761
847
|
const controller = new AbortController();
|
|
762
848
|
this.activeRuns.set(conversation.id, controller);
|
|
763
849
|
try {
|
|
@@ -770,7 +856,7 @@ export class RunService {
|
|
|
770
856
|
}
|
|
771
857
|
}
|
|
772
858
|
const expanded = await expandAtReferences(this.store, workspace, trimmed);
|
|
773
|
-
await this.runConversationTurns(conversation, workspace, expanded, controller,
|
|
859
|
+
await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
|
|
774
860
|
await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
|
|
775
861
|
}
|
|
776
862
|
catch (error) {
|
|
@@ -786,11 +872,11 @@ export class RunService {
|
|
|
786
872
|
/**
|
|
787
873
|
* Steers a queued message into the in-flight turn.
|
|
788
874
|
*/
|
|
789
|
-
async steer(conversationId,
|
|
790
|
-
return this.serializeConversation(conversationId, () => this.steerUnsafe(conversationId,
|
|
875
|
+
async steer(conversationId, messageId) {
|
|
876
|
+
return this.serializeConversation(conversationId, () => this.steerUnsafe(conversationId, messageId));
|
|
791
877
|
}
|
|
792
|
-
/** Starts a chosen
|
|
793
|
-
async sendQueued(conversationId,
|
|
878
|
+
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
879
|
+
async sendQueued(conversationId, messageId) {
|
|
794
880
|
const waitForStop = async () => {
|
|
795
881
|
// A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
|
|
796
882
|
// short cleanup window; a click on the newly visible button must not race the old run slot.
|
|
@@ -803,64 +889,107 @@ export class RunService {
|
|
|
803
889
|
return this.serializeConversation(conversationId, async () => {
|
|
804
890
|
if (this.activeRuns.has(conversationId))
|
|
805
891
|
throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
892
|
+
// Claim the run slot BEFORE releasing the command lane, then start the turn from the message that
|
|
893
|
+
// is already in the transcript. It is not re-sent through `enqueueAndRun`, which would write a
|
|
894
|
+
// SECOND copy of these words: the whole point of this action is that the user is sending a message
|
|
895
|
+
// they can already see sitting in their conversation.
|
|
896
|
+
const controller = new AbortController();
|
|
897
|
+
this.activeRuns.set(conversationId, controller);
|
|
898
|
+
try {
|
|
899
|
+
const { conversation, workspace } = this.turnContext(conversationId);
|
|
900
|
+
const agent = await this.runtime.agentFor?.(conversation);
|
|
901
|
+
const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
|
|
902
|
+
const opened = await this.store.openTurnOnSpecificMessage(conversationId, messageId, identity);
|
|
816
903
|
this.broadcaster.broadcastState();
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
904
|
+
await this.runConversationTurns(conversation, workspace, opened.user.content, controller, opened.user.id, opened.user.images);
|
|
905
|
+
await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
await this.store.setStatus(conversationId, "failed");
|
|
909
|
+
// A preflight failure must put the words BACK where the user can see them. Re-adding them would
|
|
910
|
+
// duplicate the message that is still in the transcript, so this only re-marks the one that is
|
|
911
|
+
// there - which is what makes "the send failed, your message is still waiting" a true statement.
|
|
912
|
+
await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
|
|
913
|
+
throw error;
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
this.activeRuns.delete(conversationId);
|
|
917
|
+
this.extensionUiService.cancelPendingUi(conversationId, "expired");
|
|
918
|
+
this.broadcaster.broadcastState();
|
|
919
|
+
}
|
|
820
920
|
});
|
|
821
921
|
}
|
|
822
|
-
async steerUnsafe(conversationId,
|
|
823
|
-
|
|
824
|
-
|
|
922
|
+
async steerUnsafe(conversationId, messageId) {
|
|
923
|
+
// MOVED TO THE FRONT FIRST, ALWAYS - AND THE MOVE IS BEST-EFFORT.
|
|
924
|
+
//
|
|
925
|
+
// The move is what makes "插到最前" mean what it says rather than leaving a flag nobody can explain.
|
|
926
|
+
// But the move CAN fail, and the case where it fails is a real one a person causes on purpose: two
|
|
927
|
+
// steers in a row. The first message is already in Pi's stdin, nothing can be placed in front of it,
|
|
928
|
+
// and the second click means "and this one too". Refusing it would break a capability the old
|
|
929
|
+
// implementation had and would leave the person with a message that cannot be sent at all until the
|
|
930
|
+
// turn ends - which is the thing they were trying to avoid by clicking twice.
|
|
931
|
+
//
|
|
932
|
+
// So the refusal is swallowed HERE and only here: being second in Pi's own FIFO queue is still a jump
|
|
933
|
+
// past everything else that is merely waiting, which is what the second click was asking for. The
|
|
934
|
+
// `message.reorder` command does not swallow it, because a button that says 「插到最前」 and silently
|
|
935
|
+
// did not move anything is the promise this refactor exists to stop making.
|
|
936
|
+
try {
|
|
937
|
+
await this.store.reorderWaitingMessage(conversationId, messageId, 0);
|
|
825
938
|
}
|
|
939
|
+
catch {
|
|
940
|
+
// Nothing to do: the claim below is what actually delivers it, and Pi takes steers in order.
|
|
941
|
+
}
|
|
942
|
+
this.broadcaster.broadcastState();
|
|
943
|
+
if (!this.runtime.steer)
|
|
944
|
+
return;
|
|
826
945
|
// A running conversation may be in the tiny hand-off window after Pi ended but before this service
|
|
827
|
-
// opens the next
|
|
828
|
-
//
|
|
829
|
-
if (this.runtime.canSteer && !this.runtime.canSteer(conversationId))
|
|
830
|
-
await this.store.prioritizeQueuedMessage(conversationId, queueId);
|
|
831
|
-
this.broadcaster.broadcastState();
|
|
946
|
+
// opens the next waiting message. The message is at the front of the waiting set, so there is nothing
|
|
947
|
+
// left to promise and nothing to report as an error - the click did what it was for either way.
|
|
948
|
+
if (this.runtime.canSteer && !this.runtime.canSteer(conversationId))
|
|
832
949
|
return;
|
|
833
|
-
|
|
834
|
-
const claimed = await this.store.claimQueuedMessage(conversationId, queueId);
|
|
950
|
+
const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
|
|
835
951
|
this.broadcaster.broadcastState();
|
|
836
952
|
try {
|
|
837
|
-
|
|
838
|
-
if (disposition === "handled") {
|
|
839
|
-
await this.store.dropSendingMessages(conversationId, 1);
|
|
840
|
-
}
|
|
953
|
+
await this.runtime.steer(conversationId, claimed.content, claimed.images);
|
|
841
954
|
}
|
|
842
955
|
catch (error) {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
956
|
+
// Back to waiting in every failure case, including the "Pi is gone" one. It is still at the front of
|
|
957
|
+
// whatever is waiting, which is the most this could promise once the transport has refused.
|
|
958
|
+
await this.store.releaseWaitingMessage(conversationId, claimed.id);
|
|
959
|
+
this.broadcaster.broadcastState();
|
|
960
|
+
if (error instanceof SteerUnavailableError)
|
|
847
961
|
return;
|
|
848
|
-
}
|
|
849
|
-
await this.store.releaseQueuedMessage(claimed.id);
|
|
850
962
|
throw error;
|
|
851
963
|
}
|
|
852
|
-
this.broadcaster.broadcastState();
|
|
853
964
|
}
|
|
854
965
|
/**
|
|
855
|
-
*
|
|
966
|
+
* Withdraws a waiting message.
|
|
856
967
|
*/
|
|
857
|
-
async cancelQueued(conversationId,
|
|
858
|
-
return this.serializeConversation(conversationId, () => this.cancelQueuedUnsafe(conversationId,
|
|
968
|
+
async cancelQueued(conversationId, messageId) {
|
|
969
|
+
return this.serializeConversation(conversationId, () => this.cancelQueuedUnsafe(conversationId, messageId));
|
|
859
970
|
}
|
|
860
|
-
async cancelQueuedUnsafe(conversationId,
|
|
861
|
-
await this.store.
|
|
971
|
+
async cancelQueuedUnsafe(conversationId, messageId) {
|
|
972
|
+
await this.store.cancelWaitingMessage(conversationId, messageId);
|
|
862
973
|
this.broadcaster.broadcastState();
|
|
863
974
|
}
|
|
975
|
+
/**
|
|
976
|
+
* Rewrites a waiting message, in place, where the user can see it change.
|
|
977
|
+
*/
|
|
978
|
+
async editQueued(conversationId, messageId, content) {
|
|
979
|
+
return this.serializeConversation(conversationId, async () => {
|
|
980
|
+
await this.store.editWaitingMessage(conversationId, messageId, content);
|
|
981
|
+
this.broadcaster.broadcastState();
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Moves a waiting message within the waiting set.
|
|
986
|
+
*/
|
|
987
|
+
async reorderQueued(conversationId, messageId, toIndex) {
|
|
988
|
+
return this.serializeConversation(conversationId, async () => {
|
|
989
|
+
await this.store.reorderWaitingMessage(conversationId, messageId, toIndex);
|
|
990
|
+
this.broadcaster.broadcastState();
|
|
991
|
+
});
|
|
992
|
+
}
|
|
864
993
|
/**
|
|
865
994
|
* Dispatches a subagent from client command (human dispatch).
|
|
866
995
|
*/
|
|
@@ -158,19 +158,31 @@ export class CommandHandler {
|
|
|
158
158
|
if (this.conversationService.isReverting(command.conversationId)) {
|
|
159
159
|
throw new Error("正在撤回上一条消息,Pi 的历史还没截断完成,请等撤回结束后再发送。");
|
|
160
160
|
}
|
|
161
|
-
|
|
161
|
+
// `attachments` was dropped here while the send path grew it, so a message carrying a skill
|
|
162
|
+
// attachment lost it on arrival - the browser drew the attachment, the transcript never had it, and
|
|
163
|
+
// the model was handed the bare prompt. Forwarded now because this is the one line that carries
|
|
164
|
+
// every field of the send.
|
|
165
|
+
await this.runService.enqueueAndRun(command.conversationId, command.content, command.images, command.attachments, command.clientId);
|
|
162
166
|
return;
|
|
163
167
|
}
|
|
164
168
|
if (command.type === "message.steer") {
|
|
165
|
-
await this.runService.steer(command.conversationId, command.
|
|
169
|
+
await this.runService.steer(command.conversationId, command.messageId);
|
|
166
170
|
return;
|
|
167
171
|
}
|
|
168
172
|
if (command.type === "message.sendQueued") {
|
|
169
|
-
await this.runService.sendQueued(command.conversationId, command.
|
|
173
|
+
await this.runService.sendQueued(command.conversationId, command.messageId);
|
|
170
174
|
return;
|
|
171
175
|
}
|
|
172
176
|
if (command.type === "message.cancel") {
|
|
173
|
-
await this.runService.cancelQueued(command.conversationId, command.
|
|
177
|
+
await this.runService.cancelQueued(command.conversationId, command.messageId);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (command.type === "message.edit") {
|
|
181
|
+
await this.runService.editQueued(command.conversationId, command.messageId, command.content);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (command.type === "message.reorder") {
|
|
185
|
+
await this.runService.reorderQueued(command.conversationId, command.messageId, command.toIndex);
|
|
174
186
|
return;
|
|
175
187
|
}
|
|
176
188
|
if (command.type === "message.revert") {
|