@flowingspring/dsh-voco 0.2.9 → 0.3.1

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/README.md CHANGED
@@ -37,6 +37,7 @@ The plugin uses `XingChenAGI/XingChenASR-V3.2-Ultra` for cloud speech recognitio
37
37
  - Speaks a short contextual acknowledgement before starting delegated Agent work.
38
38
  - Keeps full task reports in the task UI while speaking a concise, purpose-written result.
39
39
  - Supports interruption, browser navigation, reconnects, and restored conversation history.
40
+ - Pure voice sessions get a concise title from the first meaningful voice request, with the transcription as a fallback when the title model is unavailable.
40
41
  - Ships the server and browser surfaces as one public npm package.
41
42
 
42
43
  ## Configuration
package/README.zh.md CHANGED
@@ -37,6 +37,7 @@ SILICONFLOW_API_KEY=你的密钥
37
37
  - 委派任务启动前立即播报一句贴合当前请求的简短确认语。
38
38
  - 完整任务报告保留在任务界面,语音只播报专门生成的简洁结果。
39
39
  - 支持语音打断、页面切换、断线重连以及历史对话恢复。
40
+ - 纯语音会话会根据第一条有效语音请求生成简短标题;标题模型不可用时使用语音转写作为备用。
40
41
  - 服务端和浏览器界面统一通过一个公开 npm 包发行。
41
42
 
42
43
  ## 配置项
package/cordis.patch.yml CHANGED
@@ -24,7 +24,8 @@
24
24
  name: '@flowingspring/dsh-voco/voice-assistant'
25
25
  config:
26
26
  maxPendingObservations: 64
27
- restoreConversation: true
27
+ restoreConversation: true
28
+ memoryRecallTimeoutMs: 250
28
29
  maxRestoredUtterances: 24
29
30
  taskSessionPolicy: continuous
30
31
  completedAnnouncement: 任务已完成。
