@flowingspring/dsh-voco 0.2.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/README.i18n.yaml +6 -0
- package/README.md +31 -0
- package/README.zh.md +31 -0
- package/cordis.patch.yml +43 -0
- package/lib/client.js +1469 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +6 -0
- package/lib/invariant.js +9 -0
- package/lib/plugins/llm-tool-call-compat.js +101 -0
- package/lib/plugins/voice-assistant.js +1248 -0
- package/lib/plugins/voice-local.js +593 -0
- package/lib/plugins/voice-web.js +267 -0
- package/lib/plugins/voice.js +401 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/invariant.d.ts +7 -0
- package/package.json +119 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1469 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@flowingspring/dsh-voco",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
|
+
let react = require("react");
|
|
9
|
+
let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
|
|
10
|
+
//#region \0dsh-css:D:\QUAN\DevCode\Python\Project\codex-voice\dsh-voco\packages\ui-voice\src\client\VoiceControl.module.css.mjs
|
|
11
|
+
const css$3 = ".PapC8q_button{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:0;border-radius:8px;justify-content:center;align-items:center;padding:0;display:inline-flex}.PapC8q_button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.PapC8q_active{color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-interactive-bg-hover-solid)}.PapC8q_button[data-state=speaking]{animation:1.2s ease-in-out infinite PapC8q_voice-pulse}@keyframes PapC8q_voice-pulse{50%{opacity:.55;transform:scale(.94)}}@media (prefers-reduced-motion:reduce){.PapC8q_button[data-state=speaking]{animation:none}}";
|
|
12
|
+
const tagId$3 = "@flowingspring/dsh-voco/VoiceControl.module.css";
|
|
13
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$3) + "]") === null) {
|
|
14
|
+
const tag = document.createElement("style");
|
|
15
|
+
tag.dataset.plugin = "@flowingspring/dsh-voco";
|
|
16
|
+
tag.dataset.pluginCss = tagId$3;
|
|
17
|
+
tag.textContent = css$3;
|
|
18
|
+
document.head.appendChild(tag);
|
|
19
|
+
}
|
|
20
|
+
var VoiceControl_module_css_default = {
|
|
21
|
+
"active": "PapC8q_active",
|
|
22
|
+
"voice-pulse": "PapC8q_voice-pulse",
|
|
23
|
+
"button": "PapC8q_button"
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region ../ui-voice/src/client/VoiceControl.tsx
|
|
27
|
+
/** Start a fresh Voice Session from this Session's location or control the active connection. */
|
|
28
|
+
function VoiceControl({ sessionId, useVoice, startVoice, retryVoice, stopVoice, t }) {
|
|
29
|
+
const state = useVoice((snapshot) => snapshot.state);
|
|
30
|
+
const activeSessionId = useVoice((snapshot) => snapshot.sessionId);
|
|
31
|
+
const toggle = async () => {
|
|
32
|
+
if (state === "error" && activeSessionId !== void 0) {
|
|
33
|
+
try {
|
|
34
|
+
await retryVoice();
|
|
35
|
+
} catch {}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (state !== "off" && state !== "error") {
|
|
39
|
+
await stopVoice();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await startVoice(sessionId);
|
|
44
|
+
} catch {}
|
|
45
|
+
};
|
|
46
|
+
const label = state === "off" ? t("control.start") : state === "error" ? t("control.retry") : t("control.stop");
|
|
47
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
48
|
+
type: "button",
|
|
49
|
+
className: `${VoiceControl_module_css_default.button} ${state !== "off" ? VoiceControl_module_css_default.active : ""}`,
|
|
50
|
+
"aria-label": label,
|
|
51
|
+
title: label,
|
|
52
|
+
"data-state": state,
|
|
53
|
+
onClick: () => {
|
|
54
|
+
toggle();
|
|
55
|
+
},
|
|
56
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
57
|
+
viewBox: "0 0 16 16",
|
|
58
|
+
width: "16",
|
|
59
|
+
height: "16",
|
|
60
|
+
"aria-hidden": true,
|
|
61
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
62
|
+
x: "5",
|
|
63
|
+
y: "2",
|
|
64
|
+
width: "6",
|
|
65
|
+
height: "8",
|
|
66
|
+
rx: "3",
|
|
67
|
+
fill: "none",
|
|
68
|
+
stroke: "currentColor",
|
|
69
|
+
strokeWidth: "1.4"
|
|
70
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
71
|
+
d: "M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2M5.5 14h5",
|
|
72
|
+
fill: "none",
|
|
73
|
+
stroke: "currentColor",
|
|
74
|
+
strokeWidth: "1.4",
|
|
75
|
+
strokeLinecap: "round"
|
|
76
|
+
})]
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region \0dsh-css:D:\QUAN\DevCode\Python\Project\codex-voice\dsh-voco\packages\ui-voice\src\client\VoiceHistoryAction.module.css.mjs
|
|
82
|
+
const css$2 = ".hhHVeG_trigger{width:100%;min-height:36px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:8px;justify-content:center;align-items:center;gap:9px;display:flex}.hhHVeG_trigger:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.hhHVeG_panel{z-index:60;border:1px solid var(--dsw-alias-border-l2);width:min(320px,100vw - 32px);max-height:min(480px,100vh - 96px);color:var(--dsw-alias-label-primary);background:var(--dsw-specific-menu);box-shadow:var(--dsw-shadow-lv3);border-radius:14px;flex-direction:column;display:flex;position:fixed;bottom:64px;left:16px;overflow:hidden}.hhHVeG_header{border-bottom:1px solid var(--dsw-alias-border-l2);justify-content:space-between;align-items:center;min-height:48px;padding:0 12px 0 16px;display:flex}.hhHVeG_close{width:28px;height:28px;color:inherit;cursor:pointer;background:0 0;border:0;border-radius:7px;font-size:20px;line-height:1}.hhHVeG_close:hover{background:var(--dsw-alias-interactive-bg-hover)}.hhHVeG_list{padding:8px;overflow:auto}.hhHVeG_empty{color:var(--dsw-alias-label-tertiary);text-align:center;margin:0;padding:24px 12px;font-size:13px}.hhHVeG_row{width:100%;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:9px;align-items:center;gap:10px;padding:10px;display:flex}.hhHVeG_row:hover,.hhHVeG_row[data-active=true]{background:var(--dsw-alias-interactive-bg-hover)}.hhHVeG_wave{width:24px;height:24px;color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb, currentColor 10%, transparent);border-radius:50%;flex:none;justify-content:center;align-items:center;gap:2px;display:flex}.hhHVeG_wave i{background:currentColor;border-radius:2px;width:2px}.hhHVeG_wave i:first-child{height:6px}.hhHVeG_wave i:nth-child(2){height:11px}.hhHVeG_wave i:nth-child(3){height:8px}.hhHVeG_rowText{flex-direction:column;flex:1;gap:2px;min-width:0;display:flex}.hhHVeG_title{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;overflow:hidden}.hhHVeG_meta{color:var(--dsw-alias-label-tertiary);font-size:11px}";
|
|
83
|
+
const tagId$2 = "@flowingspring/dsh-voco/VoiceHistoryAction.module.css";
|
|
84
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]") === null) {
|
|
85
|
+
const tag = document.createElement("style");
|
|
86
|
+
tag.dataset.plugin = "@flowingspring/dsh-voco";
|
|
87
|
+
tag.dataset.pluginCss = tagId$2;
|
|
88
|
+
tag.textContent = css$2;
|
|
89
|
+
document.head.appendChild(tag);
|
|
90
|
+
}
|
|
91
|
+
var VoiceHistoryAction_module_css_default = {
|
|
92
|
+
"list": "hhHVeG_list",
|
|
93
|
+
"empty": "hhHVeG_empty",
|
|
94
|
+
"panel": "hhHVeG_panel",
|
|
95
|
+
"wave": "hhHVeG_wave",
|
|
96
|
+
"meta": "hhHVeG_meta",
|
|
97
|
+
"row": "hhHVeG_row",
|
|
98
|
+
"close": "hhHVeG_close",
|
|
99
|
+
"rowText": "hhHVeG_rowText",
|
|
100
|
+
"title": "hhHVeG_title",
|
|
101
|
+
"header": "hhHVeG_header",
|
|
102
|
+
"trigger": "hhHVeG_trigger"
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region ../ui-voice/src/client/VoiceHistoryAction.tsx
|
|
106
|
+
/** Render the plugin-owned Voice history entry and its Session navigation panel. */
|
|
107
|
+
function VoiceHistoryAction({ wide, useSessions, useVoice, useVoiceHistory, openSession, t }) {
|
|
108
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
109
|
+
const sessions = useSessions((state) => state);
|
|
110
|
+
const entries = useVoiceHistory((state) => state.entries);
|
|
111
|
+
const activeSessionId = useVoice((state) => state.sessionId);
|
|
112
|
+
const available = entries.flatMap((entry) => {
|
|
113
|
+
const session = sessions.byId[entry.sessionId];
|
|
114
|
+
return session === void 0 ? [] : [{
|
|
115
|
+
entry,
|
|
116
|
+
session
|
|
117
|
+
}];
|
|
118
|
+
});
|
|
119
|
+
(0, react.useEffect)(() => {
|
|
120
|
+
if (!open) return;
|
|
121
|
+
const close = (event) => {
|
|
122
|
+
if (event.key === "Escape") setOpen(false);
|
|
123
|
+
};
|
|
124
|
+
document.addEventListener("keydown", close);
|
|
125
|
+
return () => {
|
|
126
|
+
document.removeEventListener("keydown", close);
|
|
127
|
+
};
|
|
128
|
+
}, [open]);
|
|
129
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
130
|
+
type: "button",
|
|
131
|
+
className: VoiceHistoryAction_module_css_default.trigger,
|
|
132
|
+
"aria-label": t("history.open"),
|
|
133
|
+
"aria-haspopup": "dialog",
|
|
134
|
+
"aria-expanded": open,
|
|
135
|
+
onClick: () => {
|
|
136
|
+
setOpen((value) => !value);
|
|
137
|
+
},
|
|
138
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
139
|
+
viewBox: "0 0 16 16",
|
|
140
|
+
width: "16",
|
|
141
|
+
height: "16",
|
|
142
|
+
"aria-hidden": true,
|
|
143
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
144
|
+
d: "M3 8a5 5 0 1 0 1.5-3.55M3 2.7v2.8h2.8M8 5.2V8l2 1.2",
|
|
145
|
+
fill: "none",
|
|
146
|
+
stroke: "currentColor",
|
|
147
|
+
strokeWidth: "1.35",
|
|
148
|
+
strokeLinecap: "round",
|
|
149
|
+
strokeLinejoin: "round"
|
|
150
|
+
})
|
|
151
|
+
}), wide && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("history.label") })]
|
|
152
|
+
}), open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
153
|
+
className: VoiceHistoryAction_module_css_default.panel,
|
|
154
|
+
role: "dialog",
|
|
155
|
+
"aria-label": t("history.title"),
|
|
156
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
157
|
+
className: VoiceHistoryAction_module_css_default.header,
|
|
158
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("history.title") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
159
|
+
type: "button",
|
|
160
|
+
className: VoiceHistoryAction_module_css_default.close,
|
|
161
|
+
"aria-label": t("history.close"),
|
|
162
|
+
onClick: () => {
|
|
163
|
+
setOpen(false);
|
|
164
|
+
},
|
|
165
|
+
children: "×"
|
|
166
|
+
})]
|
|
167
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
168
|
+
className: VoiceHistoryAction_module_css_default.list,
|
|
169
|
+
children: [available.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
170
|
+
className: VoiceHistoryAction_module_css_default.empty,
|
|
171
|
+
children: t("history.empty")
|
|
172
|
+
}), available.map(({ entry, session }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
173
|
+
type: "button",
|
|
174
|
+
className: VoiceHistoryAction_module_css_default.row,
|
|
175
|
+
"data-active": entry.sessionId === activeSessionId || void 0,
|
|
176
|
+
onClick: () => {
|
|
177
|
+
openSession(entry.sessionId);
|
|
178
|
+
setOpen(false);
|
|
179
|
+
},
|
|
180
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
181
|
+
className: VoiceHistoryAction_module_css_default.wave,
|
|
182
|
+
"aria-hidden": true,
|
|
183
|
+
children: [
|
|
184
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}),
|
|
185
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}),
|
|
186
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {})
|
|
187
|
+
]
|
|
188
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
189
|
+
className: VoiceHistoryAction_module_css_default.rowText,
|
|
190
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
191
|
+
className: VoiceHistoryAction_module_css_default.title,
|
|
192
|
+
children: session.displayTitle
|
|
193
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
194
|
+
className: VoiceHistoryAction_module_css_default.meta,
|
|
195
|
+
children: entry.sessionId === activeSessionId ? t("history.active") : t("history.saved")
|
|
196
|
+
})]
|
|
197
|
+
})]
|
|
198
|
+
}, entry.sessionId))]
|
|
199
|
+
})]
|
|
200
|
+
})] });
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region \0dsh-css:D:\QUAN\DevCode\Python\Project\codex-voice\dsh-voco\packages\ui-voice\src\client\VoiceNodeViews.module.css.mjs
|
|
204
|
+
const css$1 = ".KSbXkW_utterance{flex-direction:column;align-items:flex-start;gap:6px;min-width:0;display:flex}.KSbXkW_utterance[data-role=user]{align-items:flex-end}.KSbXkW_bubble{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);max-width:min(525px,82%);color:var(--dsw-alias-label-primary);overflow-wrap:anywhere;white-space:pre-wrap;border-radius:18px;padding:10px 16px;font-size:16px;line-height:24px}.KSbXkW_utterance[data-role=user] .KSbXkW_bubble{background:var(--dsw-specific-bubble);border-color:#0000}.KSbXkW_utterance[data-state=streaming] .KSbXkW_bubble{color:var(--dsw-alias-label-secondary)}.KSbXkW_meta,.KSbXkW_voiceBadge{align-items:center;display:inline-flex}.KSbXkW_meta{color:var(--dsw-alias-label-tertiary);gap:8px;padding:0 5px;font-size:10px;line-height:14px}.KSbXkW_voiceBadge{color:var(--dsw-alias-state-business-primary);letter-spacing:.04em;gap:4px;font-weight:600}.KSbXkW_miniWave{align-items:center;gap:1px;height:10px;display:inline-flex}.KSbXkW_miniWave i{background:currentColor;border-radius:1px;width:1.5px;height:4px}.KSbXkW_miniWave i:nth-child(2){height:8px}.KSbXkW_miniWave i:nth-child(3){height:6px}.KSbXkW_taskCard{box-sizing:border-box;width:fit-content;min-width:0;max-width:min(525px,100%)}.KSbXkW_taskSummary{min-width:0;max-width:100%;color:inherit;cursor:pointer;font:inherit;background:0 0;border:0;border-radius:6px;align-items:center;gap:7px;margin:-3px -5px;padding:3px 5px;display:inline-flex}.KSbXkW_taskSummary:hover{background:var(--dsw-alias-bg-layer-1)}.KSbXkW_taskDot{background:var(--dsw-alias-state-business-primary);border-radius:50%;flex:none;width:7px;height:7px}.KSbXkW_taskCard[data-status=completed] .KSbXkW_taskDot{background:var(--dsw-alias-state-success-primary)}.KSbXkW_taskCard[data-status=failed] .KSbXkW_taskDot{background:var(--dsw-alias-state-error-primary)}.KSbXkW_taskCard[data-status=cancelled] .KSbXkW_taskDot,.KSbXkW_taskCard[data-status=interrupted] .KSbXkW_taskDot{background:var(--dsw-alias-state-warn-primary)}.KSbXkW_taskTitle{min-width:0;color:var(--dsw-alias-label-secondary);text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;line-height:20px;overflow:hidden}.KSbXkW_taskStatus{color:var(--dsw-alias-label-tertiary);flex:none;font-size:11px;line-height:16px}.KSbXkW_taskStatus:before{content:\"·\";margin-right:7px}.KSbXkW_taskChevron{border-right:1px solid var(--dsw-alias-label-tertiary);border-bottom:1px solid var(--dsw-alias-label-tertiary);width:6px;height:6px;margin:0 2px 0 1px;transition:transform .12s;transform:rotate(-45deg)}.KSbXkW_taskCard[data-expanded=true] .KSbXkW_taskChevron{transform:rotate(45deg)}.KSbXkW_taskInput,.KSbXkW_taskUpdate{overflow-wrap:anywhere;margin:0;font-size:13px;line-height:20px}.KSbXkW_taskInput{color:var(--dsw-alias-label-primary)}.KSbXkW_taskUpdate{color:var(--dsw-alias-label-secondary);margin-top:4px}.KSbXkW_taskDetails{border-left:1px solid var(--dsw-alias-border-l2);margin:9px 0 0 3px;padding:2px 0 2px 16px}.KSbXkW_taskActions{align-items:center;gap:14px;margin-top:7px;display:flex}.KSbXkW_taskLink{color:var(--dsw-alias-state-business-primary);cursor:pointer;font:inherit;background:0 0;border:0;padding:0;font-size:12px;line-height:18px}.KSbXkW_taskLink:hover:not(:disabled){text-underline-position:from-font;text-decoration:underline}.KSbXkW_taskLink:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.KSbXkW_taskCancel{color:var(--dsw-alias-state-error-primary)}";
|
|
205
|
+
const tagId$1 = "@flowingspring/dsh-voco/VoiceNodeViews.module.css";
|
|
206
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
|
|
207
|
+
const tag = document.createElement("style");
|
|
208
|
+
tag.dataset.plugin = "@flowingspring/dsh-voco";
|
|
209
|
+
tag.dataset.pluginCss = tagId$1;
|
|
210
|
+
tag.textContent = css$1;
|
|
211
|
+
document.head.appendChild(tag);
|
|
212
|
+
}
|
|
213
|
+
var VoiceNodeViews_module_css_default = {
|
|
214
|
+
"bubble": "KSbXkW_bubble",
|
|
215
|
+
"taskCancel": "KSbXkW_taskCancel",
|
|
216
|
+
"meta": "KSbXkW_meta",
|
|
217
|
+
"voiceBadge": "KSbXkW_voiceBadge",
|
|
218
|
+
"taskInput": "KSbXkW_taskInput",
|
|
219
|
+
"taskSummary": "KSbXkW_taskSummary",
|
|
220
|
+
"taskDetails": "KSbXkW_taskDetails",
|
|
221
|
+
"taskStatus": "KSbXkW_taskStatus",
|
|
222
|
+
"taskDot": "KSbXkW_taskDot",
|
|
223
|
+
"taskTitle": "KSbXkW_taskTitle",
|
|
224
|
+
"taskChevron": "KSbXkW_taskChevron",
|
|
225
|
+
"taskActions": "KSbXkW_taskActions",
|
|
226
|
+
"miniWave": "KSbXkW_miniWave",
|
|
227
|
+
"taskCard": "KSbXkW_taskCard",
|
|
228
|
+
"taskLink": "KSbXkW_taskLink",
|
|
229
|
+
"taskUpdate": "KSbXkW_taskUpdate",
|
|
230
|
+
"utterance": "KSbXkW_utterance"
|
|
231
|
+
};
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region ../ui-voice/src/client/VoiceNodeViews.tsx
|
|
234
|
+
/** Render a durable Voice utterance while replacing its unsettled text with live WebSocket deltas. */
|
|
235
|
+
function VoiceUtteranceView({ node, sessionId, useVoice, t }) {
|
|
236
|
+
const liveText = useVoice((snapshot) => snapshot.sessionId === sessionId ? snapshot.textById[String(node.data.utteranceId)]?.text : void 0);
|
|
237
|
+
const text = node.data.text ?? liveText ?? (node.data.role === "user" ? t("utterance.user.pending") : t("utterance.assistant.pending"));
|
|
238
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
|
|
239
|
+
className: VoiceNodeViews_module_css_default.utterance,
|
|
240
|
+
"data-voice-utterance": true,
|
|
241
|
+
"data-role": node.data.role,
|
|
242
|
+
"data-state": node.data.state,
|
|
243
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
244
|
+
className: VoiceNodeViews_module_css_default.bubble,
|
|
245
|
+
children: text
|
|
246
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
247
|
+
className: VoiceNodeViews_module_css_default.meta,
|
|
248
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
249
|
+
className: VoiceNodeViews_module_css_default.voiceBadge,
|
|
250
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
251
|
+
className: VoiceNodeViews_module_css_default.miniWave,
|
|
252
|
+
"aria-hidden": true,
|
|
253
|
+
children: [
|
|
254
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}),
|
|
255
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}),
|
|
256
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {})
|
|
257
|
+
]
|
|
258
|
+
}), t("badge.voice")]
|
|
259
|
+
}), node.data.state === "interrupted" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("utterance.interrupted") })]
|
|
260
|
+
})]
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const STATUS_KEYS = {
|
|
264
|
+
accepted: "task.status.accepted",
|
|
265
|
+
queued: "task.status.queued",
|
|
266
|
+
running: "task.status.running",
|
|
267
|
+
"waiting-user": "task.status.waiting-user",
|
|
268
|
+
completed: "task.status.completed",
|
|
269
|
+
failed: "task.status.failed",
|
|
270
|
+
cancelled: "task.status.cancelled",
|
|
271
|
+
interrupted: "task.status.interrupted"
|
|
272
|
+
};
|
|
273
|
+
/** Render the task link from a Voice Session to its bound background Agent Session. */
|
|
274
|
+
function VoiceDelegationView({ node, sessionId, useSessions, useVoice, openSession, cancelTask, t }) {
|
|
275
|
+
const [expanded, setExpanded] = (0, react.useState)(false);
|
|
276
|
+
const navigable = useSessions((sessions) => sessions.ids.includes(node.data.taskSessionId));
|
|
277
|
+
const cancellable = useVoice((voice) => voice.sessionId === sessionId && voice.state !== "off" && voice.state !== "error") && !isTerminalTaskStatus(node.data.status);
|
|
278
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
|
|
279
|
+
className: VoiceNodeViews_module_css_default.taskCard,
|
|
280
|
+
"data-voice-delegation": true,
|
|
281
|
+
"data-status": node.data.status,
|
|
282
|
+
"data-expanded": expanded,
|
|
283
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
284
|
+
type: "button",
|
|
285
|
+
className: VoiceNodeViews_module_css_default.taskSummary,
|
|
286
|
+
"aria-expanded": expanded,
|
|
287
|
+
"aria-label": expanded ? t("task.collapse") : t("task.expand"),
|
|
288
|
+
onClick: () => {
|
|
289
|
+
setExpanded((value) => !value);
|
|
290
|
+
},
|
|
291
|
+
children: [
|
|
292
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
293
|
+
className: VoiceNodeViews_module_css_default.taskDot,
|
|
294
|
+
"aria-hidden": true
|
|
295
|
+
}),
|
|
296
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
297
|
+
className: VoiceNodeViews_module_css_default.taskTitle,
|
|
298
|
+
children: t("task.title")
|
|
299
|
+
}),
|
|
300
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
301
|
+
className: VoiceNodeViews_module_css_default.taskStatus,
|
|
302
|
+
children: t(STATUS_KEYS[node.data.status])
|
|
303
|
+
}),
|
|
304
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
305
|
+
className: VoiceNodeViews_module_css_default.taskChevron,
|
|
306
|
+
"aria-hidden": true
|
|
307
|
+
})
|
|
308
|
+
]
|
|
309
|
+
}), expanded && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
310
|
+
className: VoiceNodeViews_module_css_default.taskDetails,
|
|
311
|
+
children: [
|
|
312
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
313
|
+
className: VoiceNodeViews_module_css_default.taskInput,
|
|
314
|
+
children: node.data.input
|
|
315
|
+
}),
|
|
316
|
+
node.data.update !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
317
|
+
className: VoiceNodeViews_module_css_default.taskUpdate,
|
|
318
|
+
children: node.data.update
|
|
319
|
+
}),
|
|
320
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
321
|
+
className: VoiceNodeViews_module_css_default.taskActions,
|
|
322
|
+
children: [cancellable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
323
|
+
type: "button",
|
|
324
|
+
className: `${VoiceNodeViews_module_css_default.taskLink} ${VoiceNodeViews_module_css_default.taskCancel}`,
|
|
325
|
+
onClick: () => {
|
|
326
|
+
cancelTask(String(node.data.taskId));
|
|
327
|
+
},
|
|
328
|
+
children: t("task.cancel")
|
|
329
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
330
|
+
type: "button",
|
|
331
|
+
className: VoiceNodeViews_module_css_default.taskLink,
|
|
332
|
+
disabled: !navigable,
|
|
333
|
+
onClick: () => {
|
|
334
|
+
openSession(node.data.taskSessionId);
|
|
335
|
+
},
|
|
336
|
+
children: t("task.open")
|
|
337
|
+
})]
|
|
338
|
+
})
|
|
339
|
+
]
|
|
340
|
+
})]
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function isTerminalTaskStatus(status) {
|
|
344
|
+
return status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region \0dsh-css:D:\QUAN\DevCode\Python\Project\codex-voice\dsh-voco\packages\ui-voice\src\client\VoiceOverlay.module.css.mjs
|
|
348
|
+
const css = ".AAuDqa_root{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);max-width:min(520px,100vw - 36px);min-height:36px;box-shadow:var(--dsw-shadow-lv2);color:var(--dsw-alias-label-primary);pointer-events:auto;border-radius:12px;align-items:center;gap:10px;padding:4px 6px 4px 12px;font-size:12px;line-height:18px;display:flex;position:absolute;bottom:18px;right:18px}.AAuDqa_wave{width:18px;height:18px;color:var(--dsw-alias-state-business-primary);flex:none;justify-content:center;align-items:center;gap:2px;display:inline-flex}.AAuDqa_wave i{background:currentColor;border-radius:1px;width:2px;height:5px;animation:.9s ease-in-out infinite alternate AAuDqa_voice-wave}.AAuDqa_wave i:nth-child(2){height:11px;animation-delay:-300ms}.AAuDqa_wave i:nth-child(3){height:8px;animation-delay:-600ms}.AAuDqa_wave i:nth-child(4){height:4px;animation-delay:-150ms}.AAuDqa_root[data-state=connecting] .AAuDqa_wave,.AAuDqa_root[data-state=error] .AAuDqa_wave{opacity:.55}.AAuDqa_root[data-state=error] .AAuDqa_wave{color:var(--dsw-alias-state-error-primary)}.AAuDqa_status{text-overflow:ellipsis;white-space:nowrap;flex:auto;min-width:0;overflow:hidden}.AAuDqa_link,.AAuDqa_stop{min-height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;background:0 0;border:0;border-radius:7px;flex:none;padding:0 8px}.AAuDqa_link:hover:not(:disabled),.AAuDqa_stop:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.AAuDqa_link:disabled{cursor:default;opacity:.45}.AAuDqa_stop{color:var(--dsw-alias-state-error-primary)}@keyframes AAuDqa_voice-wave{to{transform:scaleY(.55)}}@media (prefers-reduced-motion:reduce){.AAuDqa_wave i{animation:none}}";
|
|
349
|
+
const tagId = "@flowingspring/dsh-voco/VoiceOverlay.module.css";
|
|
350
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
351
|
+
const tag = document.createElement("style");
|
|
352
|
+
tag.dataset.plugin = "@flowingspring/dsh-voco";
|
|
353
|
+
tag.dataset.pluginCss = tagId;
|
|
354
|
+
tag.textContent = css;
|
|
355
|
+
document.head.appendChild(tag);
|
|
356
|
+
}
|
|
357
|
+
var VoiceOverlay_module_css_default = {
|
|
358
|
+
"voice-wave": "AAuDqa_voice-wave",
|
|
359
|
+
"stop": "AAuDqa_stop",
|
|
360
|
+
"status": "AAuDqa_status",
|
|
361
|
+
"wave": "AAuDqa_wave",
|
|
362
|
+
"root": "AAuDqa_root",
|
|
363
|
+
"link": "AAuDqa_link"
|
|
364
|
+
};
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region ../ui-voice/src/client/VoiceOverlay.tsx
|
|
367
|
+
const STATE_KEYS = {
|
|
368
|
+
connecting: "overlay.connecting",
|
|
369
|
+
listening: "overlay.listening",
|
|
370
|
+
speaking: "overlay.speaking",
|
|
371
|
+
error: "overlay.error"
|
|
372
|
+
};
|
|
373
|
+
/** Keep the active Voice Session controllable while another Session is selected. */
|
|
374
|
+
function VoiceOverlay({ useVoice, useSessions, openVoiceSession, stopVoice, t }) {
|
|
375
|
+
const voice = useVoice((snapshot) => snapshot);
|
|
376
|
+
const navigable = useSessions((sessions) => voice.sessionId !== void 0 && sessions.ids.includes(voice.sessionId));
|
|
377
|
+
if (voice.state === "off") return null;
|
|
378
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("aside", {
|
|
379
|
+
className: VoiceOverlay_module_css_default.root,
|
|
380
|
+
"data-voice-overlay": true,
|
|
381
|
+
"data-state": voice.state,
|
|
382
|
+
"aria-live": "polite",
|
|
383
|
+
children: [
|
|
384
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
385
|
+
className: VoiceOverlay_module_css_default.wave,
|
|
386
|
+
"aria-hidden": true,
|
|
387
|
+
children: [
|
|
388
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}),
|
|
389
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}),
|
|
390
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}),
|
|
391
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {})
|
|
392
|
+
]
|
|
393
|
+
}),
|
|
394
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
395
|
+
className: VoiceOverlay_module_css_default.status,
|
|
396
|
+
children: t(STATE_KEYS[voice.state])
|
|
397
|
+
}),
|
|
398
|
+
voice.sessionId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
399
|
+
type: "button",
|
|
400
|
+
className: VoiceOverlay_module_css_default.link,
|
|
401
|
+
disabled: !navigable,
|
|
402
|
+
onClick: () => {
|
|
403
|
+
openVoiceSession(voice.sessionId);
|
|
404
|
+
},
|
|
405
|
+
children: t("overlay.return")
|
|
406
|
+
}),
|
|
407
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
408
|
+
type: "button",
|
|
409
|
+
className: VoiceOverlay_module_css_default.stop,
|
|
410
|
+
onClick: () => {
|
|
411
|
+
stopVoice();
|
|
412
|
+
},
|
|
413
|
+
children: t("overlay.stop")
|
|
414
|
+
})
|
|
415
|
+
]
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region ../ui-voice/src/client/voice-controller.ts
|
|
420
|
+
const INITIAL_SNAPSHOT = {
|
|
421
|
+
state: "off",
|
|
422
|
+
textById: {}
|
|
423
|
+
};
|
|
424
|
+
/** Own the single browser microphone, provider WebSocket, playback queue, and live transcript feed. */
|
|
425
|
+
var VoiceController = class {
|
|
426
|
+
snapshot = INITIAL_SNAPSHOT;
|
|
427
|
+
listeners = /* @__PURE__ */ new Set();
|
|
428
|
+
transport;
|
|
429
|
+
openingTransport;
|
|
430
|
+
openingAbort;
|
|
431
|
+
generation = 0;
|
|
432
|
+
frame;
|
|
433
|
+
pendingText = [];
|
|
434
|
+
/** Return the identity-stable snapshot until a voice fact changes. */
|
|
435
|
+
getSnapshot = () => this.snapshot;
|
|
436
|
+
/** Subscribe to state and frame-coalesced transcript changes. */
|
|
437
|
+
subscribe = (listener) => {
|
|
438
|
+
this.listeners.add(listener);
|
|
439
|
+
return () => {
|
|
440
|
+
this.listeners.delete(listener);
|
|
441
|
+
};
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* Open one voice transport and make it the root-owned active session.
|
|
445
|
+
* @param sessionId - durable voice session to connect.
|
|
446
|
+
*/
|
|
447
|
+
async start(sessionId) {
|
|
448
|
+
const generation = ++this.generation;
|
|
449
|
+
this.pendingText.splice(0);
|
|
450
|
+
const contexts = createAudioContexts();
|
|
451
|
+
await this.closeTransport();
|
|
452
|
+
if (generation !== this.generation) {
|
|
453
|
+
await closeAudioContexts(contexts);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const openingAbort = new AbortController();
|
|
457
|
+
this.openingAbort = openingAbort;
|
|
458
|
+
this.publish({
|
|
459
|
+
state: "connecting",
|
|
460
|
+
sessionId,
|
|
461
|
+
textById: {}
|
|
462
|
+
});
|
|
463
|
+
try {
|
|
464
|
+
const transport = await openVoiceTransport(String(sessionId), {
|
|
465
|
+
onBinary: () => {
|
|
466
|
+
this.setState("speaking");
|
|
467
|
+
},
|
|
468
|
+
onEvent: (event) => {
|
|
469
|
+
this.handleEvent(event);
|
|
470
|
+
},
|
|
471
|
+
onPlaybackEnded: () => {
|
|
472
|
+
this.setState("listening");
|
|
473
|
+
},
|
|
474
|
+
onUnexpectedClose: () => {
|
|
475
|
+
this.handleUnexpectedClose();
|
|
476
|
+
}
|
|
477
|
+
}, openingAbort.signal, (opening) => {
|
|
478
|
+
if (generation === this.generation) this.openingTransport = opening;
|
|
479
|
+
else opening.close();
|
|
480
|
+
}, contexts);
|
|
481
|
+
if (generation !== this.generation) {
|
|
482
|
+
await transport.close();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.openingTransport = void 0;
|
|
486
|
+
this.transport = transport;
|
|
487
|
+
for (const text of this.pendingText.splice(0)) transport.submitText(text);
|
|
488
|
+
this.setState("listening");
|
|
489
|
+
} catch (cause) {
|
|
490
|
+
if (generation !== this.generation) return;
|
|
491
|
+
this.setState("error");
|
|
492
|
+
throw cause;
|
|
493
|
+
} finally {
|
|
494
|
+
if (this.openingAbort === openingAbort) this.openingAbort = void 0;
|
|
495
|
+
if (generation === this.generation) this.openingTransport = void 0;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** Retry the failed active Voice Session, when one was created successfully. */
|
|
499
|
+
async retry() {
|
|
500
|
+
const sessionId = this.snapshot.sessionId;
|
|
501
|
+
if (sessionId === void 0) throw new Error("voice retry requires an active session");
|
|
502
|
+
await this.start(sessionId);
|
|
503
|
+
}
|
|
504
|
+
/** Cancel the exact active background task through the attached Voice transport. */
|
|
505
|
+
cancelTask(taskId) {
|
|
506
|
+
if (taskId === "") throw new Error("voice task id is required");
|
|
507
|
+
if (this.transport === void 0) throw new Error("voice task cancellation requires an active connection");
|
|
508
|
+
this.transport.cancelTask(taskId);
|
|
509
|
+
}
|
|
510
|
+
/** Submit composer text as a voice turn, queueing it while the transport connects. */
|
|
511
|
+
submitText(text) {
|
|
512
|
+
const value = text.trim();
|
|
513
|
+
if (value === "") throw new Error("voice text submission requires non-empty text");
|
|
514
|
+
if (this.snapshot.state === "off" || this.snapshot.state === "error" || this.snapshot.sessionId === void 0) throw new Error("voice text submission requires an active connection");
|
|
515
|
+
if (this.transport === void 0) this.pendingText.push(value);
|
|
516
|
+
else this.transport.submitText(value);
|
|
517
|
+
}
|
|
518
|
+
/** Stop capture/playback and await owned browser-resource teardown. */
|
|
519
|
+
async stop() {
|
|
520
|
+
++this.generation;
|
|
521
|
+
this.openingAbort?.abort();
|
|
522
|
+
this.openingAbort = void 0;
|
|
523
|
+
this.publish(INITIAL_SNAPSHOT);
|
|
524
|
+
this.pendingText.splice(0);
|
|
525
|
+
await this.closeTransport();
|
|
526
|
+
}
|
|
527
|
+
async closeTransport() {
|
|
528
|
+
const transports = /* @__PURE__ */ new Set();
|
|
529
|
+
if (this.transport !== void 0) transports.add(this.transport);
|
|
530
|
+
if (this.openingTransport !== void 0) transports.add(this.openingTransport);
|
|
531
|
+
this.transport = void 0;
|
|
532
|
+
this.openingTransport = void 0;
|
|
533
|
+
if (this.frame !== void 0) {
|
|
534
|
+
cancelAnimationFrame(this.frame);
|
|
535
|
+
this.frame = void 0;
|
|
536
|
+
}
|
|
537
|
+
const settled = await Promise.allSettled([...transports].map((transport) => transport.close()));
|
|
538
|
+
const failures = [];
|
|
539
|
+
for (const result of settled) if (result.status === "rejected") failures.push(result.reason);
|
|
540
|
+
if (failures.length > 0) throw new AggregateError(failures, "voice transport cleanup failed");
|
|
541
|
+
}
|
|
542
|
+
handleUnexpectedClose() {
|
|
543
|
+
this.openingAbort?.abort();
|
|
544
|
+
this.setState("error");
|
|
545
|
+
this.closeTransport().catch((cause) => {
|
|
546
|
+
console.error("voice transport cleanup failed:", cause);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
setState(state) {
|
|
550
|
+
if (this.snapshot.state === state) return;
|
|
551
|
+
this.publish({
|
|
552
|
+
...this.snapshot,
|
|
553
|
+
state
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
setText(id, text, cadence) {
|
|
557
|
+
if (id === "") return;
|
|
558
|
+
if (this.snapshot.textById[id]?.text === text) return;
|
|
559
|
+
this.snapshot = {
|
|
560
|
+
...this.snapshot,
|
|
561
|
+
textById: {
|
|
562
|
+
...this.snapshot.textById,
|
|
563
|
+
[id]: { text }
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
if (cadence === "immediate") this.notify();
|
|
567
|
+
else this.notifyOnFrame();
|
|
568
|
+
}
|
|
569
|
+
appendText(id, delta) {
|
|
570
|
+
if (id === "" || delta === "") return;
|
|
571
|
+
this.setText(id, `${this.snapshot.textById[id]?.text ?? ""}${delta}`, "animation-frame");
|
|
572
|
+
}
|
|
573
|
+
handleEvent(input) {
|
|
574
|
+
const event = recordOf(input);
|
|
575
|
+
if (event === void 0) return;
|
|
576
|
+
const type = stringOf(event.type);
|
|
577
|
+
if (type === "transcription.started") {
|
|
578
|
+
this.setText(stringOf(event.utteranceId), "", "immediate");
|
|
579
|
+
this.setState("listening");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (type === "transcription.updated") {
|
|
583
|
+
this.setText(stringOf(event.utteranceId), stringOf(event.text), "animation-frame");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (type === "transcription.completed") {
|
|
587
|
+
this.setText(stringOf(event.utteranceId), stringOf(event.text), "immediate");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (type === "output_text.started") {
|
|
591
|
+
this.setText(stringOf(event.utteranceId), "", "immediate");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (type === "output_text.delta") {
|
|
595
|
+
this.appendText(stringOf(event.utteranceId), stringOf(event.text));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (type === "output_text.done") {
|
|
599
|
+
const text = stringOf(event.text);
|
|
600
|
+
if (text !== "") this.setText(stringOf(event.utteranceId), text, "immediate");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (type === "output_audio.started") {
|
|
604
|
+
this.setState("speaking");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (type === "response.interrupted") {
|
|
608
|
+
this.setState("listening");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (type === "error") this.handleUnexpectedClose();
|
|
612
|
+
}
|
|
613
|
+
publish(snapshot) {
|
|
614
|
+
if (this.frame !== void 0) {
|
|
615
|
+
cancelAnimationFrame(this.frame);
|
|
616
|
+
this.frame = void 0;
|
|
617
|
+
}
|
|
618
|
+
this.snapshot = snapshot;
|
|
619
|
+
this.notify();
|
|
620
|
+
}
|
|
621
|
+
notifyOnFrame() {
|
|
622
|
+
this.frame ??= requestAnimationFrame(() => {
|
|
623
|
+
this.frame = void 0;
|
|
624
|
+
this.notify();
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
notify() {
|
|
628
|
+
for (const listener of this.listeners) try {
|
|
629
|
+
listener();
|
|
630
|
+
} catch (cause) {
|
|
631
|
+
console.error("voice snapshot listener failed:", cause);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
function recordOf(value) {
|
|
636
|
+
return value !== null && typeof value === "object" ? value : void 0;
|
|
637
|
+
}
|
|
638
|
+
function stringOf(value) {
|
|
639
|
+
return typeof value === "string" ? value : "";
|
|
640
|
+
}
|
|
641
|
+
async function openVoiceTransport(sessionId, handlers, signal, onOpening, contexts) {
|
|
642
|
+
const { input: inputContext, output: outputContext } = contexts;
|
|
643
|
+
const socket = new WebSocket(voiceUrl(sessionId));
|
|
644
|
+
const workletUrl = URL.createObjectURL(new Blob([CAPTURE_WORKLET], { type: "text/javascript" }));
|
|
645
|
+
let stream;
|
|
646
|
+
let source;
|
|
647
|
+
let processor;
|
|
648
|
+
let silent;
|
|
649
|
+
let stopped = false;
|
|
650
|
+
let socketClosed = false;
|
|
651
|
+
let ready = false;
|
|
652
|
+
let outputDone = false;
|
|
653
|
+
let encodedPending = 0;
|
|
654
|
+
let playbackGeneration = 0;
|
|
655
|
+
let encodedQueue = Promise.resolve();
|
|
656
|
+
let inputSampleRate = 16e3;
|
|
657
|
+
let outputSampleRate = 24e3;
|
|
658
|
+
let audioFormat = "pcm_s16le";
|
|
659
|
+
let playAt = outputContext.currentTime;
|
|
660
|
+
const chunks = [];
|
|
661
|
+
const playing = /* @__PURE__ */ new Set();
|
|
662
|
+
const transport = {
|
|
663
|
+
cancelTask: (taskId) => {
|
|
664
|
+
if (socket.readyState !== WebSocket.OPEN) throw new Error("voice transport is not open");
|
|
665
|
+
socket.send(JSON.stringify({
|
|
666
|
+
type: "task.cancel",
|
|
667
|
+
taskId
|
|
668
|
+
}));
|
|
669
|
+
},
|
|
670
|
+
submitText: (text) => {
|
|
671
|
+
if (socket.readyState !== WebSocket.OPEN) throw new Error("voice transport is not open");
|
|
672
|
+
socket.send(JSON.stringify({
|
|
673
|
+
type: "text.submit",
|
|
674
|
+
text
|
|
675
|
+
}));
|
|
676
|
+
},
|
|
677
|
+
close: async () => {
|
|
678
|
+
/* v8 ignore next -- controller ownership closes a transport once; the guard protects browser close races. */
|
|
679
|
+
if (stopped) return;
|
|
680
|
+
stopped = true;
|
|
681
|
+
if (!socketClosed && socket.readyState === WebSocket.OPEN) try {
|
|
682
|
+
socket.send(JSON.stringify({ type: "session.close" }));
|
|
683
|
+
} catch {}
|
|
684
|
+
socket.removeEventListener("message", onMessage);
|
|
685
|
+
socket.removeEventListener("close", onClose);
|
|
686
|
+
stopPlayback();
|
|
687
|
+
source?.disconnect();
|
|
688
|
+
processor?.disconnect();
|
|
689
|
+
silent?.disconnect();
|
|
690
|
+
processor?.port.close();
|
|
691
|
+
for (const track of stream?.getTracks() ?? []) track.stop();
|
|
692
|
+
socket.close();
|
|
693
|
+
await Promise.allSettled([inputContext.close(), outputContext.close()]);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
const stopPlayback = () => {
|
|
697
|
+
playbackGeneration += 1;
|
|
698
|
+
encodedPending = 0;
|
|
699
|
+
encodedQueue = Promise.resolve();
|
|
700
|
+
for (const node of playing) try {
|
|
701
|
+
node.stop();
|
|
702
|
+
} catch {}
|
|
703
|
+
playing.clear();
|
|
704
|
+
playAt = outputContext.currentTime;
|
|
705
|
+
outputDone = false;
|
|
706
|
+
};
|
|
707
|
+
const notifyPlaybackEnded = () => {
|
|
708
|
+
if (outputDone && encodedPending === 0 && playing.size === 0 && socket.readyState === WebSocket.OPEN) {
|
|
709
|
+
socket.send(JSON.stringify({ type: "playback.ended" }));
|
|
710
|
+
outputDone = false;
|
|
711
|
+
handlers.onPlaybackEnded();
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
const onMessage = (message) => {
|
|
715
|
+
if (!ready) return;
|
|
716
|
+
if (message.data instanceof ArrayBuffer) {
|
|
717
|
+
if (audioFormat === "audio_mpeg") {
|
|
718
|
+
const generation = playbackGeneration;
|
|
719
|
+
encodedPending += 1;
|
|
720
|
+
encodedQueue = encodedQueue.catch(() => {}).then(() => scheduleEncoded(outputContext, message.data, playing, notifyPlaybackEnded, (value) => {
|
|
721
|
+
playAt = value;
|
|
722
|
+
}, () => playAt, () => generation === playbackGeneration && !stopped)).catch((error) => {
|
|
723
|
+
if (generation === playbackGeneration && !stopped) handlers.onEvent({
|
|
724
|
+
type: "error",
|
|
725
|
+
message: `audio decode failed: ${String(error)}`
|
|
726
|
+
});
|
|
727
|
+
}).finally(() => {
|
|
728
|
+
if (generation === playbackGeneration) {
|
|
729
|
+
encodedPending -= 1;
|
|
730
|
+
notifyPlaybackEnded();
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
} else schedulePcm(outputContext, message.data, outputSampleRate, playing, notifyPlaybackEnded, (value) => {
|
|
734
|
+
playAt = value;
|
|
735
|
+
}, playAt);
|
|
736
|
+
handlers.onBinary(message.data);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
let event;
|
|
740
|
+
try {
|
|
741
|
+
event = JSON.parse(String(message.data));
|
|
742
|
+
} catch {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const record = recordOf(event);
|
|
746
|
+
if (record?.type === "transcription.started" || record?.type === "response.interrupted") stopPlayback();
|
|
747
|
+
if (record?.type === "output_audio.done") {
|
|
748
|
+
outputDone = true;
|
|
749
|
+
notifyPlaybackEnded();
|
|
750
|
+
}
|
|
751
|
+
handlers.onEvent(event);
|
|
752
|
+
};
|
|
753
|
+
const onClose = () => {
|
|
754
|
+
socketClosed = true;
|
|
755
|
+
handlers.onUnexpectedClose();
|
|
756
|
+
};
|
|
757
|
+
socket.binaryType = "arraybuffer";
|
|
758
|
+
onOpening(transport);
|
|
759
|
+
try {
|
|
760
|
+
await socketOpened(socket, signal);
|
|
761
|
+
socket.addEventListener("message", onMessage);
|
|
762
|
+
socket.addEventListener("close", onClose);
|
|
763
|
+
const readyEvent = await socketReady(socket, signal);
|
|
764
|
+
inputSampleRate = readyEvent.audio.inputSampleRate;
|
|
765
|
+
outputSampleRate = readyEvent.audio.outputSampleRate;
|
|
766
|
+
audioFormat = readyEvent.audio.format;
|
|
767
|
+
ready = true;
|
|
768
|
+
const captured = await microphone(signal);
|
|
769
|
+
if (openingStopped(signal, stopped)) {
|
|
770
|
+
for (const track of captured.getTracks()) track.stop();
|
|
771
|
+
throw new Error("voice connection opening aborted");
|
|
772
|
+
}
|
|
773
|
+
stream = captured;
|
|
774
|
+
await abortable(inputContext.audioWorklet.addModule(workletUrl), signal);
|
|
775
|
+
if (openingStopped(signal, stopped)) throw new Error("voice connection opening aborted");
|
|
776
|
+
source = inputContext.createMediaStreamSource(stream);
|
|
777
|
+
processor = new AudioWorkletNode(inputContext, "dsh-pcm-capture");
|
|
778
|
+
silent = inputContext.createGain();
|
|
779
|
+
silent.gain.value = 0;
|
|
780
|
+
processor.port.onmessage = (event) => {
|
|
781
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
782
|
+
chunks.push(...resample(event.data, inputContext.sampleRate, inputSampleRate));
|
|
783
|
+
const frameSize = Math.max(1, Math.round(inputSampleRate / 5));
|
|
784
|
+
while (chunks.length >= frameSize) {
|
|
785
|
+
const part = chunks.splice(0, frameSize);
|
|
786
|
+
const pcm = new Int16Array(part.length);
|
|
787
|
+
for (const [index, sample] of part.entries()) pcm[index] = Math.round(Math.max(-1, Math.min(1, sample)) * 32767);
|
|
788
|
+
socket.send(pcm.buffer);
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
source.connect(processor);
|
|
792
|
+
processor.connect(silent);
|
|
793
|
+
silent.connect(inputContext.destination);
|
|
794
|
+
} catch (cause) {
|
|
795
|
+
await transport.close();
|
|
796
|
+
throw cause;
|
|
797
|
+
} finally {
|
|
798
|
+
URL.revokeObjectURL(workletUrl);
|
|
799
|
+
}
|
|
800
|
+
return transport;
|
|
801
|
+
}
|
|
802
|
+
function createAudioContexts() {
|
|
803
|
+
const contexts = {
|
|
804
|
+
input: new AudioContext(),
|
|
805
|
+
output: new AudioContext()
|
|
806
|
+
};
|
|
807
|
+
contexts.input.resume().catch(() => {});
|
|
808
|
+
contexts.output.resume().catch(() => {});
|
|
809
|
+
return contexts;
|
|
810
|
+
}
|
|
811
|
+
async function closeAudioContexts(contexts) {
|
|
812
|
+
await Promise.allSettled([contexts.input.close(), contexts.output.close()]);
|
|
813
|
+
}
|
|
814
|
+
function microphone(signal) {
|
|
815
|
+
if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("voice connection opening aborted"));
|
|
816
|
+
return new Promise((resolve, reject) => {
|
|
817
|
+
let cancelled = false;
|
|
818
|
+
const cleanup = () => {
|
|
819
|
+
signal.removeEventListener("abort", aborted);
|
|
820
|
+
};
|
|
821
|
+
const aborted = () => {
|
|
822
|
+
cancelled = true;
|
|
823
|
+
cleanup();
|
|
824
|
+
reject(/* @__PURE__ */ new Error("voice connection opening aborted"));
|
|
825
|
+
};
|
|
826
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
827
|
+
let request;
|
|
828
|
+
try {
|
|
829
|
+
request = navigator.mediaDevices.getUserMedia({ audio: {
|
|
830
|
+
echoCancellation: true,
|
|
831
|
+
noiseSuppression: true,
|
|
832
|
+
autoGainControl: true
|
|
833
|
+
} });
|
|
834
|
+
} catch (cause) {
|
|
835
|
+
cleanup();
|
|
836
|
+
reject(errorOf(cause));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
request.then((value) => {
|
|
840
|
+
cleanup();
|
|
841
|
+
if (cancelled || signal.aborted) {
|
|
842
|
+
for (const track of value.getTracks()) track.stop();
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
resolve(value);
|
|
846
|
+
}, (cause) => {
|
|
847
|
+
cleanup();
|
|
848
|
+
if (!cancelled) reject(errorOf(cause));
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
function abortable(operation, signal) {
|
|
853
|
+
if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("voice connection opening aborted"));
|
|
854
|
+
return new Promise((resolve, reject) => {
|
|
855
|
+
let cancelled = false;
|
|
856
|
+
const cleanup = () => {
|
|
857
|
+
signal.removeEventListener("abort", aborted);
|
|
858
|
+
};
|
|
859
|
+
const aborted = () => {
|
|
860
|
+
cleanup();
|
|
861
|
+
cancelled = true;
|
|
862
|
+
reject(/* @__PURE__ */ new Error("voice connection opening aborted"));
|
|
863
|
+
};
|
|
864
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
865
|
+
operation.then((value) => {
|
|
866
|
+
cleanup();
|
|
867
|
+
if (!cancelled) resolve(value);
|
|
868
|
+
}, (cause) => {
|
|
869
|
+
cleanup();
|
|
870
|
+
if (!cancelled) reject(errorOf(cause));
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
function socketOpened(socket, signal) {
|
|
875
|
+
return new Promise((resolve, reject) => {
|
|
876
|
+
const cleanup = () => {
|
|
877
|
+
socket.removeEventListener("open", opened);
|
|
878
|
+
socket.removeEventListener("error", failed);
|
|
879
|
+
signal.removeEventListener("abort", aborted);
|
|
880
|
+
};
|
|
881
|
+
const opened = () => {
|
|
882
|
+
cleanup();
|
|
883
|
+
resolve();
|
|
884
|
+
};
|
|
885
|
+
const failed = () => {
|
|
886
|
+
cleanup();
|
|
887
|
+
reject(/* @__PURE__ */ new Error("voice websocket failed"));
|
|
888
|
+
};
|
|
889
|
+
const aborted = () => {
|
|
890
|
+
cleanup();
|
|
891
|
+
reject(/* @__PURE__ */ new Error("voice connection opening aborted"));
|
|
892
|
+
};
|
|
893
|
+
if (signal.aborted) {
|
|
894
|
+
aborted();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
socket.addEventListener("open", opened, { once: true });
|
|
898
|
+
socket.addEventListener("error", failed, { once: true });
|
|
899
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
function socketReady(socket, signal) {
|
|
903
|
+
return new Promise((resolve, reject) => {
|
|
904
|
+
const cleanup = () => {
|
|
905
|
+
socket.removeEventListener("message", received);
|
|
906
|
+
socket.removeEventListener("error", failed);
|
|
907
|
+
socket.removeEventListener("close", closed);
|
|
908
|
+
signal.removeEventListener("abort", aborted);
|
|
909
|
+
};
|
|
910
|
+
const settle = (operation) => {
|
|
911
|
+
cleanup();
|
|
912
|
+
operation();
|
|
913
|
+
};
|
|
914
|
+
const received = (message) => {
|
|
915
|
+
try {
|
|
916
|
+
const value = message.data instanceof ArrayBuffer ? void 0 : JSON.parse(String(message.data));
|
|
917
|
+
settle(() => {
|
|
918
|
+
resolve(parseReady(value));
|
|
919
|
+
});
|
|
920
|
+
} catch (cause) {
|
|
921
|
+
settle(() => {
|
|
922
|
+
reject(errorOf(cause));
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
const failed = () => {
|
|
927
|
+
settle(() => {
|
|
928
|
+
reject(/* @__PURE__ */ new Error("voice websocket failed before ready"));
|
|
929
|
+
});
|
|
930
|
+
};
|
|
931
|
+
const closed = () => {
|
|
932
|
+
settle(() => {
|
|
933
|
+
reject(/* @__PURE__ */ new Error("voice websocket closed before ready"));
|
|
934
|
+
});
|
|
935
|
+
};
|
|
936
|
+
const aborted = () => {
|
|
937
|
+
settle(() => {
|
|
938
|
+
reject(/* @__PURE__ */ new Error("voice connection opening aborted"));
|
|
939
|
+
});
|
|
940
|
+
};
|
|
941
|
+
if (signal.aborted) {
|
|
942
|
+
aborted();
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
946
|
+
closed();
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
socket.addEventListener("message", received);
|
|
950
|
+
socket.addEventListener("error", failed, { once: true });
|
|
951
|
+
socket.addEventListener("close", closed, { once: true });
|
|
952
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
function openingStopped(signal, stopped) {
|
|
956
|
+
return signal.aborted || stopped;
|
|
957
|
+
}
|
|
958
|
+
function errorOf(cause) {
|
|
959
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
960
|
+
}
|
|
961
|
+
function parseReady(value) {
|
|
962
|
+
const event = recordOf(value);
|
|
963
|
+
if (event?.type === "error") {
|
|
964
|
+
const message = stringOf(event.message);
|
|
965
|
+
throw new Error(message === "" ? "voice setup failed before ready" : message);
|
|
966
|
+
}
|
|
967
|
+
if (event?.type !== "ready") throw new Error("voice websocket expected a ready event");
|
|
968
|
+
const voiceSessionId = stringOf(event.voiceSessionId);
|
|
969
|
+
if (voiceSessionId === "") throw new Error("voice websocket ready event has no voiceSessionId");
|
|
970
|
+
const audio = recordOf(event.audio);
|
|
971
|
+
if (audio?.inputSampleRate !== 16e3 || audio.outputSampleRate !== 24e3 && audio.outputSampleRate !== 48e3 || audio.format !== "pcm_s16le" && audio.format !== "audio_mpeg") throw new Error("voice websocket ready event has unsupported audio settings");
|
|
972
|
+
if (event.interactionMode !== "speech-shell" && event.interactionMode !== "frontend-agent") throw new Error("voice websocket ready event has an invalid interactionMode");
|
|
973
|
+
return {
|
|
974
|
+
type: "ready",
|
|
975
|
+
voiceSessionId,
|
|
976
|
+
audio: {
|
|
977
|
+
inputSampleRate: audio.inputSampleRate,
|
|
978
|
+
outputSampleRate: audio.outputSampleRate,
|
|
979
|
+
format: audio.format
|
|
980
|
+
},
|
|
981
|
+
interactionMode: event.interactionMode
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Resolve the dedicated WebSocket URL for one durable Voice Session.
|
|
986
|
+
* @param sessionId - durable voice session id.
|
|
987
|
+
* @param location - browser location used for host and protocol defaults.
|
|
988
|
+
* @returns the /voice WebSocket URL carrying the session id.
|
|
989
|
+
*/
|
|
990
|
+
function voiceUrl(sessionId, location = window.location) {
|
|
991
|
+
const url = new URL("/voice", location.href);
|
|
992
|
+
url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
993
|
+
url.searchParams.set("sessionId", sessionId);
|
|
994
|
+
return url;
|
|
995
|
+
}
|
|
996
|
+
const CAPTURE_WORKLET = `
|
|
997
|
+
class DshPcmCaptureProcessor extends AudioWorkletProcessor {
|
|
998
|
+
process(inputs) {
|
|
999
|
+
const channel = inputs[0] && inputs[0][0]
|
|
1000
|
+
if (channel) this.port.postMessage(channel.slice())
|
|
1001
|
+
return true
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
registerProcessor('dsh-pcm-capture', DshPcmCaptureProcessor)
|
|
1005
|
+
`;
|
|
1006
|
+
/**
|
|
1007
|
+
* Resample browser float PCM into the provider input rate.
|
|
1008
|
+
* @param input - float samples at `fromRate`.
|
|
1009
|
+
* @param fromRate - source sample rate in Hz.
|
|
1010
|
+
* @param toRate - target sample rate in Hz.
|
|
1011
|
+
* @returns linearly interpolated samples at `toRate`.
|
|
1012
|
+
*/
|
|
1013
|
+
function resample(input, fromRate, toRate) {
|
|
1014
|
+
if (fromRate === toRate) return [...input];
|
|
1015
|
+
const ratio = fromRate / toRate;
|
|
1016
|
+
const result = new Array(Math.floor(input.length / ratio));
|
|
1017
|
+
for (let index = 0; index < result.length; index += 1) {
|
|
1018
|
+
const position = index * ratio;
|
|
1019
|
+
const left = Math.floor(position);
|
|
1020
|
+
const fraction = position - left;
|
|
1021
|
+
const leftSample = input[left];
|
|
1022
|
+
const rightSample = input[Math.min(left + 1, input.length - 1)];
|
|
1023
|
+
result[index] = leftSample * (1 - fraction) + rightSample * fraction;
|
|
1024
|
+
}
|
|
1025
|
+
return result;
|
|
1026
|
+
}
|
|
1027
|
+
function schedulePcm(context, bytes, sampleRate, playing, ended, setPlayAt, currentPlayAt) {
|
|
1028
|
+
const pcm = new Int16Array(bytes);
|
|
1029
|
+
const buffer = context.createBuffer(1, pcm.length, sampleRate);
|
|
1030
|
+
const channel = buffer.getChannelData(0);
|
|
1031
|
+
for (const [index, sample] of pcm.entries()) channel[index] = sample / 32768;
|
|
1032
|
+
const node = context.createBufferSource();
|
|
1033
|
+
node.buffer = buffer;
|
|
1034
|
+
node.connect(context.destination);
|
|
1035
|
+
const start = Math.max(currentPlayAt, context.currentTime);
|
|
1036
|
+
setPlayAt(start + buffer.duration);
|
|
1037
|
+
playing.add(node);
|
|
1038
|
+
node.onended = () => {
|
|
1039
|
+
playing.delete(node);
|
|
1040
|
+
ended();
|
|
1041
|
+
};
|
|
1042
|
+
node.start(start);
|
|
1043
|
+
}
|
|
1044
|
+
async function scheduleEncoded(context, bytes, playing, ended, setPlayAt, getPlayAt, shouldSchedule) {
|
|
1045
|
+
const buffer = await context.decodeAudioData(bytes.slice(0));
|
|
1046
|
+
if (!shouldSchedule()) return;
|
|
1047
|
+
const node = context.createBufferSource();
|
|
1048
|
+
node.buffer = buffer;
|
|
1049
|
+
node.connect(context.destination);
|
|
1050
|
+
const start = Math.max(getPlayAt(), context.currentTime);
|
|
1051
|
+
setPlayAt(start + buffer.duration);
|
|
1052
|
+
playing.add(node);
|
|
1053
|
+
node.onended = () => {
|
|
1054
|
+
playing.delete(node);
|
|
1055
|
+
ended();
|
|
1056
|
+
};
|
|
1057
|
+
node.start(start);
|
|
1058
|
+
}
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region ../ui-voice/src/client/voice-history.ts
|
|
1061
|
+
const STORAGE_KEY = "dsh.voice.history.v1";
|
|
1062
|
+
function parseEntry(input) {
|
|
1063
|
+
if (typeof input !== "object" || input === null) return void 0;
|
|
1064
|
+
const value = input;
|
|
1065
|
+
if (typeof value.sessionId !== "string" || value.sessionId === "") return void 0;
|
|
1066
|
+
if (typeof value.lastActiveAt !== "number" || !Number.isFinite(value.lastActiveAt) || value.lastActiveAt < 0) return;
|
|
1067
|
+
return {
|
|
1068
|
+
sessionId: value.sessionId,
|
|
1069
|
+
lastActiveAt: value.lastActiveAt
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
function readHistory(storage) {
|
|
1073
|
+
if (storage === void 0) return { entries: [] };
|
|
1074
|
+
try {
|
|
1075
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
1076
|
+
if (raw === null) return { entries: [] };
|
|
1077
|
+
const parsed = JSON.parse(raw);
|
|
1078
|
+
if (typeof parsed !== "object" || parsed === null) return { entries: [] };
|
|
1079
|
+
const value = parsed;
|
|
1080
|
+
if (value.version !== 1 || !Array.isArray(value.entries)) return { entries: [] };
|
|
1081
|
+
const entries = [];
|
|
1082
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1083
|
+
for (const candidate of value.entries) {
|
|
1084
|
+
const entry = parseEntry(candidate);
|
|
1085
|
+
if (entry === void 0 || seen.has(entry.sessionId)) continue;
|
|
1086
|
+
seen.add(entry.sessionId);
|
|
1087
|
+
entries.push(entry);
|
|
1088
|
+
}
|
|
1089
|
+
entries.sort((left, right) => right.lastActiveAt - left.lastActiveAt);
|
|
1090
|
+
return { entries };
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
console.error("voice history rehydration failed:", error);
|
|
1093
|
+
return { entries: [] };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/** Own the local history index without adding Voice metadata to Host session projections. */
|
|
1097
|
+
var VoiceHistoryStore = class {
|
|
1098
|
+
snapshot;
|
|
1099
|
+
storage;
|
|
1100
|
+
unsubscribe;
|
|
1101
|
+
constructor(storage = typeof localStorage === "undefined" ? void 0 : localStorage) {
|
|
1102
|
+
this.storage = storage;
|
|
1103
|
+
this.snapshot = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)(readHistory(storage));
|
|
1104
|
+
this.unsubscribe = this.snapshot.subscribe(() => {
|
|
1105
|
+
this.persist();
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
/** Move one successfully opened Voice Session to the front of the index. */
|
|
1109
|
+
record(sessionId, lastActiveAt = Date.now()) {
|
|
1110
|
+
this.snapshot.set({ entries: [{
|
|
1111
|
+
sessionId,
|
|
1112
|
+
lastActiveAt
|
|
1113
|
+
}, ...this.snapshot.getSnapshot().entries.filter((entry) => entry.sessionId !== sessionId)] });
|
|
1114
|
+
}
|
|
1115
|
+
/** Stop persistence notifications. */
|
|
1116
|
+
dispose() {
|
|
1117
|
+
this.unsubscribe();
|
|
1118
|
+
}
|
|
1119
|
+
persist() {
|
|
1120
|
+
if (this.storage === void 0) return;
|
|
1121
|
+
const value = {
|
|
1122
|
+
version: 1,
|
|
1123
|
+
entries: this.snapshot.getSnapshot().entries
|
|
1124
|
+
};
|
|
1125
|
+
try {
|
|
1126
|
+
this.storage.setItem(STORAGE_KEY, JSON.stringify(value));
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
console.error("voice history persistence failed:", error);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region ../ui-voice/src/client/voice-text-submit.ts
|
|
1134
|
+
/** Routes the native composer through Voice while preserving its ordinary offline submission. */
|
|
1135
|
+
var VoiceTextSubmitBridge = class {
|
|
1136
|
+
voice;
|
|
1137
|
+
resolveInput;
|
|
1138
|
+
patched = /* @__PURE__ */ new Map();
|
|
1139
|
+
constructor(voice, resolveInput) {
|
|
1140
|
+
this.voice = voice;
|
|
1141
|
+
this.resolveInput = resolveInput;
|
|
1142
|
+
}
|
|
1143
|
+
install(sessionId) {
|
|
1144
|
+
if (this.patched.has(sessionId)) return;
|
|
1145
|
+
const input = this.resolveInput(sessionId);
|
|
1146
|
+
const originalOwnDescriptor = Object.getOwnPropertyDescriptor(input, "submit");
|
|
1147
|
+
const originalSubmit = input.submit.bind(input);
|
|
1148
|
+
const patchedSubmit = (mode) => {
|
|
1149
|
+
const voice = this.voice.getSnapshot();
|
|
1150
|
+
const state = input.state.getSnapshot();
|
|
1151
|
+
if (!(voice.sessionId === sessionId && voice.state !== "off" && voice.state !== "error") || state.draft.trim() === "" || state.imageIds.length > 0) {
|
|
1152
|
+
originalSubmit(mode);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
this.voice.submitText(state.draft);
|
|
1157
|
+
input.setDraft("");
|
|
1158
|
+
} catch (cause) {
|
|
1159
|
+
input.notify("error", cause instanceof Error ? cause.message : String(cause));
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
input.submit = patchedSubmit;
|
|
1163
|
+
this.patched.set(sessionId, {
|
|
1164
|
+
input,
|
|
1165
|
+
originalOwnDescriptor,
|
|
1166
|
+
patchedSubmit
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
dispose() {
|
|
1170
|
+
for (const { input, originalOwnDescriptor, patchedSubmit } of this.patched.values()) {
|
|
1171
|
+
if (input.submit !== patchedSubmit) continue;
|
|
1172
|
+
if (originalOwnDescriptor === void 0) delete input.submit;
|
|
1173
|
+
else Object.defineProperty(input, "submit", originalOwnDescriptor);
|
|
1174
|
+
}
|
|
1175
|
+
this.patched.clear();
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region ../ui-voice/src/client/voice-definitions.ts
|
|
1180
|
+
/** Fold one streamed utterance into a stable Chat node. */
|
|
1181
|
+
const voiceUtteranceDefinition = {
|
|
1182
|
+
kind: "voice-utterance",
|
|
1183
|
+
target: "chat",
|
|
1184
|
+
match: (event) => {
|
|
1185
|
+
if (event.type === "voice/utterance-start") return {
|
|
1186
|
+
id: String(event.data.utteranceId),
|
|
1187
|
+
role: "start"
|
|
1188
|
+
};
|
|
1189
|
+
if (event.type === "voice/utterance-end") return {
|
|
1190
|
+
id: String(event.data.utteranceId),
|
|
1191
|
+
role: "update"
|
|
1192
|
+
};
|
|
1193
|
+
return null;
|
|
1194
|
+
},
|
|
1195
|
+
start: (_context, match) => {
|
|
1196
|
+
if (match.event.type !== "voice/utterance-start") throw new Error("voice utterance start requires voice/utterance-start");
|
|
1197
|
+
return {
|
|
1198
|
+
utteranceId: match.event.data.utteranceId,
|
|
1199
|
+
role: match.event.data.role,
|
|
1200
|
+
state: "streaming"
|
|
1201
|
+
};
|
|
1202
|
+
},
|
|
1203
|
+
update: (context, match) => {
|
|
1204
|
+
if (match.event.type !== "voice/utterance-end") return context.state;
|
|
1205
|
+
return {
|
|
1206
|
+
...context.state,
|
|
1207
|
+
text: match.event.data.text,
|
|
1208
|
+
state: match.event.data.state
|
|
1209
|
+
};
|
|
1210
|
+
},
|
|
1211
|
+
buildViewNode: (context) => {
|
|
1212
|
+
if (context.start === void 0 || context.state === void 0) return null;
|
|
1213
|
+
const emptyInterrupted = context.state.state === "interrupted" && (context.state.text?.trim() ?? "") === "";
|
|
1214
|
+
return {
|
|
1215
|
+
key: context.key,
|
|
1216
|
+
kind: "voice-utterance",
|
|
1217
|
+
id: context.id,
|
|
1218
|
+
target: "chat",
|
|
1219
|
+
anchorSeq: context.start.event.seq,
|
|
1220
|
+
location: context.start.location,
|
|
1221
|
+
visibility: emptyInterrupted ? "hidden" : "visible",
|
|
1222
|
+
data: context.state
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
/** Fold a delegated task and all observations into one navigation card. */
|
|
1227
|
+
const voiceDelegationDefinition = {
|
|
1228
|
+
kind: "voice-delegation",
|
|
1229
|
+
target: "chat",
|
|
1230
|
+
match: (event) => {
|
|
1231
|
+
if (event.type === "voice/task-delegated") return {
|
|
1232
|
+
id: String(event.data.taskId),
|
|
1233
|
+
role: "start"
|
|
1234
|
+
};
|
|
1235
|
+
if (event.type === "voice/task-observation") return {
|
|
1236
|
+
id: String(event.data.taskId),
|
|
1237
|
+
role: "update"
|
|
1238
|
+
};
|
|
1239
|
+
return null;
|
|
1240
|
+
},
|
|
1241
|
+
start: (_context, match) => {
|
|
1242
|
+
if (match.event.type !== "voice/task-delegated") throw new Error("voice delegation start requires voice/task-delegated");
|
|
1243
|
+
return {
|
|
1244
|
+
taskId: match.event.data.taskId,
|
|
1245
|
+
taskSessionId: match.event.data.taskSessionId,
|
|
1246
|
+
input: match.event.data.input,
|
|
1247
|
+
status: "accepted"
|
|
1248
|
+
};
|
|
1249
|
+
},
|
|
1250
|
+
update: (context, match) => {
|
|
1251
|
+
if (match.event.type !== "voice/task-observation") return context.state;
|
|
1252
|
+
const observation = match.event.data;
|
|
1253
|
+
const update = observation.voiceMessage?.text ?? observation.announcement ?? observation.reason ?? context.state.update;
|
|
1254
|
+
return {
|
|
1255
|
+
...context.state,
|
|
1256
|
+
status: observation.status,
|
|
1257
|
+
...update === void 0 ? {} : { update }
|
|
1258
|
+
};
|
|
1259
|
+
},
|
|
1260
|
+
buildViewNode: (context) => {
|
|
1261
|
+
if (context.start === void 0) return null;
|
|
1262
|
+
return {
|
|
1263
|
+
key: context.key,
|
|
1264
|
+
kind: "voice-delegation",
|
|
1265
|
+
id: context.id,
|
|
1266
|
+
target: "chat",
|
|
1267
|
+
anchorSeq: context.start.event.seq,
|
|
1268
|
+
location: context.start.location,
|
|
1269
|
+
visibility: "visible",
|
|
1270
|
+
data: context.state
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
//#endregion
|
|
1275
|
+
//#region ../ui-voice/src/client/locales.ts
|
|
1276
|
+
/** `voice` namespace dictionaries. */
|
|
1277
|
+
/** Dictionary namespace owned by this plugin. */
|
|
1278
|
+
const NS = "voice";
|
|
1279
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
1280
|
+
const zh = {
|
|
1281
|
+
"badge.voice": "VOICE",
|
|
1282
|
+
"control.start": "开始语音对话",
|
|
1283
|
+
"control.retry": "语音连接失败,点击重试",
|
|
1284
|
+
"control.stop": "结束语音对话",
|
|
1285
|
+
"overlay.connecting": "正在连接语音",
|
|
1286
|
+
"overlay.listening": "正在聆听",
|
|
1287
|
+
"overlay.speaking": "正在播放回复",
|
|
1288
|
+
"overlay.error": "语音连接失败",
|
|
1289
|
+
"overlay.return": "返回语音对话",
|
|
1290
|
+
"overlay.stop": "结束",
|
|
1291
|
+
"history.open": "打开语音历史",
|
|
1292
|
+
"history.label": "语音历史",
|
|
1293
|
+
"history.title": "语音对话",
|
|
1294
|
+
"history.close": "关闭语音历史",
|
|
1295
|
+
"history.empty": "还没有语音对话",
|
|
1296
|
+
"history.active": "通话中",
|
|
1297
|
+
"history.saved": "已保存",
|
|
1298
|
+
"utterance.user.pending": "正在聆听…",
|
|
1299
|
+
"utterance.assistant.pending": "正在生成语音回复…",
|
|
1300
|
+
"utterance.interrupted": "已打断",
|
|
1301
|
+
"task.title": "后台处理",
|
|
1302
|
+
"task.status.accepted": "已接受",
|
|
1303
|
+
"task.status.queued": "排队中",
|
|
1304
|
+
"task.status.running": "执行中",
|
|
1305
|
+
"task.status.waiting-user": "等待确认",
|
|
1306
|
+
"task.status.completed": "已完成",
|
|
1307
|
+
"task.status.failed": "失败",
|
|
1308
|
+
"task.status.cancelled": "已取消",
|
|
1309
|
+
"task.status.interrupted": "服务中断",
|
|
1310
|
+
"task.expand": "展开后台处理详情",
|
|
1311
|
+
"task.collapse": "收起后台处理详情",
|
|
1312
|
+
"task.cancel": "取消任务",
|
|
1313
|
+
"task.open": "查看后台详情"
|
|
1314
|
+
};
|
|
1315
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
1316
|
+
const en = {
|
|
1317
|
+
"badge.voice": "VOICE",
|
|
1318
|
+
"control.start": "Start voice conversation",
|
|
1319
|
+
"control.retry": "Voice connection failed, press to retry",
|
|
1320
|
+
"control.stop": "End voice conversation",
|
|
1321
|
+
"overlay.connecting": "Connecting voice",
|
|
1322
|
+
"overlay.listening": "Listening",
|
|
1323
|
+
"overlay.speaking": "Playing response",
|
|
1324
|
+
"overlay.error": "Voice connection failed",
|
|
1325
|
+
"overlay.return": "Return to voice conversation",
|
|
1326
|
+
"overlay.stop": "End",
|
|
1327
|
+
"history.open": "Open voice history",
|
|
1328
|
+
"history.label": "Voice history",
|
|
1329
|
+
"history.title": "Voice conversations",
|
|
1330
|
+
"history.close": "Close voice history",
|
|
1331
|
+
"history.empty": "No voice conversations yet",
|
|
1332
|
+
"history.active": "Active",
|
|
1333
|
+
"history.saved": "Saved",
|
|
1334
|
+
"utterance.user.pending": "Listening…",
|
|
1335
|
+
"utterance.assistant.pending": "Generating voice response…",
|
|
1336
|
+
"utterance.interrupted": "Interrupted",
|
|
1337
|
+
"task.title": "Background task",
|
|
1338
|
+
"task.status.accepted": "Accepted",
|
|
1339
|
+
"task.status.queued": "Queued",
|
|
1340
|
+
"task.status.running": "Running",
|
|
1341
|
+
"task.status.waiting-user": "Waiting for you",
|
|
1342
|
+
"task.status.completed": "Completed",
|
|
1343
|
+
"task.status.failed": "Failed",
|
|
1344
|
+
"task.status.cancelled": "Cancelled",
|
|
1345
|
+
"task.status.interrupted": "Interrupted",
|
|
1346
|
+
"task.expand": "Show background task details",
|
|
1347
|
+
"task.collapse": "Hide background task details",
|
|
1348
|
+
"task.cancel": "Cancel task",
|
|
1349
|
+
"task.open": "View background details"
|
|
1350
|
+
};
|
|
1351
|
+
//#endregion
|
|
1352
|
+
//#region ../ui-voice/src/client/index.ts
|
|
1353
|
+
const inject = [
|
|
1354
|
+
"conversation",
|
|
1355
|
+
"conversationEvents",
|
|
1356
|
+
"slots",
|
|
1357
|
+
"sessions",
|
|
1358
|
+
"locale"
|
|
1359
|
+
];
|
|
1360
|
+
/** Mount root transport controls and durable Voice conversation renderers. @param ctx - browser context. */
|
|
1361
|
+
function apply(ctx) {
|
|
1362
|
+
const controller = new VoiceController();
|
|
1363
|
+
const history = new VoiceHistoryStore();
|
|
1364
|
+
const textSubmit = new VoiceTextSubmitBridge(controller, (sessionId) => {
|
|
1365
|
+
const scope = ctx.sessions.scope(sessionId);
|
|
1366
|
+
if (scope === void 0) throw new Error(`voice input session "${sessionId}" is unavailable`);
|
|
1367
|
+
return ctx.conversation.input.for(scope);
|
|
1368
|
+
});
|
|
1369
|
+
const stopVoice = () => controller.stop();
|
|
1370
|
+
const startVoice = async (sourceSessionId) => {
|
|
1371
|
+
ctx.sessions.open(sourceSessionId);
|
|
1372
|
+
textSubmit.install(sourceSessionId);
|
|
1373
|
+
await controller.start(sourceSessionId);
|
|
1374
|
+
history.record(sourceSessionId);
|
|
1375
|
+
};
|
|
1376
|
+
const voiceControlInjected = () => ({
|
|
1377
|
+
hooks: { voice: controller },
|
|
1378
|
+
startVoice,
|
|
1379
|
+
retryVoice: async () => {
|
|
1380
|
+
await controller.retry();
|
|
1381
|
+
const sessionId = controller.getSnapshot().sessionId;
|
|
1382
|
+
if (sessionId !== void 0) history.record(sessionId);
|
|
1383
|
+
},
|
|
1384
|
+
stopVoice
|
|
1385
|
+
});
|
|
1386
|
+
const voiceOverlayInjected = () => ({
|
|
1387
|
+
hooks: { voice: controller },
|
|
1388
|
+
openVoiceSession: (id) => {
|
|
1389
|
+
ctx.sessions.open(id);
|
|
1390
|
+
},
|
|
1391
|
+
stopVoice
|
|
1392
|
+
});
|
|
1393
|
+
const utteranceInjected = () => ({ hooks: { voice: controller } });
|
|
1394
|
+
const delegationInjected = () => ({
|
|
1395
|
+
hooks: { voice: controller },
|
|
1396
|
+
openSession: (id) => {
|
|
1397
|
+
ctx.sessions.open(id);
|
|
1398
|
+
},
|
|
1399
|
+
cancelTask: (id) => {
|
|
1400
|
+
controller.cancelTask(id);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
const historyInjected = () => ({
|
|
1404
|
+
hooks: {
|
|
1405
|
+
voice: controller,
|
|
1406
|
+
voiceHistory: history.snapshot
|
|
1407
|
+
},
|
|
1408
|
+
openSession: (id) => {
|
|
1409
|
+
history.record(id);
|
|
1410
|
+
ctx.sessions.open(id);
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
1414
|
+
zh,
|
|
1415
|
+
en
|
|
1416
|
+
}), "ui-voice: dictionaries");
|
|
1417
|
+
ctx.effect(() => async () => {
|
|
1418
|
+
await controller.stop();
|
|
1419
|
+
}, "ui-voice: transport teardown");
|
|
1420
|
+
ctx.effect(() => () => {
|
|
1421
|
+
textSubmit.dispose();
|
|
1422
|
+
}, "ui-voice: text submit bridge teardown");
|
|
1423
|
+
ctx.effect(() => () => {
|
|
1424
|
+
history.dispose();
|
|
1425
|
+
}, "ui-voice: history persistence teardown");
|
|
1426
|
+
ctx.conversationEvents.register(voiceUtteranceDefinition);
|
|
1427
|
+
ctx.conversationEvents.register(voiceDelegationDefinition);
|
|
1428
|
+
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
|
|
1429
|
+
name: "conversation.input.right",
|
|
1430
|
+
id: "voice",
|
|
1431
|
+
order: 20,
|
|
1432
|
+
locale: NS,
|
|
1433
|
+
inject: voiceControlInjected
|
|
1434
|
+
}, VoiceControl));
|
|
1435
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
1436
|
+
name: "shell.overlay",
|
|
1437
|
+
id: "voice-active",
|
|
1438
|
+
order: 20,
|
|
1439
|
+
locale: NS,
|
|
1440
|
+
inject: voiceOverlayInjected
|
|
1441
|
+
}, VoiceOverlay));
|
|
1442
|
+
ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
1443
|
+
name: "sidebar.footer.action",
|
|
1444
|
+
id: "voice-history",
|
|
1445
|
+
order: 10,
|
|
1446
|
+
locale: NS,
|
|
1447
|
+
inject: historyInjected
|
|
1448
|
+
}, VoiceHistoryAction));
|
|
1449
|
+
ctx.slots.inject("conversation.chat.node", () => ctx.slots.register({
|
|
1450
|
+
name: "conversation.chat.node",
|
|
1451
|
+
key: "voice-utterance",
|
|
1452
|
+
locale: NS,
|
|
1453
|
+
inject: utteranceInjected
|
|
1454
|
+
}, VoiceUtteranceView));
|
|
1455
|
+
ctx.slots.inject("conversation.chat.node", () => ctx.slots.register({
|
|
1456
|
+
name: "conversation.chat.node",
|
|
1457
|
+
key: "voice-delegation",
|
|
1458
|
+
locale: NS,
|
|
1459
|
+
inject: delegationInjected
|
|
1460
|
+
}, VoiceDelegationView));
|
|
1461
|
+
}
|
|
1462
|
+
//#endregion
|
|
1463
|
+
exports.apply = apply;
|
|
1464
|
+
exports.inject = inject;
|
|
1465
|
+
return module.exports;
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
//# sourceMappingURL=client.js.map
|