@evandu/super-pi 0.1.40 → 0.1.41

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.
@@ -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-Dxm2Vtis.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DuqJLo6R.css">
11
+ <script type="module" crossorigin src="/assets/index-Dihs8Ezc.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DTd4g0h5.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -110,35 +110,66 @@ export class RunService {
110
110
  * backfilled with `options[0]`.
111
111
  */
112
112
  async askQuestion(conversationId) {
113
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
114
- const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
115
- if (!message)
116
- throw new Error("No active assistant message");
117
- // THE CALL, NOT THE MESSAGE. The newest `question` call still marked pending is the one the model is
118
- // blocked inside; anything else would attach an answer to a question nobody is waiting on.
119
- let index = -1;
120
- for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
121
- const call = message.toolCalls?.[i];
122
- if (call?.label === "question" && call.question?.status === "pending") {
123
- index = i;
124
- break;
113
+ // THE ROW ARRIVES AFTER THE REQUEST, AND THAT IS A RACE, NOT A BUG IN THE CALLER.
114
+ //
115
+ // The extension lives INSIDE the Pi process. When Pi starts running the tool it writes
116
+ // `tool_execution_start` to stdout AND calls the tool - so the tool's HTTP request can reach this
117
+ // server while the transcript drain loop is still reading that very record off the pipe. The endpoint
118
+ // therefore asked for a tool call that had not been stored yet, and answered 「这个会话里没有正在
119
+ // 等待回答的 question 工具调用」 - which is a refusal, so the tool FAILED, and the person was never
120
+ // shown the question at all.
121
+ //
122
+ // So the lookup WAITS. A few hundred milliseconds of polling covers the drain, and a question that
123
+ // genuinely has no pending call still fails - just not instantly.
124
+ const findCall = () => {
125
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
126
+ const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
127
+ if (!message)
128
+ return undefined;
129
+ for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
130
+ const call = message.toolCalls?.[i];
131
+ if (call?.label === "question" && call.question?.status === "pending")
132
+ return { messageId: message.id, index: i };
125
133
  }
134
+ return undefined;
135
+ };
136
+ let found;
137
+ for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
138
+ found = findCall();
139
+ if (found)
140
+ break;
141
+ await new Promise((resolve) => setTimeout(resolve, 100));
126
142
  }
127
- if (index < 0)
143
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
144
+ if (!conversation?.messages.length)
145
+ throw new Error("No active assistant message");
146
+ if (!found)
128
147
  throw new Error("这个会话里没有正在等待回答的 question 工具调用");
129
- const messageId = message.id;
148
+ const { messageId, index } = found;
130
149
  return new Promise((resolve, reject) => {
131
150
  const finish = (settle) => { clearInterval(poll); clearTimeout(deadline); settle(); };
132
151
  const poll = setInterval(() => {
133
152
  const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
134
153
  if (current?.status === "answered")
135
- finish(() => resolve({ answers: current.answers ?? [] }));
154
+ finish(() => resolve(answerPayload(current)));
136
155
  // CANCELLED IS NOT AN ANSWER. Rejecting is what makes the tool call fail, which is what stops the
137
156
  // run; resolving here would let the model carry on from a question the person refused to answer.
138
157
  else if (current?.status === "cancelled")
139
158
  finish(() => reject(new Error("用户没有回答这个问题")));
140
159
  }, 250);
141
160
  const deadline = setTimeout(() => finish(() => reject(new Error("等待回答超时"))), 30 * 60 * 1000);
161
+ // WHAT GOES BACK TO THE MODEL IS WRITTEN FOR THE MODEL. The answers pair to their questions by id,
162
+ // which is what stops a person answering the second of two being recorded against the first - but an
163
+ // id is a UUID, and a model handed `{answers:[{id:"671b0f0b-…", selected:["发"]}]}` has to guess at
164
+ // what that means. It guessed, and reported a real answer as a cancellation. So the answer goes back
165
+ // as its questions and its choices, with a sentence naming the outcome.
166
+ const answerPayload = (current) => {
167
+ const answers = (current.answers ?? []).map((answer) => {
168
+ const asked = current.items.find((item) => item.id === answer.id);
169
+ return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
170
+ });
171
+ return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
172
+ };
142
173
  });
