agentus 0.4.4 → 0.5.0
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/package.json +7 -2
- package/packages/server/mock/agent.mjs +6 -0
- package/packages/server/package.json +2 -1
- package/packages/server/src/acp/session-manager.ts +7 -1
- package/packages/server/src/dashscope.ts +82 -0
- package/packages/server/src/index.ts +179 -10
- package/packages/server/src/observer/hub.ts +241 -19
- package/packages/server/src/settings.ts +159 -2
- package/packages/server/src/speech/route.ts +181 -0
- package/packages/server/src/store/store.ts +7 -0
- package/packages/shared/src/index.ts +10 -2
- package/packages/web/dist/assets/index-SSGsR6n4.js +127 -0
- package/packages/web/dist/assets/index-xZyah18L.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-DwdrqRgR.js +0 -125
- package/packages/web/dist/assets/index-xlniZD2Q.css +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@agentclientprotocol/sdk": "1.5.1",
|
|
32
32
|
"tsx": "^4.19.0",
|
|
33
|
-
"ws": "^8.22.0"
|
|
33
|
+
"ws": "^8.22.0",
|
|
34
|
+
"yaml": "^2.9.1"
|
|
34
35
|
},
|
|
35
36
|
"workspaces": [
|
|
36
37
|
"packages/*"
|
|
@@ -64,6 +65,10 @@
|
|
|
64
65
|
"notify-testbed": "tsx scripts/notify-testbed.mts",
|
|
65
66
|
"notify-smoke": "tsx scripts/notify-smoke.mts",
|
|
66
67
|
"web-notify-rules": "tsx scripts/qa/web-notify-rules.mts",
|
|
68
|
+
"notify-chime": "tsx scripts/qa/notify-chime.mts",
|
|
69
|
+
"ws-send-ack": "node scripts/qa/ws-send-ack.mjs",
|
|
70
|
+
"send-ack": "node scripts/qa/send-ack-page.mjs",
|
|
71
|
+
"composer-input": "node scripts/qa/composer-input-sweep.mjs",
|
|
67
72
|
"web-notify-sweep": "node scripts/qa/web-notify-sweep.mjs",
|
|
68
73
|
"apk": "bash scripts/build-apk.sh",
|
|
69
74
|
"transcript-fold": "tsx scripts/qa/transcript-fold.mts",
|
|
@@ -416,6 +416,12 @@ const agent = () => ({
|
|
|
416
416
|
// per-turn slow mode: long stream so QA can drop the socket mid-turn (AC6)
|
|
417
417
|
const slow = /\[slow\]/.test(text) ? Math.max(SLOW, 900) : SLOW;
|
|
418
418
|
|
|
419
|
+
// `[hold:<ms>]` — a turn that is IN FLIGHT but SILENT for that long (issue #24's heartbeat
|
|
420
|
+
// fixture). No frames at all, so 「有任务在跑 + 没有动静」 is exactly the state the ticker must
|
|
421
|
+
// speak on — and the state a `[slow]` stream can never reproduce (it emits every 900 ms).
|
|
422
|
+
const hold = Number((/\[hold:(\d+)\]/.exec(text) || [])[1] || 0);
|
|
423
|
+
if (hold > 0) await sleep(Math.min(180_000, hold));
|
|
424
|
+
|
|
419
425
|
// `[status]` — the agent-status carrier from issue #18, exactly as hermes' `make_status_cb` sends
|
|
420
426
|
// it: an EMPTY thought chunk whose `_meta.hermes` holds the sentence. Sent mid-turn on purpose, so a
|
|
421
427
|
// sweep can prove the line appears while the turn runs and is gone when it ends, and that NOTHING
|
|
@@ -972,6 +972,12 @@ export class SessionManager {
|
|
|
972
972
|
* message, no attribution, indistinguishable from something they typed).
|
|
973
973
|
*/
|
|
974
974
|
origin?: { sessionId: string; title?: string | null };
|
|
975
|
+
/**
|
|
976
|
+
* Client-generated id of the prompt that is being sent (issue #26). Echoed on the `turn-start`
|
|
977
|
+
* this call causes, so a page can prove its frame landed instead of assuming `ws.send()` did
|
|
978
|
+
* something. Opaque to the server: it comes back unchanged, it is never stored.
|
|
979
|
+
*/
|
|
980
|
+
pid?: string;
|
|
975
981
|
} = {},
|
|
976
982
|
): Promise<void> {
|
|
977
983
|
const s = this.#need(sessionId);
|
|
@@ -1038,7 +1044,7 @@ export class SessionManager {
|
|
|
1038
1044
|
// Timed from the announcement, not from the prompt call: the operator's wait INCLUDES the
|
|
1039
1045
|
// moment the card lit up. Only the queue-wait above is excluded (that is other turns' time).
|
|
1040
1046
|
const turnStartedAt = Date.now();
|
|
1041
|
-
this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s) });
|
|
1047
|
+
this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s), ...(opts.pid ? { pid: opts.pid } : {}) });
|
|
1042
1048
|
const msg = this.#store.appendMessage({
|
|
1043
1049
|
sessionId,
|
|
1044
1050
|
kind: "user",
|
|
@@ -233,3 +233,85 @@ export function openDashscopeStream(opts: {
|
|
|
233
233
|
started,
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 语音总结: one message in, a few spoken sentences out.
|
|
239
|
+
*
|
|
240
|
+
* Same door as batch ASR (`{compat}/chat/completions`) with the audio block swapped for text, so
|
|
241
|
+
* the operator's existing voice endpoint and key are enough — there is no second model to
|
|
242
|
+
* configure and no agent, no session and no history involved.
|
|
243
|
+
*
|
|
244
|
+
* The input is capped at `maxChars × 20` characters (30k ≈ 15k tokens is already the ceiling for a
|
|
245
|
+
* 30-second listen): a 200 KB pasted log must not turn a long press into a bill.
|
|
246
|
+
*/
|
|
247
|
+
export async function summarizeForSpeech(opts: {
|
|
248
|
+
text: string;
|
|
249
|
+
maxChars: number;
|
|
250
|
+
prompt: string;
|
|
251
|
+
model: string;
|
|
252
|
+
timeoutSec: number;
|
|
253
|
+
/** Where to send it. Absent = the voice endpoint (settings → 语音), which is the old, always-on
|
|
254
|
+
* route. Present = the SESSION's own provider (see speech/route.ts) — the operator asked for the
|
|
255
|
+
* summary to be billed to whatever model he is actually talking to, not to a second vendor. */
|
|
256
|
+
endpoint?: { baseUrl: string; apiKey: string };
|
|
257
|
+
}): Promise<{ text: string; truncated: boolean; ms: number }> {
|
|
258
|
+
const { key, compat } = opts.endpoint
|
|
259
|
+
? { key: opts.endpoint.apiKey, compat: opts.endpoint.baseUrl.replace(/\/+$/, "") }
|
|
260
|
+
: creds();
|
|
261
|
+
const v = getSettings().voice;
|
|
262
|
+
const limit = Math.max(400, opts.maxChars * 20);
|
|
263
|
+
const body = opts.text.trim();
|
|
264
|
+
const truncated = body.length > limit;
|
|
265
|
+
const input = truncated ? `${body.slice(0, limit)}\n…(后面还有 ${body.length - limit} 字没发)` : body;
|
|
266
|
+
const ac = new AbortController();
|
|
267
|
+
const timer = setTimeout(() => ac.abort(), Math.max(5, opts.timeoutSec) * 1000);
|
|
268
|
+
const started = Date.now();
|
|
269
|
+
try {
|
|
270
|
+
const res = await fetch(`${compat}/chat/completions`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
model: opts.model || (opts.endpoint ? "" : "qwen-flash"),
|
|
275
|
+
messages: [
|
|
276
|
+
{ role: "system", content: opts.prompt.replace(/\{maxChars\}/g, String(opts.maxChars)) },
|
|
277
|
+
{ role: "user", content: input },
|
|
278
|
+
],
|
|
279
|
+
// No temperature knob: this surface is not a playground, and the default has produced
|
|
280
|
+
// the steadiest length compliance. `max_tokens` guards against a chatty model — and it
|
|
281
|
+
// must leave room for REASONING tokens, which count against the same budget: measured on
|
|
282
|
+
// deepseek-v4-flash, a 480-token cap was spent entirely on reasoning_content and the
|
|
283
|
+
// visible answer came back empty with finish_reason=length. 1200 is the floor that makes a
|
|
284
|
+
// reasoning model answer; for a plain one it is only a ceiling nobody reaches.
|
|
285
|
+
max_tokens: Math.max(1200, opts.maxChars * 6),
|
|
286
|
+
}),
|
|
287
|
+
signal: ac.signal,
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
const detail = (await res.text().catch(() => "")).slice(0, 200);
|
|
291
|
+
throw new DashscopeError(`chat/completions ${res.status}: ${detail}`, "upstream");
|
|
292
|
+
}
|
|
293
|
+
const data = (await res.json()) as {
|
|
294
|
+
choices?: { message?: { content?: string; reasoning_content?: string }; finish_reason?: string }[];
|
|
295
|
+
};
|
|
296
|
+
const choice = data?.choices?.[0];
|
|
297
|
+
const out = String(choice?.message?.content ?? "").trim();
|
|
298
|
+
if (!out) {
|
|
299
|
+
// Say WHICH silence this was. An empty `content` with a fat `reasoning_content` and
|
|
300
|
+
// finish_reason=length means the budget went to thinking, not that the model refused.
|
|
301
|
+
const thought = String(choice?.message?.reasoning_content ?? "").length;
|
|
302
|
+
const why = choice?.finish_reason === "length"
|
|
303
|
+
? `预算被推理吃光了(reasoning ${thought} 字,finish_reason=length)—— 调大字数上限,或换一个非推理模型`
|
|
304
|
+
: `模型返回空内容(finish_reason=${choice?.finish_reason ?? "?"})`;
|
|
305
|
+
throw new DashscopeError(why, "upstream");
|
|
306
|
+
}
|
|
307
|
+
void v;
|
|
308
|
+
return { text: out, truncated, ms: Date.now() - started };
|
|
309
|
+
} catch (e) {
|
|
310
|
+
if ((e as Error).name === "AbortError") {
|
|
311
|
+
throw new DashscopeError(`summary timed out after ${opts.timeoutSec}s`, "timeout");
|
|
312
|
+
}
|
|
313
|
+
throw e;
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -9,20 +9,22 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import { WebSocketServer, WebSocket } from "ws";
|
|
12
|
-
import { Store } from "./store/store.js";
|
|
12
|
+
import { Store, textOf } from "./store/store.js";
|
|
13
|
+
import { routeForModel, type SpeechRoute } from "./speech/route.js";
|
|
13
14
|
import { exportFilename, renderJson, renderMarkdown, type ExportSessionHeader } from "./store/export.js";
|
|
14
15
|
import { IMAGE_UNSUPPORTED, SessionManager, SessionUnavailable } from "./acp/session-manager.js";
|
|
15
16
|
import { describeAcpError } from "./acp/errors.js";
|
|
16
17
|
import { BACKENDS } from "./acp/backends.js";
|
|
17
|
-
import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
|
+
import { HERMES_DEFAULT_HOME, classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
19
|
import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
|
|
19
20
|
import { terms } from "./term.js";
|
|
20
21
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
21
22
|
import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
22
|
-
import { openDashscopeStream } from "./dashscope.js";
|
|
23
|
+
import { openDashscopeStream, summarizeForSpeech } from "./dashscope.js";
|
|
23
24
|
import {
|
|
25
|
+
SUMMARY_CHARS_RANGE, SUMMARY_MODEL_DEFAULT, SUMMARY_PROMPT_DEFAULT,
|
|
24
26
|
agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
|
|
25
|
-
saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveTheme,
|
|
27
|
+
saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveSummary, saveTheme, summarySettings,
|
|
26
28
|
} from "./settings.js";
|
|
27
29
|
import { ObserverHub } from "./observer/hub.js";
|
|
28
30
|
import * as auth from "./auth.js";
|
|
@@ -261,6 +263,21 @@ hub = new ObserverHub({
|
|
|
261
263
|
};
|
|
262
264
|
pick();
|
|
263
265
|
},
|
|
266
|
+
// 工作播报 的作用域与生命周期 (issues #23/#24, 2026-10-18).
|
|
267
|
+
// `watching` is the SAME presence the notification rules use: 「他在看哪个会话」 has one answer in
|
|
268
|
+
// this process, and a second, quieter definition of it is how a feature starts talking about the
|
|
269
|
+
// wrong session. `notify` is a `const` declared below — this closure only ever runs from an event
|
|
270
|
+
// handler, never during construction.
|
|
271
|
+
watching: (id) => notify.operatorWatching(id),
|
|
272
|
+
// The reaped-observer fix: a `system` session the idle reaper took no longer accepts prompts, and
|
|
273
|
+
// trusting its id forever is what made the brief say 「播报失败:no such session」 until a restart.
|
|
274
|
+
// `hasSession` (not `list()`) on purpose: `list()` is the RAIL's view and excludes system sessions,
|
|
275
|
+
// so asking it about an observer answers 「no」 every single time — which would re-spawn a fresh
|
|
276
|
+
// observer on every turn and throw away the context continuity the whole design is for.
|
|
277
|
+
alive: (id) => mgr.hasSession(id),
|
|
278
|
+
// 切走即关 / 关掉收听即关: closing the PROCESS is the point — an idle observer is a whole ACP
|
|
279
|
+
// runtime, and the reaper's five minutes are five minutes of paying for nothing.
|
|
280
|
+
disposeSession: (id) => mgr.closeSession(id),
|
|
264
281
|
emit,
|
|
265
282
|
});
|
|
266
283
|
|
|
@@ -282,7 +299,12 @@ const notify = new NotifyCenter({
|
|
|
282
299
|
// The page reporting which session it is showing is also the idle reaper's clock: opening a slot
|
|
283
300
|
// (and staying in it) counts as USED, so switching away gives it a full threshold instead of
|
|
284
301
|
// reaping it on the next tick because its last MESSAGE was days ago.
|
|
285
|
-
|
|
302
|
+
// ...and it is also the 工作播报 scope switch (issue #23): the observer follows the session on
|
|
303
|
+
// screen, and the previous one is CLOSED rather than left warming. Two consumers, one fact.
|
|
304
|
+
onFocus: (sessionId: string) => {
|
|
305
|
+
mgr.touch(sessionId);
|
|
306
|
+
hub?.focusChanged(sessionId);
|
|
307
|
+
},
|
|
286
308
|
// Username+password pairing: the phone types the same login the browser uses. The check and
|
|
287
309
|
// its rate limit stay here, in auth.ts, so a phone cannot have a laxer door than the UI.
|
|
288
310
|
credentials: (username, password, ip) => {
|
|
@@ -299,6 +321,11 @@ const notify = new NotifyCenter({
|
|
|
299
321
|
},
|
|
300
322
|
});
|
|
301
323
|
|
|
324
|
+
// The beat starts here, and not at construction: `tick()` reads `notify`'s presence, and a timer
|
|
325
|
+
// started before that `const` exists would be the one thing in this file able to throw a TDZ error
|
|
326
|
+
// at runtime. 15s is finer than the smallest beat the menu offers (1 minute).
|
|
327
|
+
hub?.start();
|
|
328
|
+
|
|
302
329
|
// One row per interaction. A notification button carries an opaque actionId plus the ref the
|
|
303
330
|
// publisher attached; the cockpit is the only place that knows what it means. Adding a new
|
|
304
331
|
// interaction is therefore a case here (or a new ref.type), never an app rebuild.
|
|
@@ -423,8 +450,9 @@ function runPrompt(
|
|
|
423
450
|
text: string,
|
|
424
451
|
attachments: PromptAttachment[] = [],
|
|
425
452
|
interrupt = false,
|
|
453
|
+
pid?: string,
|
|
426
454
|
): void {
|
|
427
|
-
void mgr.prompt(sessionId, text, attachments, { interrupt }).catch((e: unknown) => {
|
|
455
|
+
void mgr.prompt(sessionId, text, attachments, { interrupt, pid }).catch((e: unknown) => {
|
|
428
456
|
const msg = describeAcpError(e);
|
|
429
457
|
const tail = mgr.stderrTail(sessionId);
|
|
430
458
|
console.error(`[agentus] prompt failed for ${sessionId}: ${msg}${tail ? `\n agent stderr tail:\n ${tail}` : ""}`);
|
|
@@ -436,7 +464,10 @@ function runPrompt(
|
|
|
436
464
|
const lost = msg === "turn already running" || msg === IMAGE_UNSUPPORTED;
|
|
437
465
|
emit({ t: "message", message: {
|
|
438
466
|
seq: -1, sessionId, kind: "meta",
|
|
439
|
-
|
|
467
|
+
// `pid` rides along (issue #26) so the page can pair this refusal with the prompt it is still
|
|
468
|
+
// waiting to acknowledge: without it the refusal and the ack timeout both put the same sentence
|
|
469
|
+
// back in line, and the operator sees his message twice.
|
|
470
|
+
payload: { text: `turn failed: ${msg}`, ...(lost ? { rejectedPrompt: text } : {}), ...(pid ? { pid } : {}) },
|
|
440
471
|
createdAt: Date.now(),
|
|
441
472
|
} });
|
|
442
473
|
// `turn-end` is the frame that says A TURN IS OVER. For the two "nothing left the client" refusals
|
|
@@ -808,6 +839,22 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
808
839
|
});
|
|
809
840
|
}
|
|
810
841
|
|
|
842
|
+
// ---- 工作播报 diagnostics (issues #23/#24) ------------------------------------------
|
|
843
|
+
// What the hub is watching and what it spawned. Operator-only, read-only, no secrets: this is
|
|
844
|
+
// the answer to 「它有没有在看这个会话 / 观察员还活着吗」 that used to require reading logs.
|
|
845
|
+
if (url.pathname === "/api/observer" && req.method === "GET") {
|
|
846
|
+
if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
|
|
847
|
+
const o = getSettings().observer;
|
|
848
|
+
return send(res, 200, {
|
|
849
|
+
enabled: o.enabled,
|
|
850
|
+
intervalSec: o.intervalSec,
|
|
851
|
+
throttleSec: o.throttleSec,
|
|
852
|
+
observers: hub?.observerIds() ?? [],
|
|
853
|
+
watches: hub?.debug() ?? [],
|
|
854
|
+
ticks: hub?.ticks() ?? 0,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
811
858
|
// ---- the phone's onboarding page + the APK, both behind the operator login ----------
|
|
812
859
|
// The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
|
|
813
860
|
// sent to the login screen (the SPA at /) and comes back with a session cookie.
|
|
@@ -1238,7 +1285,12 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1238
1285
|
if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
|
|
1239
1286
|
if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
|
|
1240
1287
|
if (body.observer && typeof body.observer === "object") saveObserver(body.observer as Record<string, unknown>);
|
|
1241
|
-
|
|
1288
|
+
// Turning the speaker off (or the observer off) must stop the COST, not just the voice:
|
|
1289
|
+
// the observer is a whole ACP process, and waiting for the idle reaper to take it is five
|
|
1290
|
+
// minutes of paying for a feature the operator just switched off (issue #23 §3.2).
|
|
1291
|
+
if (body.prefs || body.observer) hub?.onPolicyChanged();
|
|
1292
|
+
if (body.summary && typeof body.summary === "object") saveSummary(body.summary as Record<string, unknown>);
|
|
1293
|
+
if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent && !body.observer && !body.summary) saveSettings(body);
|
|
1242
1294
|
return send(res, 200, publicSettings());
|
|
1243
1295
|
} catch (e) {
|
|
1244
1296
|
return send(res, 400, { error: String((e as Error)?.message ?? e) });
|
|
@@ -1350,11 +1402,15 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1350
1402
|
// here used to kill the whole server (QA#19, unhandled rejection)
|
|
1351
1403
|
if (mgr.hasSession(id)) {
|
|
1352
1404
|
await mgr.closeSession(id);
|
|
1405
|
+
// 工作播报 (#23): a closed session's observer and its watch entry have no future. Before
|
|
1406
|
+
// this, `forget()` had zero call sites and the map only grew.
|
|
1407
|
+
hub?.forget(id);
|
|
1353
1408
|
return send(res, 200, { closed: id });
|
|
1354
1409
|
}
|
|
1355
1410
|
// not live: either a cold slot the operator wants gone, or an unknown id.
|
|
1356
1411
|
// Purging is what makes the rail's "on disk · N" list manageable (M4).
|
|
1357
1412
|
if (store.deleteSession(id)) {
|
|
1413
|
+
hub?.forget(id);
|
|
1358
1414
|
emit(sessionsEvent());
|
|
1359
1415
|
return send(res, 200, { purged: id });
|
|
1360
1416
|
}
|
|
@@ -1367,6 +1423,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1367
1423
|
// had no way to make it.
|
|
1368
1424
|
try {
|
|
1369
1425
|
const info = await mgr.setArchived(id, true);
|
|
1426
|
+
hub?.forget(id);
|
|
1370
1427
|
emit(sessionsEvent());
|
|
1371
1428
|
return send(res, 200, info);
|
|
1372
1429
|
} catch (e) {
|
|
@@ -1457,7 +1514,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1457
1514
|
const attachments = Array.isArray(body.attachments) ? (body.attachments as PromptAttachment[]) : [];
|
|
1458
1515
|
if (!text && !attachments.length) return send(res, 400, { error: "empty prompt" });
|
|
1459
1516
|
// fire & forget: stream arrives via WS; client watches turn-start/end
|
|
1460
|
-
void runPrompt(id, text, attachments, body.interrupt === true);
|
|
1517
|
+
void runPrompt(id, text, attachments, body.interrupt === true, typeof body.pid === "string" ? body.pid : undefined);
|
|
1461
1518
|
return send(res, 202, { ok: true });
|
|
1462
1519
|
}
|
|
1463
1520
|
if (req.method === "POST" && sub === "/workspace") {
|
|
@@ -1525,6 +1582,86 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1525
1582
|
return send(res, 409, { error: msg });
|
|
1526
1583
|
}
|
|
1527
1584
|
}
|
|
1585
|
+
if (req.method === "POST" && sub === "/speech-summary") {
|
|
1586
|
+
/** The endpoint this session's OWN model lives on. Read per request: the operator can
|
|
1587
|
+
* switch the model from the toolbar and the next long press must follow it. */
|
|
1588
|
+
const sessionRoute = (): { route: SpeechRoute | null; why: string } => {
|
|
1589
|
+
const row = store.getSession(id);
|
|
1590
|
+
if (!row) return { route: null, why: "会话不在库里" };
|
|
1591
|
+
const be = store.listBackends().find((b) => b.id === row.backend);
|
|
1592
|
+
const models = (row.models ?? {}) as { currentModelId?: string | null };
|
|
1593
|
+
// The provider keys live in this box's agent home, which is a property of the runtime —
|
|
1594
|
+
// not of the vendor that happens to be driving this session. A backend that owns no home
|
|
1595
|
+
// (mock, qoder) therefore still resolves a `deepseek:…` model through the Hermes home.
|
|
1596
|
+
const home = (be ? planFor(be).home : null) ?? HERMES_DEFAULT_HOME;
|
|
1597
|
+
return routeForModel(models.currentModelId ?? null, home);
|
|
1598
|
+
};
|
|
1599
|
+
// 语音总结 (the message speaker's long press): ONE stored message, made listenable.
|
|
1600
|
+
//
|
|
1601
|
+
// No agent, no fork, no history: the text goes to the voice endpoint's chat surface, the
|
|
1602
|
+
// same one ASR already posts to. The BODY of the message is read from the store by key —
|
|
1603
|
+
// the browser does not get to say what reaches the model, and the server's copy is what the
|
|
1604
|
+
// operator actually saw on screen.
|
|
1605
|
+
const cfg = summarySettings();
|
|
1606
|
+
if (!cfg.enabled) return send(res, 409, { error: "语音总结已关闭(设置 → 语音总结)", code: "disabled" });
|
|
1607
|
+
const body = await readJson(req);
|
|
1608
|
+
const key = String(body.key ?? "").trim();
|
|
1609
|
+
if (!key) return send(res, 400, { error: "key is required" });
|
|
1610
|
+
const row = store.messageByKey(id, key);
|
|
1611
|
+
if (!row) return send(res, 404, { error: `no such message: ${key}` });
|
|
1612
|
+
// 字数档 (issue from the operator, 2026-10-18): the message speaker's long press offers
|
|
1613
|
+
// 100/150/200 字 next to 「按设置」. The count is the ONE thing that varies per call, and it
|
|
1614
|
+
// rides the prompt's existing `{maxChars}` placeholder — so this is the only place it has to
|
|
1615
|
+
// be honoured. Clamped to the same range the settings page validates, so a crafted body
|
|
1616
|
+
// cannot ask for a 10k-character invoice.
|
|
1617
|
+
const asked = Math.round(Number(body.maxChars));
|
|
1618
|
+
const [clo, chi] = SUMMARY_CHARS_RANGE;
|
|
1619
|
+
const maxChars = Number.isFinite(asked) && asked >= clo && asked <= chi ? asked : cfg.maxChars;
|
|
1620
|
+
// Judge "short enough to just read" on what would actually be SPOKEN: voice.ts drops code
|
|
1621
|
+
// blocks, and a 20-line diff must not count as a long message worth a model call.
|
|
1622
|
+
const spoken = textOf(row.payload).replace(/```[\s\S]*?```/g, " ").replace(/\s+/g, " ").trim();
|
|
1623
|
+
if (!spoken) return send(res, 409, { error: "这条消息没有可以念的文字", code: "empty" });
|
|
1624
|
+
if (spoken.length <= maxChars) {
|
|
1625
|
+
return send(res, 200, { ok: true, via: "verbatim", text: spoken, chars: spoken.length, ms: 0, maxChars });
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
// The operator's rule: bill this to the model he is already talking to. The voice
|
|
1629
|
+
// endpoint stays as the fallback (and is the whole route when followSession is off).
|
|
1630
|
+
const own = cfg.followSession ? sessionRoute() : { route: null, why: "" };
|
|
1631
|
+
const model = own.route?.model || cfg.model || SUMMARY_MODEL_DEFAULT;
|
|
1632
|
+
const r = await summarizeForSpeech({
|
|
1633
|
+
text: spoken,
|
|
1634
|
+
maxChars,
|
|
1635
|
+
prompt: cfg.prompt.trim() || SUMMARY_PROMPT_DEFAULT,
|
|
1636
|
+
model,
|
|
1637
|
+
timeoutSec: cfg.timeoutSec,
|
|
1638
|
+
endpoint: own.route ? { baseUrl: own.route.baseUrl, apiKey: own.route.apiKey } : undefined,
|
|
1639
|
+
});
|
|
1640
|
+
// Newlines are the one thing the model keeps ignoring: 「不要换行」 in the prompt is a
|
|
1641
|
+
// request, so the answer is normalised here — the note clamps to two lines and the TTS
|
|
1642
|
+
// chunker reads sentence by sentence, and a stray blank line costs both of them.
|
|
1643
|
+
const text = r.text.replace(/\s*\n+\s*/g, " ").replace(/\s{2,}/g, " ").trim();
|
|
1644
|
+
return send(res, 200, {
|
|
1645
|
+
ok: true, via: "model", text, chars: text.length,
|
|
1646
|
+
// echo the bucket: the note prints 「约 150 字」 and a summary that silently used the
|
|
1647
|
+
// configured number would make the menu look broken
|
|
1648
|
+
maxChars,
|
|
1649
|
+
ms: r.ms, truncated: r.truncated, model,
|
|
1650
|
+
// which account this call was billed to — the note prints it, and a fallback that
|
|
1651
|
+
// silently used the OTHER provider would be a lie about the cost
|
|
1652
|
+
route: own.route ? "session" : "voice",
|
|
1653
|
+
provider: own.route?.provider ?? "dashscope",
|
|
1654
|
+
routeNote: own.route ? own.route.source : own.why,
|
|
1655
|
+
});
|
|
1656
|
+
} catch (e) {
|
|
1657
|
+
// 409 = nothing is configured, 504 = we gave up waiting, 502 = the endpoint said no.
|
|
1658
|
+
// None of them may be papered over with the original text: a fallback that looks like a
|
|
1659
|
+
// summary is worse than an honest failure the UI can offer to retry.
|
|
1660
|
+
const err = e as { code?: string; message?: string };
|
|
1661
|
+
const status = err.code === "not_configured" ? 409 : err.code === "timeout" ? 504 : 502;
|
|
1662
|
+
return send(res, status, { error: String(err.message ?? e), code: err.code ?? "upstream" });
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1528
1665
|
if (req.method === "POST" && sub === "/fork") {
|
|
1529
1666
|
try {
|
|
1530
1667
|
// forking a cold session resumes it first (the call has to reach a live agent)
|
|
@@ -1662,6 +1799,9 @@ const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
|
1662
1799
|
httpServer.on("upgrade", handleUpgrade);
|
|
1663
1800
|
wss.on("connection", (ws) => {
|
|
1664
1801
|
const clientId = randomUUID().slice(0, 8);
|
|
1802
|
+
// Any frame — including a pong — proves the peer is alive (see the heartbeat below).
|
|
1803
|
+
(ws as WebSocket & { isAlive?: boolean }).isAlive = true;
|
|
1804
|
+
ws.on("pong", () => { (ws as WebSocket & { isAlive?: boolean }).isAlive = true; });
|
|
1665
1805
|
clients.set(clientId, { ws, lastSeen: new Map() });
|
|
1666
1806
|
const sendEvt = (e: ServerEvent) => {
|
|
1667
1807
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(e));
|
|
@@ -1698,7 +1838,7 @@ wss.on("connection", (ws) => {
|
|
|
1698
1838
|
break;
|
|
1699
1839
|
}
|
|
1700
1840
|
case "prompt":
|
|
1701
|
-
void runPrompt(cmd.sessionId, cmd.text, cmd.attachments ?? [], cmd.interrupt === true);
|
|
1841
|
+
void runPrompt(cmd.sessionId, cmd.text, cmd.attachments ?? [], cmd.interrupt === true, cmd.pid);
|
|
1702
1842
|
break;
|
|
1703
1843
|
case "cancel":
|
|
1704
1844
|
await mgr.cancel(cmd.sessionId);
|
|
@@ -1732,6 +1872,35 @@ wss.on("connection", (ws) => {
|
|
|
1732
1872
|
ws.on("close", () => clients.delete(clientId));
|
|
1733
1873
|
});
|
|
1734
1874
|
|
|
1875
|
+
// ---- liveness: the half-open socket ---------------------------------------------------------
|
|
1876
|
+
// A TCP connection that dies without a FIN (Wi-Fi switch, laptop sleep, NAT timeout) stays OPEN in
|
|
1877
|
+
// BOTH ends: the page's `send()` succeeds into the void, and this server keeps a client that will
|
|
1878
|
+
// never read again. There is no traffic to fail on, so nothing above this line can notice — and the
|
|
1879
|
+
// operator's message is written into a socket nobody owns, with no bubble and no error (issue #26,
|
|
1880
|
+
// measured live 2026-10-11 with the network cut). Ping/pong is the only detector the protocol gives
|
|
1881
|
+
// us: browsers answer `ping` automatically, so a missing pong means the peer is gone.
|
|
1882
|
+
//
|
|
1883
|
+
// 20s, not 60s: the window between "socket died" and "page knows" is exactly how long a prompt can
|
|
1884
|
+
// vanish, and the ack timeout on the client (8s) covers what happens inside one beat. An idle
|
|
1885
|
+
// cockpit costs 3 tiny frames a minute.
|
|
1886
|
+
const HEARTBEAT_MS = Number(process.env.AGENTUS_WS_HEARTBEAT_MS ?? "") || 20_000;
|
|
1887
|
+
const heartbeat = setInterval(() => {
|
|
1888
|
+
for (const [id, client] of clients) {
|
|
1889
|
+
const ws = client.ws as WebSocket & { isAlive?: boolean };
|
|
1890
|
+
if (ws.isAlive === false) {
|
|
1891
|
+
// No pong since the last beat: the peer is not there. `terminate()` (not `close()`) because a
|
|
1892
|
+
// half-open peer never answers the closing handshake either — this is the only way out.
|
|
1893
|
+
ws.terminate();
|
|
1894
|
+
clients.delete(id);
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
ws.isAlive = false;
|
|
1898
|
+
try { ws.ping(); } catch { /* already gone; the next beat reaps it */ }
|
|
1899
|
+
}
|
|
1900
|
+
}, HEARTBEAT_MS);
|
|
1901
|
+
heartbeat.unref();
|
|
1902
|
+
|
|
1903
|
+
|
|
1735
1904
|
// ---- the workspace panel's terminal -----------------------------------------
|
|
1736
1905
|
// A shell is a stream, so it gets its own socket instead of riding the event bus:
|
|
1737
1906
|
// one socket = one shell, and closing the socket (or the tab) kills the shell.
|