@yfwu2020/dsh-selection-explain 0.1.3 → 0.4.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/LICENSE +21 -0
- package/README.md +137 -7
- package/assets/quotes.png +0 -0
- package/assets/voice-inserted.png +0 -0
- package/assets/voice.png +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +3453 -203
- package/lib/index.js +533 -2
- package/lib/index.js.map +1 -1
- package/lib/prompts.js +5 -0
- package/lib/prompts.js.map +1 -1
- package/lib/types/index.d.ts +76 -0
- package/package.json +8 -3
- package/scripts/build-demo-pages.mjs +58 -4
- package/scripts/build.sh +4 -2
- package/scripts/measure-cost.mjs +251 -0
- package/scripts/smoke-bridge.mjs +365 -0
- package/scripts/smoke-panel.mjs +433 -0
- package/scripts/smoke-voice.mjs +772 -0
- package/scripts/test-bridge.mjs +408 -0
- package/scripts/test-client.mjs +1562 -1
- package/scripts/test-prompt.mjs +15 -0
- package/scripts/test-speech.mjs +220 -0
- package/scripts/test-transcript.mjs +74 -1
- package/src/client/index.js +3453 -203
- package/src/index.ts +625 -2
- package/src/prompts.ts +5 -0
package/lib/index.js
CHANGED
|
@@ -59,6 +59,9 @@ export const Config = z.object({
|
|
|
59
59
|
sessionContextMaxMessages: z.number().min(1).max(200).default(24),
|
|
60
60
|
sessionContextFastMessages: z.number().min(1).max(200).default(8),
|
|
61
61
|
sessionContextMaxChars: z.number().min(0).max(2000000).default(0),
|
|
62
|
+
quoteContextRounds: z.number().min(0).max(10).default(1),
|
|
63
|
+
quoteContextMaxCharsPerTurn: z.number().min(200).max(20000).default(2000),
|
|
64
|
+
quoteContextMaxChars: z.number().min(400).max(100000).default(6000),
|
|
62
65
|
resultCacheTtlMs: z.number().min(0).max(86400000).default(600000),
|
|
63
66
|
tools: z.boolean().default(true),
|
|
64
67
|
/**
|
|
@@ -82,6 +85,11 @@ export const Config = z.object({
|
|
|
82
85
|
toolResultMaxChars: z.number().min(200).max(20000).default(3000),
|
|
83
86
|
toolReadRoot: z.string().default(''),
|
|
84
87
|
chatReasoningEffort: z.string().default('high'),
|
|
88
|
+
/**
|
|
89
|
+
* 侧边栏 HTML 预览里的划词桥。关掉就不往预览帧里注入桥脚本,
|
|
90
|
+
* 侧边栏网页里选中文字不会再弹「解读」。
|
|
91
|
+
*/
|
|
92
|
+
bridgeSidebarPreview: z.boolean().default(true),
|
|
85
93
|
});
|
|
86
94
|
/** 联网类工具名:用来判断"首选里的联网工具在不在"。 */
|
|
87
95
|
const SEARCH_TOOL_NAMES = ['advanced_search', 'platform_search', 'web_search'];
|
|
@@ -387,6 +395,65 @@ function clampText(raw, max) {
|
|
|
387
395
|
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
388
396
|
return text.length > max ? `${text.slice(0, max)}\n…(已截断)` : text;
|
|
389
397
|
}
|
|
398
|
+
// ────────────────────────────── 语音输入(小窗麦克风) ──────────────────────────────
|
|
399
|
+
//
|
|
400
|
+
// 小窗的追问输入框加一个麦克风:录一段话 → 转成文字**插进输入框**(不是直接发送,
|
|
401
|
+
// 识别错了还能改)。转写这条链路在 host 这一侧收口:
|
|
402
|
+
//
|
|
403
|
+
// GET ${API_PREFIX}/speech 识别服务目录 + 就绪状态(客户端据此决定麦克风能不能点)
|
|
404
|
+
// POST ${API_PREFIX}/speech/transcribe 一段 16kHz 单声道 PCM16 WAV(base64)→ 文字
|
|
405
|
+
// POST ${API_PREFIX}/speech/prepare 显式准备本地识别模型(首次要下载,必须用户点头)
|
|
406
|
+
//
|
|
407
|
+
// 为什么不让浏览器自己去连识别服务:音频、provider 选择、模型路径全部留在 host,
|
|
408
|
+
// 客户端只拿到"一段话的文字";而且**音频不落盘、不进会话**(转写完即丢)。
|
|
409
|
+
// 识别服务(@deepseek-ai/dsh-experimental-speech-to-text)是实验性的、可以不装:
|
|
410
|
+
// 拿不到该服务就整条链路优雅降级(目录接口回 available:false,麦克风点了只给一句解释)。
|
|
411
|
+
/** 单次录音时长上限(秒):到了就自动收尾去识别,不让用户对着麦克风发呆。 */
|
|
412
|
+
const SPEECH_MAX_SECONDS = 60;
|
|
413
|
+
/** 单条音频的字节上限(≈ 2 分钟 16kHz 单声道 PCM16);与识别服务默认的 4MB 对齐。 */
|
|
414
|
+
const SPEECH_MAX_BYTES = 4 * 1024 * 1024;
|
|
415
|
+
/** 识别请求体上限:base64 会膨胀 4/3,再留点余量给 JSON 外壳。 */
|
|
416
|
+
const SPEECH_BODY_LIMIT = Math.ceil(SPEECH_MAX_BYTES / 3) * 4 + 4096;
|
|
417
|
+
/** 单次识别的等待上限(毫秒):真机上 60 秒音频几秒内就回来了,这是兜底。 */
|
|
418
|
+
const SPEECH_TIMEOUT_MS = 120_000;
|
|
419
|
+
/**
|
|
420
|
+
* 校验客户端送上来的音频是不是**规范**的 16kHz 单声道 PCM16 WAV。
|
|
421
|
+
*
|
|
422
|
+
* 为什么抠得这么死:这个格式是浏览器端编码器(MediaRecorder → decodeAudioData →
|
|
423
|
+
* OfflineAudioContext 重采样 → 手写 WAV 头)唯一会产出的形状。逐字段对齐之后,
|
|
424
|
+
* "音频不对"就只剩两种可能——客户端版本对不上、或者有人手工构造了这个请求。
|
|
425
|
+
* 光看二进制长度不算数:头里写的采样率/声道/位深必须是那一套,否则识别服务会按
|
|
426
|
+
* 错误的采样率解释这段音频(听起来像快放/慢放),识别结果自然全错。
|
|
427
|
+
*
|
|
428
|
+
* @param audio 完整音频字节
|
|
429
|
+
* @param maxSeconds 允许的最长时长
|
|
430
|
+
* @returns 时长(秒)
|
|
431
|
+
*/
|
|
432
|
+
export function validateWave(audio, maxSeconds) {
|
|
433
|
+
if (audio.length < 46 ||
|
|
434
|
+
audio.toString('ascii', 0, 4) !== 'RIFF' ||
|
|
435
|
+
audio.toString('ascii', 8, 12) !== 'WAVE' ||
|
|
436
|
+
audio.toString('ascii', 12, 16) !== 'fmt ' ||
|
|
437
|
+
audio.readUInt32LE(16) !== 16 ||
|
|
438
|
+
audio.readUInt16LE(20) !== 1 || // PCM
|
|
439
|
+
audio.readUInt16LE(22) !== 1 || // 单声道
|
|
440
|
+
audio.readUInt32LE(24) !== 16000 || // 16kHz
|
|
441
|
+
audio.readUInt32LE(28) !== 32000 || // 字节率 = 16000 × 1ch × 2B
|
|
442
|
+
audio.readUInt16LE(32) !== 2 || // 块对齐
|
|
443
|
+
audio.readUInt16LE(34) !== 16 || // 16 bit
|
|
444
|
+
audio.toString('ascii', 36, 40) !== 'data' ||
|
|
445
|
+
audio.readUInt32LE(4) !== audio.length - 8 ||
|
|
446
|
+
audio.readUInt32LE(40) !== audio.length - 44 ||
|
|
447
|
+
(audio.length - 44) % 2 !== 0) {
|
|
448
|
+
throw new Error('音频格式不是 16kHz 单声道 PCM16 WAV');
|
|
449
|
+
}
|
|
450
|
+
const seconds = (audio.length - 44) / 32000;
|
|
451
|
+
if (seconds <= 0)
|
|
452
|
+
throw new Error('录音是空的');
|
|
453
|
+
if (seconds > maxSeconds)
|
|
454
|
+
throw new Error(`录音超过 ${maxSeconds} 秒`);
|
|
455
|
+
return seconds;
|
|
456
|
+
}
|
|
390
457
|
/**
|
|
391
458
|
* 「网页模式」的设计规范:从插件自带的 `skills/web-design/SKILL.md` 读(按 mtime 缓存)。
|
|
392
459
|
*
|
|
@@ -579,6 +646,18 @@ async function runTool(ctx, name, args, signal, maxChars, agent) {
|
|
|
579
646
|
return { content: [{ type: 'text', text: `工具执行失败:${String(error?.message ?? error)}` }], isError: true };
|
|
580
647
|
}
|
|
581
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* `createSystemMessage` 的**跨版本兼容调用**。
|
|
651
|
+
*
|
|
652
|
+
* 这个函数的签名在两个版本间不一样,而两份类型都得能编译通过:
|
|
653
|
+
* · **0.1.5-rc.3**(npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的就是它,CI 的构建校验用的这份):
|
|
654
|
+
* `createSystemMessage(text, plugin)` —— 两个都必填,少传一个直接 TS2554(发布被卡过一次);
|
|
655
|
+
* · **0.1.7-rc.x**(本机/新运行时):`createSystemMessage(text)` —— 第二参数是历史遗留,传了会被忽略。
|
|
656
|
+
*
|
|
657
|
+
* 所以这里把它当"接受可选第二参数"的函数来调:两个版本都编译得过,运行时也都正确
|
|
658
|
+
* (旧版正好拿到它想要的"组装这段提示词的插件名",新版直接忽略)。
|
|
659
|
+
*/
|
|
660
|
+
const systemMessageOf = createSystemMessage;
|
|
582
661
|
/** 请求里带的会话 id(空串 = 客户端没取到)。 */
|
|
583
662
|
function sessionIdOf(body) {
|
|
584
663
|
return typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
@@ -683,6 +762,138 @@ export function transcriptOf(events, options) {
|
|
|
683
762
|
const transcript = picked.map((entry) => `${entry.marked ? '▶ ' : ' '}${entry.role}:${entry.text}`).join('\n');
|
|
684
763
|
return { transcript, marked: picked.some((entry) => entry.marked) };
|
|
685
764
|
}
|
|
765
|
+
/** 在文本里找到引用文字并套上【】(直接找不到就按"空白等价"再找一次)。 */
|
|
766
|
+
function markQuoteInText(text, marker) {
|
|
767
|
+
if (!text || !marker)
|
|
768
|
+
return text;
|
|
769
|
+
const at = text.indexOf(marker);
|
|
770
|
+
if (at >= 0)
|
|
771
|
+
return `${text.slice(0, at)}【${marker}】${text.slice(at + marker.length)}`;
|
|
772
|
+
// 归一化后匹配:把引用按空白切开,逐段允许任意空白(选中文字常与消息原文差在换行/缩进)
|
|
773
|
+
const tokens = marker.split(/\s+/).filter(Boolean).map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
774
|
+
if (tokens.length === 0)
|
|
775
|
+
return text;
|
|
776
|
+
try {
|
|
777
|
+
const hit = new RegExp(tokens.join('\\s+')).exec(text);
|
|
778
|
+
if (hit && typeof hit.index === 'number') {
|
|
779
|
+
return `${text.slice(0, hit.index)}【${hit[0]}】${text.slice(hit.index + hit[0].length)}`;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
/* 正则构造失败(极端输入):不标记,客户端会把原文另起一段补上 */
|
|
784
|
+
}
|
|
785
|
+
return text;
|
|
786
|
+
}
|
|
787
|
+
/** 围绕引用截断长消息(引用本身必须留下;找不到引用就从头截)。 */
|
|
788
|
+
function clampAroundQuote(text, marker, max) {
|
|
789
|
+
if (text.length <= max)
|
|
790
|
+
return text;
|
|
791
|
+
const at = marker ? text.indexOf(marker) : -1;
|
|
792
|
+
if (at < 0)
|
|
793
|
+
return `${text.slice(0, max)}…(已截断)`;
|
|
794
|
+
const half = Math.max(0, Math.floor((max - marker.length) / 2));
|
|
795
|
+
const from = Math.max(0, at - half);
|
|
796
|
+
const to = Math.min(text.length, at + marker.length + half);
|
|
797
|
+
return `${from > 0 ? '…' : ''}${text.slice(from, to)}${to < text.length ? '…' : ''}`;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* **引用上下文**:引用文字所在的那一组对话 ± N 组(一组 = 一条用户消息 + 它的助手回复)。
|
|
801
|
+
*
|
|
802
|
+
* 与 `transcriptOf`(解读用的会话背景)的区别,两条都是为引用量身定的:
|
|
803
|
+
* ① 窗口是**锚点两侧** —— 引用常常是"上面那句"和"下面那句"的对照,只有上文不够;
|
|
804
|
+
* ② 按**轮**取整 —— 上下各至少一整组(用户消息 + AI 回复),不会切在半个回合上。
|
|
805
|
+
* 噪音过滤与 `transcriptOf` 完全一致(工具调用/结果、系统提示、harness 注入一律不进)。
|
|
806
|
+
*/
|
|
807
|
+
export function quoteContextOf(events, marker, options = {}) {
|
|
808
|
+
const roundsAround = Math.max(0, options.roundsAround ?? 1);
|
|
809
|
+
const maxCharsPerTurn = Math.max(200, options.maxCharsPerTurn ?? 2000);
|
|
810
|
+
const maxChars = Math.max(400, options.maxChars ?? 6000);
|
|
811
|
+
const entries = [];
|
|
812
|
+
for (const event of events) {
|
|
813
|
+
if (!event || typeof event !== 'object')
|
|
814
|
+
continue;
|
|
815
|
+
const typed = event;
|
|
816
|
+
const data = (typed.data ?? {});
|
|
817
|
+
let role = '';
|
|
818
|
+
let content;
|
|
819
|
+
if (typed.type === 'user/message') {
|
|
820
|
+
if (data.source && data.source.kind !== 'user')
|
|
821
|
+
continue;
|
|
822
|
+
role = '用户';
|
|
823
|
+
content = data.content;
|
|
824
|
+
}
|
|
825
|
+
else if (typed.type === 'assistant/message') {
|
|
826
|
+
role = '助手';
|
|
827
|
+
content = data.message?.content;
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const text = messageText(content);
|
|
833
|
+
if (!text)
|
|
834
|
+
continue;
|
|
835
|
+
entries.push({ role, text, marked: false });
|
|
836
|
+
}
|
|
837
|
+
if (entries.length === 0)
|
|
838
|
+
return { transcript: '', matched: false, rounds: 0 };
|
|
839
|
+
const needle = normalizeSpace(marker);
|
|
840
|
+
let anchor = -1;
|
|
841
|
+
if (needle.length >= 2) {
|
|
842
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
843
|
+
if (normalizeSpace(entries[i]?.text ?? '').includes(needle)) {
|
|
844
|
+
anchor = i;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (anchor < 0)
|
|
850
|
+
return { transcript: '', matched: false, rounds: 0 };
|
|
851
|
+
// 按"轮"分组:一条用户消息开一轮(它之前的助手消息归上一轮)
|
|
852
|
+
const roundOf = [];
|
|
853
|
+
let round = -1;
|
|
854
|
+
for (const entry of entries) {
|
|
855
|
+
if (entry.role === '用户' || round < 0)
|
|
856
|
+
round += 1;
|
|
857
|
+
roundOf.push(round);
|
|
858
|
+
}
|
|
859
|
+
const anchorRound = roundOf[anchor] ?? 0;
|
|
860
|
+
const fromRound = Math.max(0, anchorRound - roundsAround);
|
|
861
|
+
const toRound = Math.min(round, anchorRound + roundsAround);
|
|
862
|
+
const picked = [];
|
|
863
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
864
|
+
const r = roundOf[i] ?? 0;
|
|
865
|
+
if (r < fromRound || r > toRound)
|
|
866
|
+
continue;
|
|
867
|
+
const entry = entries[i];
|
|
868
|
+
const isAnchor = i === anchor;
|
|
869
|
+
const raw = isAnchor ? markQuoteInText(entry.text, marker) : entry.text;
|
|
870
|
+
picked.push({
|
|
871
|
+
role: entry.role,
|
|
872
|
+
text: clampAroundQuote(raw, marker, maxCharsPerTurn),
|
|
873
|
+
marked: isAnchor,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
// 总量上限:超了先丢最早的几组,再丢引用之后的几组(**引用所在那条永远保留**)
|
|
877
|
+
let used = picked.reduce((sum, entry) => sum + entry.text.length, 0);
|
|
878
|
+
let droppedBefore = 0;
|
|
879
|
+
let droppedAfter = 0;
|
|
880
|
+
while (picked.length > 1 && used > maxChars && picked[0] && !picked[0].marked) {
|
|
881
|
+
const first = picked.shift();
|
|
882
|
+
droppedBefore += 1;
|
|
883
|
+
used -= first ? first.text.length : 0;
|
|
884
|
+
}
|
|
885
|
+
while (picked.length > 1 && used > maxChars && picked[picked.length - 1] && !picked[picked.length - 1]?.marked) {
|
|
886
|
+
const last = picked.pop();
|
|
887
|
+
droppedAfter += 1;
|
|
888
|
+
used -= last ? last.text.length : 0;
|
|
889
|
+
}
|
|
890
|
+
if (droppedBefore > 0)
|
|
891
|
+
picked.unshift({ role: '提示', text: `(引用之前还有 ${droppedBefore} 条消息,未包含)`, marked: false });
|
|
892
|
+
if (droppedAfter > 0)
|
|
893
|
+
picked.push({ role: '提示', text: `(引用之后还有 ${droppedAfter} 条消息,未包含)`, marked: false });
|
|
894
|
+
const transcript = picked.map((entry) => `${entry.role}:${entry.text}`).join('\n');
|
|
895
|
+
return { transcript, matched: true, rounds: toRound - fromRound + 1 };
|
|
896
|
+
}
|
|
686
897
|
/** 注册路由、解析模型路由、转发 LLM 流。 */
|
|
687
898
|
export function apply(ctx, rawConfig) {
|
|
688
899
|
const config = {
|
|
@@ -701,6 +912,9 @@ export function apply(ctx, rawConfig) {
|
|
|
701
912
|
sessionContextMaxMessages: rawConfig?.sessionContextMaxMessages ?? 24,
|
|
702
913
|
sessionContextFastMessages: rawConfig?.sessionContextFastMessages ?? 8,
|
|
703
914
|
sessionContextMaxChars: rawConfig?.sessionContextMaxChars ?? 0,
|
|
915
|
+
quoteContextRounds: rawConfig?.quoteContextRounds ?? 1,
|
|
916
|
+
quoteContextMaxCharsPerTurn: rawConfig?.quoteContextMaxCharsPerTurn ?? 2000,
|
|
917
|
+
quoteContextMaxChars: rawConfig?.quoteContextMaxChars ?? 6000,
|
|
704
918
|
resultCacheTtlMs: rawConfig?.resultCacheTtlMs ?? 600000,
|
|
705
919
|
tools: rawConfig?.tools ?? true,
|
|
706
920
|
toolNames: rawConfig?.toolNames ?? 'advanced_search,platform_search,read,grep,glob',
|
|
@@ -709,6 +923,7 @@ export function apply(ctx, rawConfig) {
|
|
|
709
923
|
toolResultMaxChars: rawConfig?.toolResultMaxChars ?? 3000,
|
|
710
924
|
toolReadRoot: rawConfig?.toolReadRoot ?? '',
|
|
711
925
|
chatReasoningEffort: rawConfig?.chatReasoningEffort ?? 'high',
|
|
926
|
+
bridgeSidebarPreview: rawConfig?.bridgeSidebarPreview ?? true,
|
|
712
927
|
};
|
|
713
928
|
/** 兜底路由:第一次真实 LLM 调用后捕获。 */
|
|
714
929
|
let captured = null;
|
|
@@ -999,6 +1214,308 @@ export function apply(ctx, rawConfig) {
|
|
|
999
1214
|
hasDetail: entry.parts.detail.length > 0,
|
|
1000
1215
|
pinned: entry.pinned,
|
|
1001
1216
|
});
|
|
1217
|
+
/**
|
|
1218
|
+
* 引用上下文:客户端点了「❝ 引用」之后,拿引用文字来换它所在的那一组对话 ± 一组。
|
|
1219
|
+
*
|
|
1220
|
+
* 为什么走 host 而不是客户端读 DOM:会话正文的**干净文本**只有 host 这边有
|
|
1221
|
+
* (`readSurface` 的事件流里工具调用/结果、系统提示、harness 注入全都被 `transcriptOf`
|
|
1222
|
+
* 那套规则滤掉过)。客户端 DOM 里读到的会是"渲染后的样子"——思考块、工具卡片、
|
|
1223
|
+
* 按钮文案全混在里面。定位不到(引用来自侧边栏网页/文档预览)就返回 matched:false,
|
|
1224
|
+
* 客户端退回它自己采的局部上下文。
|
|
1225
|
+
*/
|
|
1226
|
+
const handleQuoteContext = (req, res) => {
|
|
1227
|
+
void (async () => {
|
|
1228
|
+
if (req.method !== 'POST') {
|
|
1229
|
+
sendJson(res, 405, { ok: false, error: '只支持 POST' });
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const raw = await readBody(req, 200_000);
|
|
1233
|
+
let body = {};
|
|
1234
|
+
try {
|
|
1235
|
+
body = raw ? JSON.parse(raw) : {};
|
|
1236
|
+
}
|
|
1237
|
+
catch {
|
|
1238
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1242
|
+
const text = clampText(body.text, config.maxSelectionChars);
|
|
1243
|
+
if (!sessionId || !text.trim()) {
|
|
1244
|
+
sendJson(res, 200, { ok: true, matched: false, context: '', rounds: 0 });
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const events = await readSurfaceEvents(sessionId);
|
|
1248
|
+
const context = quoteContextOf(events, text, {
|
|
1249
|
+
roundsAround: config.quoteContextRounds,
|
|
1250
|
+
maxCharsPerTurn: config.quoteContextMaxCharsPerTurn,
|
|
1251
|
+
maxChars: config.quoteContextMaxChars,
|
|
1252
|
+
});
|
|
1253
|
+
sendJson(res, 200, {
|
|
1254
|
+
ok: true,
|
|
1255
|
+
matched: context.matched,
|
|
1256
|
+
context: context.transcript,
|
|
1257
|
+
rounds: context.rounds,
|
|
1258
|
+
chars: context.transcript.length,
|
|
1259
|
+
});
|
|
1260
|
+
})().catch((error) => {
|
|
1261
|
+
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
|
|
1262
|
+
});
|
|
1263
|
+
};
|
|
1264
|
+
/** ctx.speechToText:装了就给,没装/被停用给 undefined(麦克风据此优雅降级)。 */
|
|
1265
|
+
const speechService = () => {
|
|
1266
|
+
try {
|
|
1267
|
+
const service = ctx.get('speechToText');
|
|
1268
|
+
return service && typeof service.transcribe === 'function' ? service : undefined;
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
return undefined;
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
/**
|
|
1275
|
+
* 语音目录:有哪些识别器、各自什么状态、默认选谁、这次能录多久。
|
|
1276
|
+
*
|
|
1277
|
+
* 客户端据此决定麦克风的形态:能用 / 要先把模型准备好 / 这个部署根本没有识别服务。
|
|
1278
|
+
* 不认识的状态一律当成"不能录"(宁可让用户看到一句解释,也不要录完才发现转不了)。
|
|
1279
|
+
*/
|
|
1280
|
+
const speechCatalog = () => {
|
|
1281
|
+
const service = speechService();
|
|
1282
|
+
const limits = { maxSeconds: SPEECH_MAX_SECONDS, maxBytes: SPEECH_MAX_BYTES };
|
|
1283
|
+
if (!service) {
|
|
1284
|
+
return {
|
|
1285
|
+
ok: true,
|
|
1286
|
+
available: false,
|
|
1287
|
+
reason: 'missing',
|
|
1288
|
+
error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
|
|
1289
|
+
providers: [],
|
|
1290
|
+
selection: null,
|
|
1291
|
+
limits,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
let snapshot;
|
|
1295
|
+
try {
|
|
1296
|
+
snapshot = service.snapshot();
|
|
1297
|
+
}
|
|
1298
|
+
catch (error) {
|
|
1299
|
+
return {
|
|
1300
|
+
ok: true,
|
|
1301
|
+
available: false,
|
|
1302
|
+
reason: 'error',
|
|
1303
|
+
error: String(error?.message ?? error),
|
|
1304
|
+
providers: [],
|
|
1305
|
+
selection: null,
|
|
1306
|
+
limits,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
const providers = (snapshot?.providers ?? []).map((provider) => {
|
|
1310
|
+
const preparation = provider.preparation ?? { phase: 'ready' };
|
|
1311
|
+
return {
|
|
1312
|
+
id: String(provider.id),
|
|
1313
|
+
name: String(provider.name ?? provider.id),
|
|
1314
|
+
location: String(provider.location ?? ''),
|
|
1315
|
+
languages: Array.isArray(provider.languages) ? provider.languages.map(String) : [],
|
|
1316
|
+
// 下载源:本地模型已经装好时是空数组(没有可选项),装了才有得选
|
|
1317
|
+
downloadSources: Array.isArray(provider.downloadSources) ? provider.downloadSources.map(String) : [],
|
|
1318
|
+
phase: String(preparation.phase ?? 'unprepared'),
|
|
1319
|
+
step: preparation.step ? String(preparation.step) : '',
|
|
1320
|
+
// 下载进度(只在 downloading 时有意义):客户端显示"还要下多少"
|
|
1321
|
+
completedBytes: typeof preparation.completedBytes === 'number' ? preparation.completedBytes : 0,
|
|
1322
|
+
totalBytes: typeof preparation.totalBytes === 'number' ? preparation.totalBytes : 0,
|
|
1323
|
+
message: preparation.message ? String(preparation.message) : '',
|
|
1324
|
+
};
|
|
1325
|
+
});
|
|
1326
|
+
const selection = snapshot?.selection
|
|
1327
|
+
? { providerId: String(snapshot.selection.providerId), language: String(snapshot.selection.language) }
|
|
1328
|
+
: null;
|
|
1329
|
+
// 能录 = 至少有一个识别器已经就绪(standby 也算:本机模型在磁盘上,用到才唤醒)
|
|
1330
|
+
const usable = providers.filter((provider) => provider.phase === 'ready' || provider.phase === 'standby');
|
|
1331
|
+
return {
|
|
1332
|
+
ok: true,
|
|
1333
|
+
available: usable.length > 0,
|
|
1334
|
+
reason: usable.length > 0 ? '' : providers.length > 0 ? 'unprepared' : 'empty',
|
|
1335
|
+
error: '',
|
|
1336
|
+
providers,
|
|
1337
|
+
selection,
|
|
1338
|
+
limits,
|
|
1339
|
+
};
|
|
1340
|
+
};
|
|
1341
|
+
const handleSpeech = (req, res) => {
|
|
1342
|
+
if (req.method !== 'GET') {
|
|
1343
|
+
sendJson(res, 405, { ok: false, error: '只支持 GET' });
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
sendJson(res, 200, speechCatalog());
|
|
1347
|
+
};
|
|
1348
|
+
/**
|
|
1349
|
+
* 显式准备识别模型(首次要下 1G 左右的模型,所以**只有用户点了才做**,不自动触发)。
|
|
1350
|
+
* 只是把任务交给识别服务,进度走目录接口轮询(下载在 host 上跑,关掉页面也继续)。
|
|
1351
|
+
*/
|
|
1352
|
+
const handleSpeechPrepare = (req, res) => {
|
|
1353
|
+
void (async () => {
|
|
1354
|
+
if (req.method !== 'POST') {
|
|
1355
|
+
sendJson(res, 405, { ok: false, error: '只支持 POST' });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const service = speechService();
|
|
1359
|
+
if (!service) {
|
|
1360
|
+
sendJson(res, 200, { ok: false, error: '这个 DSH 没有启用语音识别服务' });
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
let body = {};
|
|
1364
|
+
try {
|
|
1365
|
+
const raw = await readBody(req, 20_000);
|
|
1366
|
+
body = raw ? JSON.parse(raw) : {};
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const catalog = speechCatalog();
|
|
1373
|
+
const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : '';
|
|
1374
|
+
const providers = catalog.providers ?? [];
|
|
1375
|
+
const target = wanted || (providers.find((item) => item.phase !== 'ready' && item.phase !== 'standby')?.id ?? providers[0]?.id ?? '');
|
|
1376
|
+
if (!target) {
|
|
1377
|
+
sendJson(res, 200, { ok: false, error: '没有可用的识别器' });
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
try {
|
|
1381
|
+
const source = typeof body.downloadSource === 'string' && body.downloadSource ? body.downloadSource : undefined;
|
|
1382
|
+
service.prepare(target, source === undefined ? undefined : { downloadSource: source });
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
// 已经在准备中 / 下载源不认 / 服务生命周期已结束:都不算致命,把状态回给客户端自己看
|
|
1386
|
+
sendJson(res, 200, { ok: false, error: String(error?.message ?? error), catalog: speechCatalog() });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
sendJson(res, 200, { ok: true, providerId: target, catalog: speechCatalog() });
|
|
1390
|
+
})().catch((error) => {
|
|
1391
|
+
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
|
|
1392
|
+
});
|
|
1393
|
+
};
|
|
1394
|
+
/**
|
|
1395
|
+
* 转写一段录音。
|
|
1396
|
+
*
|
|
1397
|
+
* 输入是浏览器端编码好的规范 WAV(base64),输出就是一段文字——**不落盘、不进会话**,
|
|
1398
|
+
* 也不给模型看(用户要的是"把我说的变成字",不是"让模型处理我的录音")。
|
|
1399
|
+
* 识别失败一律回 200 + { ok:false, code }:这是"这一句话没听清",
|
|
1400
|
+
* 不是 HTTP 层面的错误,客户端按 code 给不同的话术(换 HTTP 状态码反而会被
|
|
1401
|
+
* 各种中间层改写或吞掉,用户看到的就只剩"请求失败")。
|
|
1402
|
+
*/
|
|
1403
|
+
const handleSpeechTranscribe = (req, res) => {
|
|
1404
|
+
void (async () => {
|
|
1405
|
+
if (req.method !== 'POST') {
|
|
1406
|
+
sendJson(res, 405, { ok: false, error: '只支持 POST' });
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const service = speechService();
|
|
1410
|
+
if (!service) {
|
|
1411
|
+
sendJson(res, 200, {
|
|
1412
|
+
ok: false,
|
|
1413
|
+
code: 'missing',
|
|
1414
|
+
error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
|
|
1415
|
+
});
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
let body = {};
|
|
1419
|
+
try {
|
|
1420
|
+
const raw = await readBody(req, SPEECH_BODY_LIMIT);
|
|
1421
|
+
body = raw ? JSON.parse(raw) : {};
|
|
1422
|
+
}
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
sendJson(res, 200, { ok: false, code: 'transport', error: String(error?.message ?? error) });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const encoded = typeof body.audioBase64 === 'string' ? body.audioBase64 : '';
|
|
1428
|
+
if (!encoded) {
|
|
1429
|
+
sendJson(res, 200, { ok: false, code: 'audio', error: '没有收到音频' });
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
let audio;
|
|
1433
|
+
let seconds = 0;
|
|
1434
|
+
try {
|
|
1435
|
+
if (encoded.length > Math.ceil(SPEECH_MAX_BYTES / 3) * 4)
|
|
1436
|
+
throw new Error('录音太大了');
|
|
1437
|
+
audio = Buffer.from(encoded, 'base64');
|
|
1438
|
+
// 规范 base64:随手传一段别的东西进来会在这一步被挡掉(解出来对不上原文)
|
|
1439
|
+
if (audio.toString('base64') !== encoded)
|
|
1440
|
+
throw new Error('音频不是合法 base64');
|
|
1441
|
+
if (audio.length > SPEECH_MAX_BYTES)
|
|
1442
|
+
throw new Error('录音太大了');
|
|
1443
|
+
seconds = validateWave(audio, SPEECH_MAX_SECONDS);
|
|
1444
|
+
}
|
|
1445
|
+
catch (error) {
|
|
1446
|
+
sendJson(res, 200, { ok: false, code: 'audio', error: String(error?.message ?? error) });
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const catalog = speechCatalog();
|
|
1450
|
+
const providers = catalog.providers ?? [];
|
|
1451
|
+
// 这个部署默认用的语言(识别服务里配的,auto = 自动判断);请求里带了就以请求为准
|
|
1452
|
+
const catalogLanguage = String(catalog.selection?.language ?? '');
|
|
1453
|
+
const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : '';
|
|
1454
|
+
const picked = providers.find((item) => item.id === wanted) ??
|
|
1455
|
+
providers.find((item) => item.phase === 'ready' || item.phase === 'standby') ??
|
|
1456
|
+
providers[0];
|
|
1457
|
+
if (!picked) {
|
|
1458
|
+
sendJson(res, 200, { ok: false, code: 'empty', error: '没有可用的识别器' });
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (picked.phase !== 'ready' && picked.phase !== 'standby') {
|
|
1462
|
+
sendJson(res, 200, {
|
|
1463
|
+
ok: false,
|
|
1464
|
+
code: 'unprepared',
|
|
1465
|
+
providerId: picked.id,
|
|
1466
|
+
phase: picked.phase,
|
|
1467
|
+
error: `识别模型还没准备好(${picked.name}:${picked.phase})`,
|
|
1468
|
+
catalog,
|
|
1469
|
+
});
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
// 客户端断开 / 用户取消 → 立刻掐掉识别(本机模型在跑推理,不能让它白跑)
|
|
1473
|
+
const controller = new AbortController();
|
|
1474
|
+
const timer = setTimeout(() => controller.abort(new Error('识别超时')), SPEECH_TIMEOUT_MS);
|
|
1475
|
+
const onClose = () => controller.abort(new Error('客户端已断开'));
|
|
1476
|
+
req.on('close', onClose);
|
|
1477
|
+
const startedAt = Date.now();
|
|
1478
|
+
try {
|
|
1479
|
+
const language = typeof body.language === 'string' && body.language ? body.language : undefined;
|
|
1480
|
+
const spec = service.resolve({
|
|
1481
|
+
audio,
|
|
1482
|
+
...(wanted ? { providerId: wanted } : {}),
|
|
1483
|
+
...(language === undefined ? {} : { language }),
|
|
1484
|
+
});
|
|
1485
|
+
const transcript = await service.transcribe(spec, controller.signal);
|
|
1486
|
+
const text = String(transcript?.text ?? '').trim();
|
|
1487
|
+
if (!text) {
|
|
1488
|
+
sendJson(res, 200, { ok: false, code: 'empty-transcript', error: '没听清(这段录音里没有识别到内容)' });
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
sendJson(res, 200, {
|
|
1492
|
+
ok: true,
|
|
1493
|
+
text,
|
|
1494
|
+
providerId: picked.id,
|
|
1495
|
+
providerName: picked.name,
|
|
1496
|
+
language: language ?? catalogLanguage,
|
|
1497
|
+
seconds,
|
|
1498
|
+
elapsedMs: Date.now() - startedAt,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
const message = String(error?.message ?? error);
|
|
1503
|
+
const aborted = error?.name === 'AbortError' || controller.signal.aborted;
|
|
1504
|
+
// 超时/断开与"识别失败"分开报:前者是这一句没转成,后者是识别服务本身有问题
|
|
1505
|
+
sendJson(res, 200, {
|
|
1506
|
+
ok: false,
|
|
1507
|
+
code: aborted ? 'aborted' : 'failed',
|
|
1508
|
+
error: aborted ? `这次识别被打断了(${message})` : message,
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
finally {
|
|
1512
|
+
clearTimeout(timer);
|
|
1513
|
+
req.off('close', onClose);
|
|
1514
|
+
}
|
|
1515
|
+
})().catch((error) => {
|
|
1516
|
+
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
|
|
1517
|
+
});
|
|
1518
|
+
};
|
|
1002
1519
|
const handleHistory = (req, res) => {
|
|
1003
1520
|
void (async () => {
|
|
1004
1521
|
await loadHistory();
|
|
@@ -1206,12 +1723,16 @@ export function apply(ctx, rawConfig) {
|
|
|
1206
1723
|
fetchBlocked,
|
|
1207
1724
|
},
|
|
1208
1725
|
sessionContext: config.sessionContext && typeof sessionQuery?.readSurface === 'function',
|
|
1726
|
+
// 侧边栏 HTML 预览里的划词桥开关:客户端据此决定要不要往预览帧注入桥脚本
|
|
1727
|
+
bridgeSidebarPreview: config.bridgeSidebarPreview,
|
|
1209
1728
|
limits: {
|
|
1210
1729
|
maxSelectionChars: config.maxSelectionChars,
|
|
1211
1730
|
maxContextChars: config.maxContextChars,
|
|
1212
1731
|
sessionContextMaxMessages: config.sessionContextMaxMessages,
|
|
1213
1732
|
sessionContextFastMessages: config.sessionContextFastMessages,
|
|
1214
1733
|
sessionContextMaxChars: config.sessionContextMaxChars,
|
|
1734
|
+
quoteContextRounds: config.quoteContextRounds,
|
|
1735
|
+
quoteContextMaxChars: config.quoteContextMaxChars,
|
|
1215
1736
|
},
|
|
1216
1737
|
});
|
|
1217
1738
|
})().catch((error) => {
|
|
@@ -1793,7 +2314,12 @@ export function apply(ctx, rawConfig) {
|
|
|
1793
2314
|
...(sessionId ? { sessionId: sessionId } : {}),
|
|
1794
2315
|
system: undefined,
|
|
1795
2316
|
messages: [
|
|
1796
|
-
|
|
2317
|
+
// 第二参数(组装这段提示词的插件名):**新老版本签名不一样,两边都得能编译**——
|
|
2318
|
+
// · npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的是 0.1.5-rc.3:`(text, plugin)` 两个必填
|
|
2319
|
+
// (CI 就是拿这份类型做构建校验的,少传就 TS2554);
|
|
2320
|
+
// · 本机 / 新版运行时(0.1.7-rc.x):只剩 `(text)`,多传的参数会被忽略。
|
|
2321
|
+
// 所以统一走 systemMessageOf 这个宽松别名(见文件下方的定义),两边都调得动。
|
|
2322
|
+
systemMessageOf(wrapUp
|
|
1797
2323
|
? `${systemPrompt}\n\n【收尾】工具轮次已用完:直接基于已经拿到的信息给出结论,不要再请求工具,也不要复述查询过程。` +
|
|
1798
2324
|
'正文里不要出现任何工具调用格式(包括 DeepSeek 的 DSML 写法,如 invoke/parameter 标记)——那些会被当作乱码展示给用户。'
|
|
1799
2325
|
: systemPrompt, name),
|
|
@@ -2036,7 +2562,8 @@ export function apply(ctx, rawConfig) {
|
|
|
2036
2562
|
...(sessionId ? { sessionId: sessionId } : {}),
|
|
2037
2563
|
system: undefined,
|
|
2038
2564
|
messages: [
|
|
2039
|
-
|
|
2565
|
+
// 同上:多传的插件名在老版本运行时会被忽略,新版本类型上却是必填
|
|
2566
|
+
systemMessageOf(systemPrompt, name),
|
|
2040
2567
|
createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: conclusionUser }] }),
|
|
2041
2568
|
],
|
|
2042
2569
|
...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
|
|
@@ -2143,6 +2670,10 @@ export function apply(ctx, rawConfig) {
|
|
|
2143
2670
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }), `${name}: history route`);
|
|
2144
2671
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/models`, handler: handleModels }), `${name}: models route`);
|
|
2145
2672
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/promote`, handler: handlePromote }), `${name}: promote route`);
|
|
2673
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/quote-context`, handler: handleQuoteContext }), `${name}: quote-context route`);
|
|
2674
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech`, handler: handleSpeech }), `${name}: speech catalog route`);
|
|
2675
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech/transcribe`, handler: handleSpeechTranscribe }), `${name}: speech transcribe route`);
|
|
2676
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech/prepare`, handler: handleSpeechPrepare }), `${name}: speech prepare route`);
|
|
2146
2677
|
ctx.logger?.info?.(`[${name}] 划词解读路由就绪:${API_PREFIX}/analyze`);
|
|
2147
2678
|
}
|
|
2148
2679
|
/**
|