@flowingspring/dsh-voco 0.3.0 → 0.3.2
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/cordis.patch.yml +2 -1
- package/lib/client.js +20 -20
- package/lib/plugins/voice-assistant.js +154 -22
- package/package.json +1 -1
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
|
+
"controls": "TFZsua_controls",
|
|
22
|
+
"button": "TFZsua_button",
|
|
21
23
|
"interruptButton": "TFZsua_interruptButton",
|
|
22
|
-
"active": "TFZsua_active",
|
|
23
24
|
"voice-pulse": "TFZsua_voice-pulse",
|
|
24
|
-
"
|
|
25
|
-
"controls": "TFZsua_controls"
|
|
25
|
+
"active": "TFZsua_active"
|
|
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
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
144
|
+
"taskCancel": "Kt2smW_taskCancel",
|
|
145
|
+
"taskSummary": "Kt2smW_taskSummary",
|
|
146
|
+
"meta": "Kt2smW_meta",
|
|
147
147
|
"taskDot": "Kt2smW_taskDot",
|
|
148
|
-
"
|
|
149
|
-
"
|
|
148
|
+
"taskStatus": "Kt2smW_taskStatus",
|
|
149
|
+
"taskInput": "Kt2smW_taskInput",
|
|
150
|
+
"taskTitle": "Kt2smW_taskTitle",
|
|
150
151
|
"taskActions": "Kt2smW_taskActions",
|
|
152
|
+
"taskLink": "Kt2smW_taskLink",
|
|
153
|
+
"voiceBadge": "Kt2smW_voiceBadge",
|
|
154
|
+
"bubble": "Kt2smW_bubble",
|
|
151
155
|
"taskChevron": "Kt2smW_taskChevron",
|
|
152
|
-
"taskSummary": "Kt2smW_taskSummary",
|
|
153
|
-
"miniWave": "Kt2smW_miniWave",
|
|
154
|
-
"meta": "Kt2smW_meta",
|
|
155
|
-
"taskDetails": "Kt2smW_taskDetails",
|
|
156
156
|
"taskUpdate": "Kt2smW_taskUpdate",
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
157
|
+
"taskCard": "Kt2smW_taskCard",
|
|
158
|
+
"taskDetails": "Kt2smW_taskDetails",
|
|
159
|
+
"miniWave": "Kt2smW_miniWave",
|
|
160
|
+
"utterance": "Kt2smW_utterance"
|
|
161
161
|
};
|
|
162
162
|
//#endregion
|
|
163
163
|
//#region ../ui-voice/src/client/VoiceNodeViews.tsx
|
|
@@ -285,11 +285,11 @@ window.__ModuleLoader__.load({
|
|
|
285
285
|
document.head.appendChild(tag);
|
|
286
286
|
}
|
|
287
287
|
var VoiceOverlay_module_css_default = {
|
|
288
|
-
"root": "GPPB2G_root",
|
|
289
|
-
"status": "GPPB2G_status",
|
|
290
|
-
"wave": "GPPB2G_wave",
|
|
291
288
|
"voice-wave": "GPPB2G_voice-wave",
|
|
292
|
-
"
|
|
289
|
+
"wave": "GPPB2G_wave",
|
|
290
|
+
"root": "GPPB2G_root",
|
|
291
|
+
"stop": "GPPB2G_stop",
|
|
292
|
+
"status": "GPPB2G_status"
|
|
293
293
|
};
|
|
294
294
|
//#endregion
|
|
295
295
|
//#region ../ui-voice/src/client/VoiceOverlay.tsx
|
|
@@ -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 = [
|
|
@@ -166,6 +167,34 @@ function optionalSessionTitle(ctx) {
|
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
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
|
+
}
|
|
169
198
|
function workspaceMemoryReference(memory) {
|
|
170
199
|
const sections = [];
|
|
171
200
|
if (memory.summary.trim() !== "") sections.push(`稳定摘要:\n${memory.summary.trim()}`);
|
|
@@ -254,8 +283,10 @@ function apply(ctx, config = {}) {
|
|
|
254
283
|
const bindings = /* @__PURE__ */ new Map();
|
|
255
284
|
const taskBindings = /* @__PURE__ */ new Map();
|
|
256
285
|
const handles = /* @__PURE__ */ new Map();
|
|
286
|
+
const audioResponsesSeen = /* @__PURE__ */ new Set();
|
|
257
287
|
const maxPending = config.maxPendingObservations ?? 64;
|
|
258
288
|
const maxRestoredUtterances = config.maxRestoredUtterances ?? 24;
|
|
289
|
+
const memoryRecallTimeoutMs = config.memoryRecallTimeoutMs ?? 250;
|
|
259
290
|
const loadConversationMemory = async (sessionId) => {
|
|
260
291
|
const events = ctx.sessions.get(sessionId)?.events ?? (await ctx.get("sessionPersistence")?.inspect(sessionId))?.events;
|
|
261
292
|
if (events === void 0) return void 0;
|
|
@@ -865,26 +896,69 @@ function apply(ctx, config = {}) {
|
|
|
865
896
|
});
|
|
866
897
|
return;
|
|
867
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
|
+
});
|
|
868
905
|
let route;
|
|
869
906
|
try {
|
|
870
907
|
let memoryReference = "";
|
|
871
908
|
const memory = optionalWorkspaceMemory(ctx);
|
|
872
|
-
if (memory !== void 0)
|
|
873
|
-
|
|
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({
|
|
874
913
|
sessionId: binding.sessionId,
|
|
875
|
-
query:
|
|
914
|
+
query: input,
|
|
876
915
|
maxBytes: 5e3
|
|
877
|
-
}));
|
|
878
|
-
|
|
879
|
-
|
|
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
|
+
}
|
|
880
938
|
}
|
|
881
|
-
route = await routeFrontendInput(ctx, requireSourceSession(binding).events,
|
|
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
|
+
});
|
|
882
945
|
} catch (error) {
|
|
946
|
+
debugVoiceLatency("route-error", {
|
|
947
|
+
callId: String(call.id),
|
|
948
|
+
durationMs: Date.now() - routeStartedAt,
|
|
949
|
+
error: String(error)
|
|
950
|
+
});
|
|
883
951
|
ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
884
|
-
route = fallbackDelegation(
|
|
952
|
+
route = fallbackDelegation(input, recentConversationText(requireSourceSession(binding).events));
|
|
885
953
|
}
|
|
886
954
|
if (route.action === "delegate") {
|
|
887
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
|
+
});
|
|
888
962
|
speakFragment(binding, taskId, route.acknowledgement);
|
|
889
963
|
await onTaskCommand(binding, voiceSessionId, {
|
|
890
964
|
id: call.id,
|
|
@@ -915,6 +989,12 @@ function apply(ctx, config = {}) {
|
|
|
915
989
|
const taskId = delegationOverride?.taskId ?? VoiceTaskId(randomUUID());
|
|
916
990
|
const requestText = delegationOverride?.requestText ?? call.command.input;
|
|
917
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
|
+
});
|
|
918
998
|
let created;
|
|
919
999
|
try {
|
|
920
1000
|
if (continuous) created = await ensureContinuousTaskAgent(binding);
|
|
@@ -926,7 +1006,19 @@ function apply(ctx, config = {}) {
|
|
|
926
1006
|
...await createTaskAgent(binding, taskSessionId, selection)
|
|
927
1007
|
};
|
|
928
1008
|
}
|
|
1009
|
+
debugVoiceLatency("delegation-init-end", {
|
|
1010
|
+
callId: String(call.id),
|
|
1011
|
+
taskId,
|
|
1012
|
+
taskSessionId: created.taskSessionId,
|
|
1013
|
+
durationMs: Date.now() - delegationStartedAt
|
|
1014
|
+
});
|
|
929
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
|
+
});
|
|
930
1022
|
backendUnavailable(error);
|
|
931
1023
|
return;
|
|
932
1024
|
}
|
|
@@ -1153,6 +1245,11 @@ function apply(ctx, config = {}) {
|
|
|
1153
1245
|
});
|
|
1154
1246
|
return;
|
|
1155
1247
|
case "transcription.completed":
|
|
1248
|
+
debugVoiceLatency("transcription-completed", {
|
|
1249
|
+
voiceSessionId: String(session.id),
|
|
1250
|
+
utteranceId: String(event.utteranceId),
|
|
1251
|
+
textLength: event.text.length
|
|
1252
|
+
});
|
|
1156
1253
|
enqueue(binding, async () => {
|
|
1157
1254
|
endUtterance(binding, event.utteranceId, "user", "completed", event.text);
|
|
1158
1255
|
if (session.interactionMode === "speech-shell" && binding.voiceSessionId === session.id) await onTranscription(binding, event.text);
|
|
@@ -1191,7 +1288,17 @@ function apply(ctx, config = {}) {
|
|
|
1191
1288
|
debugVoiceLatency("audio-started");
|
|
1192
1289
|
return;
|
|
1193
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;
|
|
1194
1299
|
case "output_audio.done":
|
|
1300
|
+
audioResponsesSeen.delete(String(event.responseId));
|
|
1301
|
+
return;
|
|
1195
1302
|
case "task.observation":
|
|
1196
1303
|
case "error":
|
|
1197
1304
|
case "closed": return;
|
|
@@ -1301,7 +1408,7 @@ function speechFragments(text, flush) {
|
|
|
1301
1408
|
rest
|
|
1302
1409
|
};
|
|
1303
1410
|
}
|
|
1304
|
-
const DEFAULT_DELEGATION_ACKNOWLEDGEMENT = "
|
|
1411
|
+
const DEFAULT_DELEGATION_ACKNOWLEDGEMENT = "我看看。";
|
|
1305
1412
|
const MAX_DELEGATION_ACKNOWLEDGEMENT_LENGTH = 40;
|
|
1306
1413
|
const MAX_DELEGATED_TASK_LENGTH = 1e3;
|
|
1307
1414
|
const MAX_DELEGATION_BACKGROUND_LENGTH = 2e3;
|
|
@@ -1379,20 +1486,30 @@ async function routeFrontendInput(ctx, events, input, workspaceMemory = "") {
|
|
|
1379
1486
|
const recentConversation = recentConversationText(events);
|
|
1380
1487
|
if (llm === void 0) return fallbackDelegation(input, recentConversation);
|
|
1381
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
|
+
});
|
|
1382
1499
|
const message = createUserMessage({
|
|
1383
1500
|
content: [{
|
|
1384
1501
|
type: "text",
|
|
1385
1502
|
text: [
|
|
1386
1503
|
"结合最近对话判断当前用户原话应该由语音前台聊天、轻量工具还是后台编码 Agent 处理。",
|
|
1387
|
-
"
|
|
1504
|
+
"只有普通寒暄、日常对话,以及无需外部事实或工具就能可靠回答的稳定知识,才选择 chat,并直接给出自然简洁的中文回复。",
|
|
1388
1505
|
"时间、日期等会随现实变化的事实不能猜测。查询本机当前时间、日期或星期时必须选择 tool=local_datetime。",
|
|
1389
|
-
"
|
|
1506
|
+
"凡是需要搜索、查询、核实、最新信息,或涉及陌生、不确定、可能过时的术语和事实,一律选择 delegate;不能选择 chat 编造答案。",
|
|
1390
1507
|
"需要查看或修改工作区文件、运行命令、测试、安装依赖或执行其他复杂工具操作时,选择 delegate。",
|
|
1391
1508
|
"选择 delegate 时,必须结合最近对话补全省略指代,输出自包含的 task、只用于消歧的 background,并原样复制 user_request。",
|
|
1392
1509
|
"task 是后台当前唯一要执行的任务;background 不能包含新的要求,也不要把旧任务写成待办。",
|
|
1393
|
-
"
|
|
1394
|
-
"acknowledgement
|
|
1395
|
-
"
|
|
1510
|
+
"同时给出一句非常短、自然口语化的 acknowledgement,只说接下来要做的动作,不复述用户问题,不解释原因,不使用书面汇报语气。不能声称任务已经完成、已经找到结果,也不要提后台 Agent、工具或路由。",
|
|
1511
|
+
"acknowledgement 尽量简短,通常不超过 16 个汉字,硬上限为 40 个字符;避免使用“核实”“确认”“具体含义”“相关内容”等书面化表达,也不要把用户的术语或问题改写进去。",
|
|
1512
|
+
"根据任务语境使用日常说法,例如“我去查查”“我看看”“我帮你找找”“我去搜一下”。不要固定使用同一句,也不要强行添加“哈”。",
|
|
1396
1513
|
"任务边界与写操作规则:task 只能重述用户明确表达的目标,不得擅自增加新的交付物。",
|
|
1397
1514
|
"搜索、查询、核实、阅读和分析类任务默认只读,不创建或修改工作区文件。",
|
|
1398
1515
|
"“给我一个结果”“给我一个产物”“整理一下”“形成说明”等模糊表达,不代表允许写文件。",
|
|
@@ -1420,13 +1537,28 @@ async function routeFrontendInput(ctx, events, input, workspaceMemory = "") {
|
|
|
1420
1537
|
}
|
|
1421
1538
|
});
|
|
1422
1539
|
let output = "";
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
+
}
|
|
1430
1562
|
const normalized = output.trim().replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
|
|
1431
1563
|
const parsed = JSON.parse(normalized);
|
|
1432
1564
|
if (parsed.action === "chat" && typeof parsed.reply === "string" && parsed.reply.trim() !== "") return {
|
package/package.json
CHANGED