143
174
  }
144
175
  /**
@@ -390,7 +421,13 @@ export class RunService {
390
421
  onToolPreparing,
391
422
  onToolUpdate,
392
423
  onToolEnd,
393
- onThinking: (delta) => this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true }),
424
+ // BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
425
+ // reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
426
+ // arrives, and the message keeps it so the finished turn has something to go back to.
427
+ onThinking: (delta) => {
428
+ this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true });
429
+ void this.store.appendThinkingDelta(conversation.id, assistant.id, delta);
430
+ },
394
431
  images,
395
432
  })) {
396
433
  await this.store.appendAssistantDelta(conversation.id, assistant.id, delta);
@@ -211,6 +211,17 @@ export declare class Store {
211
211
  }): Promise<Conversation>;
212
212
  beginAssistantMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<Message>;
213
213
  appendAssistantDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
214
+ /**
215
+ * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
216
+ *
217
+ * It used to be broadcast and nothing more: the server emitted `message.thinking` on every delta and no
218
+ * code anywhere read it, so the reasoning reached no one - not the screen while it streamed, and not
219
+ * the transcript afterwards. The field, the event and the stylesheet were all already there; the two
220
+ * halves that would have used them were missing, which is why a finished turn had no visible reasoning
221
+ * to go back to. Persisting here rather than only in the browser is the point of its living on the
222
+ * message: a reasoning stream that vanishes on reload was never a record of anything.
223
+ */
224
+ appendThinkingDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
214
225
  /**
215
226
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
216
227
  * array so the caller can broadcast it as one snapshot.
@@ -864,6 +864,25 @@ export class Store {
864
864
  conversation.updatedAt = now();
865
865
  await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
866
866
  }
867
+ /**
868
+ * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
869
+ *
870
+ * It used to be broadcast and nothing more: the server emitted `message.thinking` on every delta and no
871
+ * code anywhere read it, so the reasoning reached no one - not the screen while it streamed, and not
872
+ * the transcript afterwards. The field, the event and the stylesheet were all already there; the two
873
+ * halves that would have used them were missing, which is why a finished turn had no visible reasoning
874
+ * to go back to. Persisting here rather than only in the browser is the point of its living on the
875
+ * message: a reasoning stream that vanishes on reload was never a record of anything.
876
+ */
877
+ async appendThinkingDelta(conversationId, messageId, delta) {
878
+ const conversation = this.requireConversation(conversationId);
879
+ const message = conversation.messages.find((item) => item.id === messageId);
880
+ if (!message)
881
+ throw new Error("Assistant message not found");
882
+ message.thinking = `${message.thinking ?? ""}${delta}`;
883
+ conversation.updatedAt = now();
884
+ await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
885
+ }
867
886
  /**
868
887
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
869
888
  * array so the caller can broadcast it as one snapshot.
@@ -140,6 +140,15 @@ const oneLine = (value) => {
140
140
  * shape can be regression-tested at all.
141
141
  */
142
142
  export function describeToolCall(name, args) {
143
+ // A QUESTION NAMES ITSELF. The generic path reads a target-ish string out of the arguments and a
144
+ // question has none, so it fell through to the preparing placeholder - which left every settled
145
+ // question row headed 「正在生成参数…」 above the question itself. The row is the decision; the header
146
+ // should say which one.
147
+ if (name.trim().toLowerCase() === "question") {
148
+ const asked = readQuestionCall(name, args);
149
+ if (asked?.items.length)
150
+ return oneLine(asked.items.length === 1 ? asked.items[0].question : `${asked.items.length} 个问题`);
151
+ }
143
152
  if (args === null || typeof args !== "object" || Array.isArray(args))
144
153
  return undefined;
145
154
  const record = args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",