package/lib/client.js CHANGED
@@ -18,11 +18,11 @@ window.__ModuleLoader__.load({
18
18
  document.head.appendChild(tag);
19
19
  }
20
20
  var VoiceControl_module_css_default = {
21
+ "interruptButton": "TFZsua_interruptButton",
22
+ "active": "TFZsua_active",
21
23
  "controls": "TFZsua_controls",
22
- "voice-pulse": "TFZsua_voice-pulse",
23
24
  "button": "TFZsua_button",
24
- "active": "TFZsua_active",
25
- "interruptButton": "TFZsua_interruptButton"
25
+ "voice-pulse": "TFZsua_voice-pulse"
26
26
  };
27
27
  //#endregion
28
28
  //#region ../ui-voice/src/client/VoiceControl.tsx
@@ -141,23 +141,23 @@ window.__ModuleLoader__.load({
141
141
  document.head.appendChild(tag);
142
142
  }
143
143
  var VoiceNodeViews_module_css_default = {
144
- "taskInput": "Kt2smW_taskInput",
144
+ "taskDot": "Kt2smW_taskDot",
145
145
  "miniWave": "Kt2smW_miniWave",
146
+ "taskInput": "Kt2smW_taskInput",
147
+ "taskUpdate": "Kt2smW_taskUpdate",
148
+ "taskCard": "Kt2smW_taskCard",
149
+ "taskTitle": "Kt2smW_taskTitle",
146
150
  "taskSummary": "Kt2smW_taskSummary",
147
- "meta": "Kt2smW_meta",
148
151
  "bubble": "Kt2smW_bubble",
149
- "taskStatus": "Kt2smW_taskStatus",
150
- "taskActions": "Kt2smW_taskActions",
151
- "taskLink": "Kt2smW_taskLink",
152
+ "meta": "Kt2smW_meta",
153
+ "taskChevron": "Kt2smW_taskChevron",
152
154
  "taskDetails": "Kt2smW_taskDetails",
153
- "taskDot": "Kt2smW_taskDot",
155
+ "taskLink": "Kt2smW_taskLink",
154
156
  "utterance": "Kt2smW_utterance",
155
- "voiceBadge": "Kt2smW_voiceBadge",
156
- "taskCard": "Kt2smW_taskCard",
157
157
  "taskCancel": "Kt2smW_taskCancel",
158
- "taskChevron": "Kt2smW_taskChevron",
159
- "taskUpdate": "Kt2smW_taskUpdate",
160
- "taskTitle": "Kt2smW_taskTitle"
158
+ "taskActions": "Kt2smW_taskActions",
159
+ "voiceBadge": "Kt2smW_voiceBadge",
160
+ "taskStatus": "Kt2smW_taskStatus"
161
161
  };
162
162
  //#endregion
163
163
  //#region ../ui-voice/src/client/VoiceNodeViews.tsx
@@ -286,9 +286,9 @@ window.__ModuleLoader__.load({
286
286
  }
287
287
  var VoiceOverlay_module_css_default = {
288
288
  "wave": "GPPB2G_wave",
289
+ "stop": "GPPB2G_stop",
289
290
  "root": "GPPB2G_root",
290
291
  "voice-wave": "GPPB2G_voice-wave",
291
- "stop": "GPPB2G_stop",
292
292
  "status": "GPPB2G_status"
293
293
  };
294
294
  //#endregion
@@ -129,7 +129,8 @@ const Config = z.object({
129
129
  completedAnnouncement: z.string().default("任务已完成。"),
130
130
  failedAnnouncement: z.string().default("任务失败了,请查看屏幕上的错误信息。"),
131
131
  cancelledAnnouncement: z.string().default("任务已取消。"),
132
- interruptedAnnouncement: z.string().default("上次任务因服务关闭而中断,没有自动重放。你可以告诉我是否继续。")
132
+ interruptedAnnouncement: z.string().default("上次任务因服务关闭而中断,没有自动重放。你可以告诉我是否继续。"),
133
+ memoryRecallTimeoutMs: z.natural().min(1).default(250)
133
134
  });
134
135
  /** Plugin-owned durable session event types, registered with core at load. */
135
136
  const VOICE_SESSION_EVENT_TYPES = [
@@ -159,6 +160,41 @@ function optionalWorkspaceMemory(ctx) {
159
160
  return;
160
161
  }
161
162
  }
163
+ function optionalSessionTitle(ctx) {
164
+ try {
165
+ return ctx.get("sessionTitle");
166
+ } catch {
167
+ return;
168
+ }
169
+ }
170
+ function recallWithSoftTimeout(operation, timeoutMs) {
171
+ const deadline = Math.max(1, timeoutMs);
172
+ return new Promise((resolve) => {
173
+ let settled = false;
174
+ const timer = setTimeout(() => {
175
+ if (settled) return;
176
+ settled = true;
177
+ resolve({ kind: "timeout" });
178
+ }, deadline);
179
+ operation().then((value) => {
180
+ if (settled) return;
181
+ settled = true;
182
+ clearTimeout(timer);
183
+ resolve({
184
+ kind: "resolved",
185
+ value
186
+ });
187
+ }, (error) => {
188
+ if (settled) return;
189
+ settled = true;
190
+ clearTimeout(timer);
191
+ resolve({
192
+ kind: "error",
193
+ error
194
+ });
195
+ });
196
+ });
197
+ }
162
198
  function workspaceMemoryReference(memory) {
163
199
  const sections = [];
164
200
  if (memory.summary.trim() !== "") sections.push(`稳定摘要:\n${memory.summary.trim()}`);
@@ -193,13 +229,64 @@ const REWRITE_EVENT_INSTRUCTIONS = {
193
229
  warning: "这是警告信息。简洁说明风险、影响以及用户需要知道的事项。",
194
230
  error: "这是失败信息。明确说明没有完成、主要原因以及可行的下一步。"
195
231
  };
232
+ const VOICE_TITLE_SYSTEM_PROMPT = `你是语音会话标题生成器。根据用户第一条有实际内容的语音请求,生成一个简短、准确、便于在会话列表中识别的中文标题。
233
+
234
+ 只输出标题本身,不要解释,不要引号,不要 Markdown,不要句末标点,不要使用“语音会话”“新会话”等泛化名称,不超过 12 个汉字。不要加入文件大小、行数、路径等执行细节。`;
235
+ function voiceTitleCandidate(value) {
236
+ const normalized = value.replace(/```[\s\S]*?```/gu, "").replace(/^\s*(?:标题|title)\s*[::]\s*/iu, "").replace(/["'「」『』]/gu, "").split(/\r?\n/u, 1)[0].replace(/[。!?!?;;,,::]+$/u, "").replace(/\s+/gu, " ").trim();
237
+ return Array.from(normalized).slice(0, 12).join("");
238
+ }
239
+ function isMeaningfulVoiceTitleInput(value) {
240
+ const normalized = value.replace(/\s+/gu, "").trim();
241
+ if (Array.from(normalized).length < 4) return false;
242
+ return !/^(?:你好|您好|嗨|哈喽|喂|在吗|有人吗|测试|谢谢|好的)[,。!?!?、]*$/u.test(normalized);
243
+ }
244
+ async function generateVoiceSessionTitle(ctx, session, input, titleService) {
245
+ let candidate = "";
246
+ try {
247
+ const llm = ctx.get("llm");
248
+ if (llm !== void 0) {
249
+ const selection = ctx.agentDefaultModel.currentSelection();
250
+ const message = createUserMessage({
251
+ content: [{
252
+ type: "text",
253
+ text: `用户第一条有效语音请求:${input}`
254
+ }],
255
+ source: {
256
+ kind: "plugin",
257
+ plugin: "voice-assistant"
258
+ }
259
+ });
260
+ let output = "";
261
+ for await (const chunk of llm.stream({
262
+ provider: selection.provider,
263
+ model: selection.model,
264
+ ...selection.reasoningEffort === void 0 ? {} : { reasoningEffort: selection.reasoningEffort },
265
+ messages: [message],
266
+ system: VOICE_TITLE_SYSTEM_PROMPT
267
+ })) if (chunk.type === "text-delta") output += chunk.text;
268
+ candidate = voiceTitleCandidate(output);
269
+ }
270
+ } catch (error) {
271
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
272
+ }
273
+ if (candidate === "") candidate = voiceTitleCandidate(input);
274
+ if (candidate === "" || titleService.get(session) !== void 0) return;
275
+ try {
276
+ titleService.rename(session, candidate);
277
+ } catch (error) {
278
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
279
+ }
280
+ }
196
281
  /** Install the driver. @param ctx - composed Agent and voice context. @param config - driver copy and queue bounds. */
197
282
  function apply(ctx, config = {}) {
198
283
  const bindings = /* @__PURE__ */ new Map();
199
284
  const taskBindings = /* @__PURE__ */ new Map();
200
285
  const handles = /* @__PURE__ */ new Map();
286
+ const audioResponsesSeen = /* @__PURE__ */ new Set();
201
287
  const maxPending = config.maxPendingObservations ?? 64;
202
288
  const maxRestoredUtterances = config.maxRestoredUtterances ?? 24;
289
+ const memoryRecallTimeoutMs = config.memoryRecallTimeoutMs ?? 250;
203
290
  const loadConversationMemory = async (sessionId) => {
204
291
  const events = ctx.sessions.get(sessionId)?.events ?? (await ctx.get("sessionPersistence")?.inspect(sessionId))?.events;
205
292
  if (events === void 0) return void 0;
@@ -223,6 +310,7 @@ function apply(ctx, config = {}) {
223
310
  interactionMode: void 0,
224
311
  voiceAttached: false,
225
312
  voiceTurnMarked: false,
313
+ voiceTitleRequested: false,
226
314
  active: void 0,
227
315
  continuousTaskAgent: void 0,
228
316
  lastTerminalTaskId: void 0,
@@ -716,13 +804,22 @@ function apply(ctx, config = {}) {
716
804
  };
717
805
  const endUtterance = (binding, utteranceId, role, state, finalText, responseId) => {
718
806
  const utterance = beginUtterance(binding, utteranceId, role, responseId);
719
- requireSourceSession(binding).append("voice/utterance-end", {
807
+ const sourceSession = requireSourceSession(binding);
808
+ sourceSession.append("voice/utterance-end", {
720
809
  utteranceId,
721
810
  role,
722
811
  text: finalText ?? utterance.text,
723
812
  state,
724
813
  ...responseId === void 0 ? {} : { responseId }
725
814
  });
815
+ const utteranceText = finalText ?? utterance.text;
816
+ if (role === "user" && state === "completed" && isMeaningfulVoiceTitleInput(utteranceText)) {
817
+ const titleService = optionalSessionTitle(ctx);
818
+ if (titleService !== void 0 && !binding.voiceTitleRequested && titleService.get(sourceSession) === void 0) {
819
+ binding.voiceTitleRequested = true;
820
+ generateVoiceSessionTitle(ctx, sourceSession, utteranceText, titleService);
821
+ }
822
+ }
726
823
  binding.utterances.delete(utteranceId);
727
824
  };
728
825
  const interruptAssistantUtterances = (binding, responseId) => {
@@ -799,26 +896,69 @@ function apply(ctx, config = {}) {
799
896
  });
800
897
  return;
801
898
  }
899
+ const routeStartedAt = Date.now();
900
+ const input = call.command.input;
901
+ debugVoiceLatency("route-command-received", {
902
+ callId: String(call.id),
903
+ inputLength: input.length
904
+ });
802
905
  let route;
803
906
  try {
804
907
  let memoryReference = "";
805
908
  const memory = optionalWorkspaceMemory(ctx);
806
- if (memory !== void 0) try {
807
- memoryReference = workspaceMemoryReference(await memory.recall({
909
+ if (memory !== void 0) {
910
+ const memoryStartedAt = Date.now();
911
+ debugVoiceLatency("memory-recall-start", { callId: String(call.id) });
912
+ const recallResult = await recallWithSoftTimeout(() => memory.recall({
808
913
  sessionId: binding.sessionId,
809
- query: call.command.input,
914
+ query: input,
810
915
  maxBytes: 5e3
811
- }));
812
- } catch (error) {
813
- ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
916
+ }), memoryRecallTimeoutMs);
917
+ if (recallResult.kind === "resolved") {
918
+ memoryReference = workspaceMemoryReference(recallResult.value);
919
+ debugVoiceLatency("memory-recall-end", {
920
+ callId: String(call.id),
921
+ durationMs: Date.now() - memoryStartedAt,
922
+ referenceLength: memoryReference.length
923
+ });
924
+ } else if (recallResult.kind === "timeout") debugVoiceLatency("memory-recall-timeout", {
925
+ callId: String(call.id),
926
+ durationMs: Date.now() - memoryStartedAt,
927
+ timeoutMs: memoryRecallTimeoutMs,
928
+ fallback: "empty-reference"
929
+ });
930
+ else {
931
+ debugVoiceLatency("memory-recall-error", {
932
+ callId: String(call.id),
933
+ durationMs: Date.now() - memoryStartedAt,
934
+ error: String(recallResult.error)
935
+ });
936
+ ctx.logger.warn(recallResult.error instanceof Error ? recallResult.error : new Error(String(recallResult.error)));
937
+ }
814
938
  }
815
- route = await routeFrontendInput(ctx, requireSourceSession(binding).events, call.command.input, memoryReference);
939
+ route = await routeFrontendInput(ctx, requireSourceSession(binding).events, input, memoryReference);
940
+ debugVoiceLatency("route-decision", {
941
+ callId: String(call.id),
942
+ action: route.action,
943
+ durationMs: Date.now() - routeStartedAt
944
+ });
816
945
  } catch (error) {
946
+ debugVoiceLatency("route-error", {
947
+ callId: String(call.id),
948
+ durationMs: Date.now() - routeStartedAt,
949
+ error: String(error)
950
+ });
817
951
  ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
818
- route = fallbackDelegation(call.command.input, recentConversationText(requireSourceSession(binding).events));
952
+ route = fallbackDelegation(input, recentConversationText(requireSourceSession(binding).events));
819
953
  }
820
954
  if (route.action === "delegate") {
821
955
  const taskId = VoiceTaskId(randomUUID());
956
+ debugVoiceLatency("ack-queued", {
957
+ callId: String(call.id),
958
+ taskId,
959
+ routeDurationMs: Date.now() - routeStartedAt,
960
+ textLength: route.acknowledgement.length
961
+ });
822
962
  speakFragment(binding, taskId, route.acknowledgement);
823
963
  await onTaskCommand(binding, voiceSessionId, {
824
964
  id: call.id,
@@ -849,6 +989,12 @@ function apply(ctx, config = {}) {
849
989
  const taskId = delegationOverride?.taskId ?? VoiceTaskId(randomUUID());
850
990
  const requestText = delegationOverride?.requestText ?? call.command.input;
851
991
  const continuous = (config.taskSessionPolicy ?? "isolated") === "continuous";
992
+ const delegationStartedAt = Date.now();
993
+ debugVoiceLatency("delegation-init-start", {
994
+ callId: String(call.id),
995
+ taskId,
996
+ policy: continuous ? "continuous" : "isolated"
997
+ });
852
998
  let created;
853
999
  try {
854
1000
  if (continuous) created = await ensureContinuousTaskAgent(binding);
@@ -860,7 +1006,19 @@ function apply(ctx, config = {}) {
860
1006
  ...await createTaskAgent(binding, taskSessionId, selection)
861
1007
  };
862
1008
  }
1009
+ debugVoiceLatency("delegation-init-end", {
1010
+ callId: String(call.id),
1011
+ taskId,
1012
+ taskSessionId: created.taskSessionId,
1013
+ durationMs: Date.now() - delegationStartedAt
1014
+ });
863
1015
  } catch (error) {
1016
+ debugVoiceLatency("delegation-init-error", {
1017
+ callId: String(call.id),
1018
+ taskId,
1019
+ durationMs: Date.now() - delegationStartedAt,
1020
+ error: String(error)
1021
+ });
864
1022
  backendUnavailable(error);
865
1023
  return;
866
1024
  }
@@ -1087,6 +1245,11 @@ function apply(ctx, config = {}) {
1087
1245
  });
1088
1246
  return;
1089
1247
  case "transcription.completed":
1248
+ debugVoiceLatency("transcription-completed", {
1249
+ voiceSessionId: String(session.id),
1250
+ utteranceId: String(event.utteranceId),
1251
+ textLength: event.text.length
1252
+ });
1090
1253
  enqueue(binding, async () => {
1091
1254
  endUtterance(binding, event.utteranceId, "user", "completed", event.text);
1092
1255
  if (session.interactionMode === "speech-shell" && binding.voiceSessionId === session.id) await onTranscription(binding, event.text);
@@ -1125,7 +1288,17 @@ function apply(ctx, config = {}) {
1125
1288
  debugVoiceLatency("audio-started");
1126
1289
  return;
1127
1290
  case "output_audio.delta":
1291
+ if (!audioResponsesSeen.has(String(event.responseId))) {
1292
+ audioResponsesSeen.add(String(event.responseId));
1293
+ debugVoiceLatency("audio-first-delta", {
1294
+ responseId: String(event.responseId),
1295
+ audioBytes: event.audio.byteLength
1296
+ });
1297
+ }
1298
+ return;
1128
1299
  case "output_audio.done":
1300
+ audioResponsesSeen.delete(String(event.responseId));
1301
+ return;
1129
1302
  case "task.observation":
1130
1303
  case "error":
1131
1304
  case "closed": return;
@@ -1313,6 +1486,16 @@ async function routeFrontendInput(ctx, events, input, workspaceMemory = "") {
1313
1486
  const recentConversation = recentConversationText(events);
1314
1487
  if (llm === void 0) return fallbackDelegation(input, recentConversation);
1315
1488
  const selection = ctx.agentDefaultModel.currentSelection();
1489
+ const routeLlmStartedAt = Date.now();
1490
+ let routeLlmFirstTextAt;
1491
+ debugVoiceLatency("route-llm-start", {
1492
+ provider: selection.provider,
1493
+ model: selection.model,
1494
+ reasoningEffort: selection.reasoningEffort,
1495
+ inputLength: input.length,
1496
+ recentConversationLength: recentConversation.length,
1497
+ workspaceMemoryLength: workspaceMemory.length
1498
+ });
1316
1499
  const message = createUserMessage({
1317
1500
  content: [{
1318
1501
  type: "text",
@@ -1354,13 +1537,28 @@ async function routeFrontendInput(ctx, events, input, workspaceMemory = "") {
1354
1537
  }
1355
1538
  });
1356
1539
  let output = "";
1357
- for await (const chunk of llm.stream({
1358
- provider: selection.provider,
1359
- model: selection.model,
1360
- ...selection.reasoningEffort === void 0 ? {} : { reasoningEffort: selection.reasoningEffort },
1361
- messages: [message],
1362
- system: "你是语音前台路由器。严格按要求输出一个 JSON 对象。不要把普通对话委派给后台编码 Agent。"
1363
- })) if (chunk.type === "text-delta") output += chunk.text;
1540
+ try {
1541
+ for await (const chunk of llm.stream({
1542
+ provider: selection.provider,
1543
+ model: selection.model,
1544
+ ...selection.reasoningEffort === void 0 ? {} : { reasoningEffort: selection.reasoningEffort },
1545
+ messages: [message],
1546
+ system: "你是语音前台路由器。严格按要求输出一个 JSON 对象。不要把普通对话委派给后台编码 Agent。"
1547
+ })) {
1548
+ if (chunk.type !== "text-delta") continue;
1549
+ if (routeLlmFirstTextAt === void 0) {
1550
+ routeLlmFirstTextAt = Date.now();
1551
+ debugVoiceLatency("route-llm-first-text", { delayMs: routeLlmFirstTextAt - routeLlmStartedAt });
1552
+ }
1553
+ output += chunk.text;
1554
+ }
1555
+ } finally {
1556
+ debugVoiceLatency("route-llm-end", {
1557
+ durationMs: Date.now() - routeLlmStartedAt,
1558
+ firstTextDelayMs: routeLlmFirstTextAt === void 0 ? void 0 : routeLlmFirstTextAt - routeLlmStartedAt,
1559
+ outputLength: output.length
1560
+ });
1561
+ }
1364
1562
  const normalized = output.trim().replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
1365
1563
  const parsed = JSON.parse(normalized);
1366
1564
  if (parsed.action === "chat" && typeof parsed.reply === "string" && parsed.reply.trim() !== "") return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flowingspring/dsh-voco",
3
3
  "description": "Persistent voice conversations for DSH with cloud speech recognition, Edge TTS, and background Agent delegation",
4
- "version": "0.2.9",
4
+ "version": "0.3.1",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",