@yfwu2020/dsh-selection-explain 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -4
- package/assets/quotes.png +0 -0
- package/assets/voice-inserted.png +0 -0
- package/assets/voice.png +0 -0
- package/lib/client.js +2623 -68
- package/lib/index.js +525 -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 +56 -0
- package/package.json +6 -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-panel.mjs +481 -0
- package/scripts/smoke-voice.mjs +772 -0
- package/scripts/test-client.mjs +1357 -0
- 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 +2623 -68
- package/src/index.ts +607 -2
- package/src/prompts.ts +5 -0
package/src/index.ts
CHANGED
|
@@ -75,6 +75,15 @@ export interface Config {
|
|
|
75
75
|
sessionContextFastMessages: number
|
|
76
76
|
/** 会话背景字符上限;0 = 不限(默认),只按消息条数取窗口。 */
|
|
77
77
|
sessionContextMaxChars: number
|
|
78
|
+
/**
|
|
79
|
+
* 引用(❝)上下文:引用文字所在的那一组对话 ± 几组(一组 = 用户消息 + 它的助手回复)。
|
|
80
|
+
* 默认 1 = 上下各一组(需求:至少带上引用所在那一组与上下各一组)。
|
|
81
|
+
*/
|
|
82
|
+
quoteContextRounds: number
|
|
83
|
+
/** 引用上下文里单条消息的字数上限(超了**围绕引用**截断,不裁掉引用本身)。 */
|
|
84
|
+
quoteContextMaxCharsPerTurn: number
|
|
85
|
+
/** 引用上下文整段的字数上限(超了丢最早的几组)。 */
|
|
86
|
+
quoteContextMaxChars: number
|
|
78
87
|
/** 共享结果缓存 TTL(毫秒):同会话+同选中+同局部片段+同背景时直接回放;0 = 关闭。 */
|
|
79
88
|
resultCacheTtlMs: number
|
|
80
89
|
/** 是否允许解读过程调用工具(联网搜索等)。 */
|
|
@@ -132,6 +141,9 @@ export const Config = z.object({
|
|
|
132
141
|
sessionContextMaxMessages: z.number().min(1).max(200).default(24),
|
|
133
142
|
sessionContextFastMessages: z.number().min(1).max(200).default(8),
|
|
134
143
|
sessionContextMaxChars: z.number().min(0).max(2000000).default(0),
|
|
144
|
+
quoteContextRounds: z.number().min(0).max(10).default(1),
|
|
145
|
+
quoteContextMaxCharsPerTurn: z.number().min(200).max(20000).default(2000),
|
|
146
|
+
quoteContextMaxChars: z.number().min(400).max(100000).default(6000),
|
|
135
147
|
resultCacheTtlMs: z.number().min(0).max(86400000).default(600000),
|
|
136
148
|
tools: z.boolean().default(true),
|
|
137
149
|
/**
|
|
@@ -527,6 +539,103 @@ function clampText(raw: unknown, max: number): string {
|
|
|
527
539
|
return text.length > max ? `${text.slice(0, max)}\n…(已截断)` : text
|
|
528
540
|
}
|
|
529
541
|
|
|
542
|
+
// ────────────────────────────── 语音输入(小窗麦克风) ──────────────────────────────
|
|
543
|
+
//
|
|
544
|
+
// 小窗的追问输入框加一个麦克风:录一段话 → 转成文字**插进输入框**(不是直接发送,
|
|
545
|
+
// 识别错了还能改)。转写这条链路在 host 这一侧收口:
|
|
546
|
+
//
|
|
547
|
+
// GET ${API_PREFIX}/speech 识别服务目录 + 就绪状态(客户端据此决定麦克风能不能点)
|
|
548
|
+
// POST ${API_PREFIX}/speech/transcribe 一段 16kHz 单声道 PCM16 WAV(base64)→ 文字
|
|
549
|
+
// POST ${API_PREFIX}/speech/prepare 显式准备本地识别模型(首次要下载,必须用户点头)
|
|
550
|
+
//
|
|
551
|
+
// 为什么不让浏览器自己去连识别服务:音频、provider 选择、模型路径全部留在 host,
|
|
552
|
+
// 客户端只拿到"一段话的文字";而且**音频不落盘、不进会话**(转写完即丢)。
|
|
553
|
+
// 识别服务(@deepseek-ai/dsh-experimental-speech-to-text)是实验性的、可以不装:
|
|
554
|
+
// 拿不到该服务就整条链路优雅降级(目录接口回 available:false,麦克风点了只给一句解释)。
|
|
555
|
+
|
|
556
|
+
/** 单次录音时长上限(秒):到了就自动收尾去识别,不让用户对着麦克风发呆。 */
|
|
557
|
+
const SPEECH_MAX_SECONDS = 60
|
|
558
|
+
/** 单条音频的字节上限(≈ 2 分钟 16kHz 单声道 PCM16);与识别服务默认的 4MB 对齐。 */
|
|
559
|
+
const SPEECH_MAX_BYTES = 4 * 1024 * 1024
|
|
560
|
+
/** 识别请求体上限:base64 会膨胀 4/3,再留点余量给 JSON 外壳。 */
|
|
561
|
+
const SPEECH_BODY_LIMIT = Math.ceil(SPEECH_MAX_BYTES / 3) * 4 + 4096
|
|
562
|
+
/** 单次识别的等待上限(毫秒):真机上 60 秒音频几秒内就回来了,这是兜底。 */
|
|
563
|
+
const SPEECH_TIMEOUT_MS = 120_000
|
|
564
|
+
|
|
565
|
+
/** 识别器的就绪状态(只声明我们用到的字段——服务是实验性的,形状可能变)。 */
|
|
566
|
+
interface SpeechPreparationView {
|
|
567
|
+
phase: string
|
|
568
|
+
step?: string
|
|
569
|
+
resource?: string
|
|
570
|
+
completedBytes?: number
|
|
571
|
+
totalBytes?: number
|
|
572
|
+
message?: string
|
|
573
|
+
steps?: ReadonlyArray<{ kind: string; status: string }>
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 一个可选的识别器(本机 SenseVoice / 云端)。 */
|
|
577
|
+
interface SpeechProviderView {
|
|
578
|
+
id: string
|
|
579
|
+
name: string
|
|
580
|
+
location: string
|
|
581
|
+
languages: readonly string[]
|
|
582
|
+
downloadSources?: readonly string[]
|
|
583
|
+
preparation?: SpeechPreparationView
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** ctx.speechToText 的宽松视图(只用到这几个方法)。 */
|
|
587
|
+
interface SpeechToTextLike {
|
|
588
|
+
snapshot: () => {
|
|
589
|
+
providers: readonly SpeechProviderView[]
|
|
590
|
+
selection: { providerId: string; language: string }
|
|
591
|
+
}
|
|
592
|
+
resolve: (request: { audio: Uint8Array; providerId?: string; language?: string }) => unknown
|
|
593
|
+
transcribe: (
|
|
594
|
+
spec: unknown,
|
|
595
|
+
signal: AbortSignal,
|
|
596
|
+
) => Promise<{ text?: unknown; audioSeconds?: unknown; inferenceSeconds?: unknown }>
|
|
597
|
+
prepare: (id: string, options?: { downloadSource?: string }) => void
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* 校验客户端送上来的音频是不是**规范**的 16kHz 单声道 PCM16 WAV。
|
|
602
|
+
*
|
|
603
|
+
* 为什么抠得这么死:这个格式是浏览器端编码器(MediaRecorder → decodeAudioData →
|
|
604
|
+
* OfflineAudioContext 重采样 → 手写 WAV 头)唯一会产出的形状。逐字段对齐之后,
|
|
605
|
+
* "音频不对"就只剩两种可能——客户端版本对不上、或者有人手工构造了这个请求。
|
|
606
|
+
* 光看二进制长度不算数:头里写的采样率/声道/位深必须是那一套,否则识别服务会按
|
|
607
|
+
* 错误的采样率解释这段音频(听起来像快放/慢放),识别结果自然全错。
|
|
608
|
+
*
|
|
609
|
+
* @param audio 完整音频字节
|
|
610
|
+
* @param maxSeconds 允许的最长时长
|
|
611
|
+
* @returns 时长(秒)
|
|
612
|
+
*/
|
|
613
|
+
export function validateWave(audio: Buffer, maxSeconds: number): number {
|
|
614
|
+
if (
|
|
615
|
+
audio.length < 46 ||
|
|
616
|
+
audio.toString('ascii', 0, 4) !== 'RIFF' ||
|
|
617
|
+
audio.toString('ascii', 8, 12) !== 'WAVE' ||
|
|
618
|
+
audio.toString('ascii', 12, 16) !== 'fmt ' ||
|
|
619
|
+
audio.readUInt32LE(16) !== 16 ||
|
|
620
|
+
audio.readUInt16LE(20) !== 1 || // PCM
|
|
621
|
+
audio.readUInt16LE(22) !== 1 || // 单声道
|
|
622
|
+
audio.readUInt32LE(24) !== 16000 || // 16kHz
|
|
623
|
+
audio.readUInt32LE(28) !== 32000 || // 字节率 = 16000 × 1ch × 2B
|
|
624
|
+
audio.readUInt16LE(32) !== 2 || // 块对齐
|
|
625
|
+
audio.readUInt16LE(34) !== 16 || // 16 bit
|
|
626
|
+
audio.toString('ascii', 36, 40) !== 'data' ||
|
|
627
|
+
audio.readUInt32LE(4) !== audio.length - 8 ||
|
|
628
|
+
audio.readUInt32LE(40) !== audio.length - 44 ||
|
|
629
|
+
(audio.length - 44) % 2 !== 0
|
|
630
|
+
) {
|
|
631
|
+
throw new Error('音频格式不是 16kHz 单声道 PCM16 WAV')
|
|
632
|
+
}
|
|
633
|
+
const seconds = (audio.length - 44) / 32000
|
|
634
|
+
if (seconds <= 0) throw new Error('录音是空的')
|
|
635
|
+
if (seconds > maxSeconds) throw new Error(`录音超过 ${maxSeconds} 秒`)
|
|
636
|
+
return seconds
|
|
637
|
+
}
|
|
638
|
+
|
|
530
639
|
/**
|
|
531
640
|
* 「网页模式」的设计规范:从插件自带的 `skills/web-design/SKILL.md` 读(按 mtime 缓存)。
|
|
532
641
|
*
|
|
@@ -719,6 +828,22 @@ async function runTool(
|
|
|
719
828
|
}
|
|
720
829
|
}
|
|
721
830
|
|
|
831
|
+
/**
|
|
832
|
+
* `createSystemMessage` 的**跨版本兼容调用**。
|
|
833
|
+
*
|
|
834
|
+
* 这个函数的签名在两个版本间不一样,而两份类型都得能编译通过:
|
|
835
|
+
* · **0.1.5-rc.3**(npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的就是它,CI 的构建校验用的这份):
|
|
836
|
+
* `createSystemMessage(text, plugin)` —— 两个都必填,少传一个直接 TS2554(发布被卡过一次);
|
|
837
|
+
* · **0.1.7-rc.x**(本机/新运行时):`createSystemMessage(text)` —— 第二参数是历史遗留,传了会被忽略。
|
|
838
|
+
*
|
|
839
|
+
* 所以这里把它当"接受可选第二参数"的函数来调:两个版本都编译得过,运行时也都正确
|
|
840
|
+
* (旧版正好拿到它想要的"组装这段提示词的插件名",新版直接忽略)。
|
|
841
|
+
*/
|
|
842
|
+
const systemMessageOf = createSystemMessage as unknown as (
|
|
843
|
+
text: string,
|
|
844
|
+
plugin?: string,
|
|
845
|
+
) => ReturnType<typeof createSystemMessage>
|
|
846
|
+
|
|
722
847
|
/** 请求里带的会话 id(空串 = 客户端没取到)。 */
|
|
723
848
|
function sessionIdOf(body: AnalyzeBody): string {
|
|
724
849
|
return typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
@@ -832,6 +957,158 @@ export function transcriptOf(events: readonly unknown[], options: BackgroundOpti
|
|
|
832
957
|
return { transcript, marked: picked.some((entry) => entry.marked) }
|
|
833
958
|
}
|
|
834
959
|
|
|
960
|
+
/** 引用上下文的结果。 */
|
|
961
|
+
export interface QuoteContext {
|
|
962
|
+
/** 「用户:…/助手:…」逐行拼好的上下文(引用部分用【】标出);没命中时为空串。 */
|
|
963
|
+
transcript: string
|
|
964
|
+
/** 引用文字是否在会话里定位到了(没定位到 → 客户端退回自己的局部上下文)。 */
|
|
965
|
+
matched: boolean
|
|
966
|
+
/** 带进来的对话组数(一组 = 一条用户消息 + 它的助手回复)。 */
|
|
967
|
+
rounds: number
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** 引用上下文的窗口与长度(默认值:上下各一组、单条 2000 字、总共 6000 字)。 */
|
|
971
|
+
export interface QuoteContextOptions {
|
|
972
|
+
/** 引用前后各取几组对话(默认 1)。 */
|
|
973
|
+
roundsAround?: number
|
|
974
|
+
/** 单条消息的字数上限(超出时**围绕引用**截断,别把引用本身裁掉)。 */
|
|
975
|
+
maxCharsPerTurn?: number
|
|
976
|
+
/** 整段上下文的字数上限(超出时丢最早的那几组)。 */
|
|
977
|
+
maxChars?: number
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** 在文本里找到引用文字并套上【】(直接找不到就按"空白等价"再找一次)。 */
|
|
981
|
+
function markQuoteInText(text: string, marker: string): string {
|
|
982
|
+
if (!text || !marker) return text
|
|
983
|
+
const at = text.indexOf(marker)
|
|
984
|
+
if (at >= 0) return `${text.slice(0, at)}【${marker}】${text.slice(at + marker.length)}`
|
|
985
|
+
// 归一化后匹配:把引用按空白切开,逐段允许任意空白(选中文字常与消息原文差在换行/缩进)
|
|
986
|
+
const tokens = marker.split(/\s+/).filter(Boolean).map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
987
|
+
if (tokens.length === 0) return text
|
|
988
|
+
try {
|
|
989
|
+
const hit = new RegExp(tokens.join('\\s+')).exec(text)
|
|
990
|
+
if (hit && typeof hit.index === 'number') {
|
|
991
|
+
return `${text.slice(0, hit.index)}【${hit[0]}】${text.slice(hit.index + hit[0].length)}`
|
|
992
|
+
}
|
|
993
|
+
} catch {
|
|
994
|
+
/* 正则构造失败(极端输入):不标记,客户端会把原文另起一段补上 */
|
|
995
|
+
}
|
|
996
|
+
return text
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** 围绕引用截断长消息(引用本身必须留下;找不到引用就从头截)。 */
|
|
1000
|
+
function clampAroundQuote(text: string, marker: string, max: number): string {
|
|
1001
|
+
if (text.length <= max) return text
|
|
1002
|
+
const at = marker ? text.indexOf(marker) : -1
|
|
1003
|
+
if (at < 0) return `${text.slice(0, max)}…(已截断)`
|
|
1004
|
+
const half = Math.max(0, Math.floor((max - marker.length) / 2))
|
|
1005
|
+
const from = Math.max(0, at - half)
|
|
1006
|
+
const to = Math.min(text.length, at + marker.length + half)
|
|
1007
|
+
return `${from > 0 ? '…' : ''}${text.slice(from, to)}${to < text.length ? '…' : ''}`
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* **引用上下文**:引用文字所在的那一组对话 ± N 组(一组 = 一条用户消息 + 它的助手回复)。
|
|
1012
|
+
*
|
|
1013
|
+
* 与 `transcriptOf`(解读用的会话背景)的区别,两条都是为引用量身定的:
|
|
1014
|
+
* ① 窗口是**锚点两侧** —— 引用常常是"上面那句"和"下面那句"的对照,只有上文不够;
|
|
1015
|
+
* ② 按**轮**取整 —— 上下各至少一整组(用户消息 + AI 回复),不会切在半个回合上。
|
|
1016
|
+
* 噪音过滤与 `transcriptOf` 完全一致(工具调用/结果、系统提示、harness 注入一律不进)。
|
|
1017
|
+
*/
|
|
1018
|
+
export function quoteContextOf(
|
|
1019
|
+
events: readonly unknown[],
|
|
1020
|
+
marker: string,
|
|
1021
|
+
options: QuoteContextOptions = {},
|
|
1022
|
+
): QuoteContext {
|
|
1023
|
+
const roundsAround = Math.max(0, options.roundsAround ?? 1)
|
|
1024
|
+
const maxCharsPerTurn = Math.max(200, options.maxCharsPerTurn ?? 2000)
|
|
1025
|
+
const maxChars = Math.max(400, options.maxChars ?? 6000)
|
|
1026
|
+
|
|
1027
|
+
const entries: TranscriptEntry[] = []
|
|
1028
|
+
for (const event of events) {
|
|
1029
|
+
if (!event || typeof event !== 'object') continue
|
|
1030
|
+
const typed = event as { type?: unknown; data?: unknown }
|
|
1031
|
+
const data = (typed.data ?? {}) as {
|
|
1032
|
+
message?: { content?: unknown }
|
|
1033
|
+
content?: unknown
|
|
1034
|
+
source?: { kind?: unknown }
|
|
1035
|
+
}
|
|
1036
|
+
let role = ''
|
|
1037
|
+
let content: unknown
|
|
1038
|
+
if (typed.type === 'user/message') {
|
|
1039
|
+
if (data.source && data.source.kind !== 'user') continue
|
|
1040
|
+
role = '用户'
|
|
1041
|
+
content = data.content
|
|
1042
|
+
} else if (typed.type === 'assistant/message') {
|
|
1043
|
+
role = '助手'
|
|
1044
|
+
content = data.message?.content
|
|
1045
|
+
} else {
|
|
1046
|
+
continue
|
|
1047
|
+
}
|
|
1048
|
+
const text = messageText(content)
|
|
1049
|
+
if (!text) continue
|
|
1050
|
+
entries.push({ role, text, marked: false })
|
|
1051
|
+
}
|
|
1052
|
+
if (entries.length === 0) return { transcript: '', matched: false, rounds: 0 }
|
|
1053
|
+
|
|
1054
|
+
const needle = normalizeSpace(marker)
|
|
1055
|
+
let anchor = -1
|
|
1056
|
+
if (needle.length >= 2) {
|
|
1057
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
1058
|
+
if (normalizeSpace(entries[i]?.text ?? '').includes(needle)) {
|
|
1059
|
+
anchor = i
|
|
1060
|
+
break
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (anchor < 0) return { transcript: '', matched: false, rounds: 0 }
|
|
1065
|
+
|
|
1066
|
+
// 按"轮"分组:一条用户消息开一轮(它之前的助手消息归上一轮)
|
|
1067
|
+
const roundOf: number[] = []
|
|
1068
|
+
let round = -1
|
|
1069
|
+
for (const entry of entries) {
|
|
1070
|
+
if (entry.role === '用户' || round < 0) round += 1
|
|
1071
|
+
roundOf.push(round)
|
|
1072
|
+
}
|
|
1073
|
+
const anchorRound = roundOf[anchor] ?? 0
|
|
1074
|
+
const fromRound = Math.max(0, anchorRound - roundsAround)
|
|
1075
|
+
const toRound = Math.min(round, anchorRound + roundsAround)
|
|
1076
|
+
|
|
1077
|
+
const picked: TranscriptEntry[] = []
|
|
1078
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
1079
|
+
const r = roundOf[i] ?? 0
|
|
1080
|
+
if (r < fromRound || r > toRound) continue
|
|
1081
|
+
const entry = entries[i] as TranscriptEntry
|
|
1082
|
+
const isAnchor = i === anchor
|
|
1083
|
+
const raw = isAnchor ? markQuoteInText(entry.text, marker) : entry.text
|
|
1084
|
+
picked.push({
|
|
1085
|
+
role: entry.role,
|
|
1086
|
+
text: clampAroundQuote(raw, marker, maxCharsPerTurn),
|
|
1087
|
+
marked: isAnchor,
|
|
1088
|
+
})
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// 总量上限:超了先丢最早的几组,再丢引用之后的几组(**引用所在那条永远保留**)
|
|
1092
|
+
let used = picked.reduce((sum, entry) => sum + entry.text.length, 0)
|
|
1093
|
+
let droppedBefore = 0
|
|
1094
|
+
let droppedAfter = 0
|
|
1095
|
+
while (picked.length > 1 && used > maxChars && picked[0] && !picked[0].marked) {
|
|
1096
|
+
const first = picked.shift()
|
|
1097
|
+
droppedBefore += 1
|
|
1098
|
+
used -= first ? first.text.length : 0
|
|
1099
|
+
}
|
|
1100
|
+
while (picked.length > 1 && used > maxChars && picked[picked.length - 1] && !picked[picked.length - 1]?.marked) {
|
|
1101
|
+
const last = picked.pop()
|
|
1102
|
+
droppedAfter += 1
|
|
1103
|
+
used -= last ? last.text.length : 0
|
|
1104
|
+
}
|
|
1105
|
+
if (droppedBefore > 0) picked.unshift({ role: '提示', text: `(引用之前还有 ${droppedBefore} 条消息,未包含)`, marked: false })
|
|
1106
|
+
if (droppedAfter > 0) picked.push({ role: '提示', text: `(引用之后还有 ${droppedAfter} 条消息,未包含)`, marked: false })
|
|
1107
|
+
|
|
1108
|
+
const transcript = picked.map((entry) => `${entry.role}:${entry.text}`).join('\n')
|
|
1109
|
+
return { transcript, matched: true, rounds: toRound - fromRound + 1 }
|
|
1110
|
+
}
|
|
1111
|
+
|
|
835
1112
|
/** 注册路由、解析模型路由、转发 LLM 流。 */
|
|
836
1113
|
export function apply(ctx: Context, rawConfig: Config): void {
|
|
837
1114
|
const config: Config = {
|
|
@@ -850,6 +1127,9 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
850
1127
|
sessionContextMaxMessages: rawConfig?.sessionContextMaxMessages ?? 24,
|
|
851
1128
|
sessionContextFastMessages: rawConfig?.sessionContextFastMessages ?? 8,
|
|
852
1129
|
sessionContextMaxChars: rawConfig?.sessionContextMaxChars ?? 0,
|
|
1130
|
+
quoteContextRounds: rawConfig?.quoteContextRounds ?? 1,
|
|
1131
|
+
quoteContextMaxCharsPerTurn: rawConfig?.quoteContextMaxCharsPerTurn ?? 2000,
|
|
1132
|
+
quoteContextMaxChars: rawConfig?.quoteContextMaxChars ?? 6000,
|
|
853
1133
|
resultCacheTtlMs: rawConfig?.resultCacheTtlMs ?? 600000,
|
|
854
1134
|
tools: rawConfig?.tools ?? true,
|
|
855
1135
|
toolNames: rawConfig?.toolNames ?? 'advanced_search,platform_search,read,grep,glob',
|
|
@@ -1214,6 +1494,305 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1214
1494
|
pinned: entry.pinned,
|
|
1215
1495
|
})
|
|
1216
1496
|
|
|
1497
|
+
/**
|
|
1498
|
+
* 引用上下文:客户端点了「❝ 引用」之后,拿引用文字来换它所在的那一组对话 ± 一组。
|
|
1499
|
+
*
|
|
1500
|
+
* 为什么走 host 而不是客户端读 DOM:会话正文的**干净文本**只有 host 这边有
|
|
1501
|
+
* (`readSurface` 的事件流里工具调用/结果、系统提示、harness 注入全都被 `transcriptOf`
|
|
1502
|
+
* 那套规则滤掉过)。客户端 DOM 里读到的会是"渲染后的样子"——思考块、工具卡片、
|
|
1503
|
+
* 按钮文案全混在里面。定位不到(引用来自侧边栏网页/文档预览)就返回 matched:false,
|
|
1504
|
+
* 客户端退回它自己采的局部上下文。
|
|
1505
|
+
*/
|
|
1506
|
+
const handleQuoteContext = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1507
|
+
void (async () => {
|
|
1508
|
+
if (req.method !== 'POST') {
|
|
1509
|
+
sendJson(res, 405, { ok: false, error: '只支持 POST' })
|
|
1510
|
+
return
|
|
1511
|
+
}
|
|
1512
|
+
const raw = await readBody(req, 200_000)
|
|
1513
|
+
let body: { sessionId?: unknown; text?: unknown } = {}
|
|
1514
|
+
try {
|
|
1515
|
+
body = raw ? (JSON.parse(raw) as { sessionId?: unknown; text?: unknown }) : {}
|
|
1516
|
+
} catch {
|
|
1517
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
1518
|
+
return
|
|
1519
|
+
}
|
|
1520
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
1521
|
+
const text = clampText(body.text, config.maxSelectionChars)
|
|
1522
|
+
if (!sessionId || !text.trim()) {
|
|
1523
|
+
sendJson(res, 200, { ok: true, matched: false, context: '', rounds: 0 })
|
|
1524
|
+
return
|
|
1525
|
+
}
|
|
1526
|
+
const events = await readSurfaceEvents(sessionId)
|
|
1527
|
+
const context = quoteContextOf(events, text, {
|
|
1528
|
+
roundsAround: config.quoteContextRounds,
|
|
1529
|
+
maxCharsPerTurn: config.quoteContextMaxCharsPerTurn,
|
|
1530
|
+
maxChars: config.quoteContextMaxChars,
|
|
1531
|
+
})
|
|
1532
|
+
sendJson(res, 200, {
|
|
1533
|
+
ok: true,
|
|
1534
|
+
matched: context.matched,
|
|
1535
|
+
context: context.transcript,
|
|
1536
|
+
rounds: context.rounds,
|
|
1537
|
+
chars: context.transcript.length,
|
|
1538
|
+
})
|
|
1539
|
+
})().catch((error: unknown) => {
|
|
1540
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
1541
|
+
})
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/** ctx.speechToText:装了就给,没装/被停用给 undefined(麦克风据此优雅降级)。 */
|
|
1545
|
+
const speechService = (): SpeechToTextLike | undefined => {
|
|
1546
|
+
try {
|
|
1547
|
+
const service = ctx.get('speechToText') as SpeechToTextLike | undefined
|
|
1548
|
+
return service && typeof service.transcribe === 'function' ? service : undefined
|
|
1549
|
+
} catch {
|
|
1550
|
+
return undefined
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* 语音目录:有哪些识别器、各自什么状态、默认选谁、这次能录多久。
|
|
1556
|
+
*
|
|
1557
|
+
* 客户端据此决定麦克风的形态:能用 / 要先把模型准备好 / 这个部署根本没有识别服务。
|
|
1558
|
+
* 不认识的状态一律当成"不能录"(宁可让用户看到一句解释,也不要录完才发现转不了)。
|
|
1559
|
+
*/
|
|
1560
|
+
const speechCatalog = (): Record<string, unknown> => {
|
|
1561
|
+
const service = speechService()
|
|
1562
|
+
const limits = { maxSeconds: SPEECH_MAX_SECONDS, maxBytes: SPEECH_MAX_BYTES }
|
|
1563
|
+
if (!service) {
|
|
1564
|
+
return {
|
|
1565
|
+
ok: true,
|
|
1566
|
+
available: false,
|
|
1567
|
+
reason: 'missing',
|
|
1568
|
+
error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
|
|
1569
|
+
providers: [],
|
|
1570
|
+
selection: null,
|
|
1571
|
+
limits,
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
let snapshot: ReturnType<SpeechToTextLike['snapshot']>
|
|
1575
|
+
try {
|
|
1576
|
+
snapshot = service.snapshot()
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
return {
|
|
1579
|
+
ok: true,
|
|
1580
|
+
available: false,
|
|
1581
|
+
reason: 'error',
|
|
1582
|
+
error: String((error as Error)?.message ?? error),
|
|
1583
|
+
providers: [],
|
|
1584
|
+
selection: null,
|
|
1585
|
+
limits,
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const providers = (snapshot?.providers ?? []).map((provider) => {
|
|
1589
|
+
const preparation = provider.preparation ?? { phase: 'ready' }
|
|
1590
|
+
return {
|
|
1591
|
+
id: String(provider.id),
|
|
1592
|
+
name: String(provider.name ?? provider.id),
|
|
1593
|
+
location: String(provider.location ?? ''),
|
|
1594
|
+
languages: Array.isArray(provider.languages) ? provider.languages.map(String) : [],
|
|
1595
|
+
// 下载源:本地模型已经装好时是空数组(没有可选项),装了才有得选
|
|
1596
|
+
downloadSources: Array.isArray(provider.downloadSources) ? provider.downloadSources.map(String) : [],
|
|
1597
|
+
phase: String(preparation.phase ?? 'unprepared'),
|
|
1598
|
+
step: preparation.step ? String(preparation.step) : '',
|
|
1599
|
+
// 下载进度(只在 downloading 时有意义):客户端显示"还要下多少"
|
|
1600
|
+
completedBytes: typeof preparation.completedBytes === 'number' ? preparation.completedBytes : 0,
|
|
1601
|
+
totalBytes: typeof preparation.totalBytes === 'number' ? preparation.totalBytes : 0,
|
|
1602
|
+
message: preparation.message ? String(preparation.message) : '',
|
|
1603
|
+
}
|
|
1604
|
+
})
|
|
1605
|
+
const selection = snapshot?.selection
|
|
1606
|
+
? { providerId: String(snapshot.selection.providerId), language: String(snapshot.selection.language) }
|
|
1607
|
+
: null
|
|
1608
|
+
// 能录 = 至少有一个识别器已经就绪(standby 也算:本机模型在磁盘上,用到才唤醒)
|
|
1609
|
+
const usable = providers.filter((provider) => provider.phase === 'ready' || provider.phase === 'standby')
|
|
1610
|
+
return {
|
|
1611
|
+
ok: true,
|
|
1612
|
+
available: usable.length > 0,
|
|
1613
|
+
reason: usable.length > 0 ? '' : providers.length > 0 ? 'unprepared' : 'empty',
|
|
1614
|
+
error: '',
|
|
1615
|
+
providers,
|
|
1616
|
+
selection,
|
|
1617
|
+
limits,
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const handleSpeech = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1622
|
+
if (req.method !== 'GET') {
|
|
1623
|
+
sendJson(res, 405, { ok: false, error: '只支持 GET' })
|
|
1624
|
+
return
|
|
1625
|
+
}
|
|
1626
|
+
sendJson(res, 200, speechCatalog())
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* 显式准备识别模型(首次要下 1G 左右的模型,所以**只有用户点了才做**,不自动触发)。
|
|
1631
|
+
* 只是把任务交给识别服务,进度走目录接口轮询(下载在 host 上跑,关掉页面也继续)。
|
|
1632
|
+
*/
|
|
1633
|
+
const handleSpeechPrepare = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1634
|
+
void (async () => {
|
|
1635
|
+
if (req.method !== 'POST') {
|
|
1636
|
+
sendJson(res, 405, { ok: false, error: '只支持 POST' })
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
const service = speechService()
|
|
1640
|
+
if (!service) {
|
|
1641
|
+
sendJson(res, 200, { ok: false, error: '这个 DSH 没有启用语音识别服务' })
|
|
1642
|
+
return
|
|
1643
|
+
}
|
|
1644
|
+
let body: { providerId?: unknown; downloadSource?: unknown } = {}
|
|
1645
|
+
try {
|
|
1646
|
+
const raw = await readBody(req, 20_000)
|
|
1647
|
+
body = raw ? (JSON.parse(raw) as { providerId?: unknown; downloadSource?: unknown }) : {}
|
|
1648
|
+
} catch {
|
|
1649
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
1650
|
+
return
|
|
1651
|
+
}
|
|
1652
|
+
const catalog = speechCatalog()
|
|
1653
|
+
const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : ''
|
|
1654
|
+
const providers = (catalog.providers as Array<{ id: string; phase: string }>) ?? []
|
|
1655
|
+
const target = wanted || (providers.find((item) => item.phase !== 'ready' && item.phase !== 'standby')?.id ?? providers[0]?.id ?? '')
|
|
1656
|
+
if (!target) {
|
|
1657
|
+
sendJson(res, 200, { ok: false, error: '没有可用的识别器' })
|
|
1658
|
+
return
|
|
1659
|
+
}
|
|
1660
|
+
try {
|
|
1661
|
+
const source = typeof body.downloadSource === 'string' && body.downloadSource ? body.downloadSource : undefined
|
|
1662
|
+
service.prepare(target, source === undefined ? undefined : { downloadSource: source })
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
// 已经在准备中 / 下载源不认 / 服务生命周期已结束:都不算致命,把状态回给客户端自己看
|
|
1665
|
+
sendJson(res, 200, { ok: false, error: String((error as Error)?.message ?? error), catalog: speechCatalog() })
|
|
1666
|
+
return
|
|
1667
|
+
}
|
|
1668
|
+
sendJson(res, 200, { ok: true, providerId: target, catalog: speechCatalog() })
|
|
1669
|
+
})().catch((error: unknown) => {
|
|
1670
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
1671
|
+
})
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* 转写一段录音。
|
|
1676
|
+
*
|
|
1677
|
+
* 输入是浏览器端编码好的规范 WAV(base64),输出就是一段文字——**不落盘、不进会话**,
|
|
1678
|
+
* 也不给模型看(用户要的是"把我说的变成字",不是"让模型处理我的录音")。
|
|
1679
|
+
* 识别失败一律回 200 + { ok:false, code }:这是"这一句话没听清",
|
|
1680
|
+
* 不是 HTTP 层面的错误,客户端按 code 给不同的话术(换 HTTP 状态码反而会被
|
|
1681
|
+
* 各种中间层改写或吞掉,用户看到的就只剩"请求失败")。
|
|
1682
|
+
*/
|
|
1683
|
+
const handleSpeechTranscribe = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1684
|
+
void (async () => {
|
|
1685
|
+
if (req.method !== 'POST') {
|
|
1686
|
+
sendJson(res, 405, { ok: false, error: '只支持 POST' })
|
|
1687
|
+
return
|
|
1688
|
+
}
|
|
1689
|
+
const service = speechService()
|
|
1690
|
+
if (!service) {
|
|
1691
|
+
sendJson(res, 200, {
|
|
1692
|
+
ok: false,
|
|
1693
|
+
code: 'missing',
|
|
1694
|
+
error: '这个 DSH 没有启用语音识别服务(缺 @deepseek-ai/dsh-experimental-speech-to-text)',
|
|
1695
|
+
})
|
|
1696
|
+
return
|
|
1697
|
+
}
|
|
1698
|
+
let body: { audioBase64?: unknown; language?: unknown; providerId?: unknown } = {}
|
|
1699
|
+
try {
|
|
1700
|
+
const raw = await readBody(req, SPEECH_BODY_LIMIT)
|
|
1701
|
+
body = raw ? (JSON.parse(raw) as typeof body) : {}
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
sendJson(res, 200, { ok: false, code: 'transport', error: String((error as Error)?.message ?? error) })
|
|
1704
|
+
return
|
|
1705
|
+
}
|
|
1706
|
+
const encoded = typeof body.audioBase64 === 'string' ? body.audioBase64 : ''
|
|
1707
|
+
if (!encoded) {
|
|
1708
|
+
sendJson(res, 200, { ok: false, code: 'audio', error: '没有收到音频' })
|
|
1709
|
+
return
|
|
1710
|
+
}
|
|
1711
|
+
let audio: Buffer
|
|
1712
|
+
let seconds = 0
|
|
1713
|
+
try {
|
|
1714
|
+
if (encoded.length > Math.ceil(SPEECH_MAX_BYTES / 3) * 4) throw new Error('录音太大了')
|
|
1715
|
+
audio = Buffer.from(encoded, 'base64')
|
|
1716
|
+
// 规范 base64:随手传一段别的东西进来会在这一步被挡掉(解出来对不上原文)
|
|
1717
|
+
if (audio.toString('base64') !== encoded) throw new Error('音频不是合法 base64')
|
|
1718
|
+
if (audio.length > SPEECH_MAX_BYTES) throw new Error('录音太大了')
|
|
1719
|
+
seconds = validateWave(audio, SPEECH_MAX_SECONDS)
|
|
1720
|
+
} catch (error) {
|
|
1721
|
+
sendJson(res, 200, { ok: false, code: 'audio', error: String((error as Error)?.message ?? error) })
|
|
1722
|
+
return
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
const catalog = speechCatalog()
|
|
1726
|
+
const providers = (catalog.providers as Array<{ id: string; phase: string; name: string }>) ?? []
|
|
1727
|
+
// 这个部署默认用的语言(识别服务里配的,auto = 自动判断);请求里带了就以请求为准
|
|
1728
|
+
const catalogLanguage = String((catalog.selection as { language?: string } | null)?.language ?? '')
|
|
1729
|
+
const wanted = typeof body.providerId === 'string' && body.providerId ? body.providerId : ''
|
|
1730
|
+
const picked =
|
|
1731
|
+
providers.find((item) => item.id === wanted) ??
|
|
1732
|
+
providers.find((item) => item.phase === 'ready' || item.phase === 'standby') ??
|
|
1733
|
+
providers[0]
|
|
1734
|
+
if (!picked) {
|
|
1735
|
+
sendJson(res, 200, { ok: false, code: 'empty', error: '没有可用的识别器' })
|
|
1736
|
+
return
|
|
1737
|
+
}
|
|
1738
|
+
if (picked.phase !== 'ready' && picked.phase !== 'standby') {
|
|
1739
|
+
sendJson(res, 200, {
|
|
1740
|
+
ok: false,
|
|
1741
|
+
code: 'unprepared',
|
|
1742
|
+
providerId: picked.id,
|
|
1743
|
+
phase: picked.phase,
|
|
1744
|
+
error: `识别模型还没准备好(${picked.name}:${picked.phase})`,
|
|
1745
|
+
catalog,
|
|
1746
|
+
})
|
|
1747
|
+
return
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// 客户端断开 / 用户取消 → 立刻掐掉识别(本机模型在跑推理,不能让它白跑)
|
|
1751
|
+
const controller = new AbortController()
|
|
1752
|
+
const timer = setTimeout(() => controller.abort(new Error('识别超时')), SPEECH_TIMEOUT_MS)
|
|
1753
|
+
const onClose = (): void => controller.abort(new Error('客户端已断开'))
|
|
1754
|
+
req.on('close', onClose)
|
|
1755
|
+
const startedAt = Date.now()
|
|
1756
|
+
try {
|
|
1757
|
+
const language = typeof body.language === 'string' && body.language ? body.language : undefined
|
|
1758
|
+
const spec = service.resolve({
|
|
1759
|
+
audio,
|
|
1760
|
+
...(wanted ? { providerId: wanted } : {}),
|
|
1761
|
+
...(language === undefined ? {} : { language }),
|
|
1762
|
+
})
|
|
1763
|
+
const transcript = await service.transcribe(spec, controller.signal)
|
|
1764
|
+
const text = String(transcript?.text ?? '').trim()
|
|
1765
|
+
if (!text) {
|
|
1766
|
+
sendJson(res, 200, { ok: false, code: 'empty-transcript', error: '没听清(这段录音里没有识别到内容)' })
|
|
1767
|
+
return
|
|
1768
|
+
}
|
|
1769
|
+
sendJson(res, 200, {
|
|
1770
|
+
ok: true,
|
|
1771
|
+
text,
|
|
1772
|
+
providerId: picked.id,
|
|
1773
|
+
providerName: picked.name,
|
|
1774
|
+
language: language ?? catalogLanguage,
|
|
1775
|
+
seconds,
|
|
1776
|
+
elapsedMs: Date.now() - startedAt,
|
|
1777
|
+
})
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
const message = String((error as Error)?.message ?? error)
|
|
1780
|
+
const aborted = (error as Error)?.name === 'AbortError' || controller.signal.aborted
|
|
1781
|
+
// 超时/断开与"识别失败"分开报:前者是这一句没转成,后者是识别服务本身有问题
|
|
1782
|
+
sendJson(res, 200, {
|
|
1783
|
+
ok: false,
|
|
1784
|
+
code: aborted ? 'aborted' : 'failed',
|
|
1785
|
+
error: aborted ? `这次识别被打断了(${message})` : message,
|
|
1786
|
+
})
|
|
1787
|
+
} finally {
|
|
1788
|
+
clearTimeout(timer)
|
|
1789
|
+
req.off('close', onClose)
|
|
1790
|
+
}
|
|
1791
|
+
})().catch((error: unknown) => {
|
|
1792
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
1793
|
+
})
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1217
1796
|
const handleHistory = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1218
1797
|
void (async () => {
|
|
1219
1798
|
await loadHistory()
|
|
@@ -1425,6 +2004,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1425
2004
|
sessionContextMaxMessages: config.sessionContextMaxMessages,
|
|
1426
2005
|
sessionContextFastMessages: config.sessionContextFastMessages,
|
|
1427
2006
|
sessionContextMaxChars: config.sessionContextMaxChars,
|
|
2007
|
+
quoteContextRounds: config.quoteContextRounds,
|
|
2008
|
+
quoteContextMaxChars: config.quoteContextMaxChars,
|
|
1428
2009
|
},
|
|
1429
2010
|
})
|
|
1430
2011
|
})().catch((error: unknown) => {
|
|
@@ -2069,7 +2650,12 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2069
2650
|
...(sessionId ? { sessionId: sessionId as never } : {}),
|
|
2070
2651
|
system: undefined,
|
|
2071
2652
|
messages: [
|
|
2072
|
-
|
|
2653
|
+
// 第二参数(组装这段提示词的插件名):**新老版本签名不一样,两边都得能编译**——
|
|
2654
|
+
// · npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的是 0.1.5-rc.3:`(text, plugin)` 两个必填
|
|
2655
|
+
// (CI 就是拿这份类型做构建校验的,少传就 TS2554);
|
|
2656
|
+
// · 本机 / 新版运行时(0.1.7-rc.x):只剩 `(text)`,多传的参数会被忽略。
|
|
2657
|
+
// 所以统一走 systemMessageOf 这个宽松别名(见文件下方的定义),两边都调得动。
|
|
2658
|
+
systemMessageOf(
|
|
2073
2659
|
wrapUp
|
|
2074
2660
|
? `${systemPrompt}\n\n【收尾】工具轮次已用完:直接基于已经拿到的信息给出结论,不要再请求工具,也不要复述查询过程。` +
|
|
2075
2661
|
'正文里不要出现任何工具调用格式(包括 DeepSeek 的 DSML 写法,如 invoke/parameter 标记)——那些会被当作乱码展示给用户。'
|
|
@@ -2310,7 +2896,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2310
2896
|
...(sessionId ? { sessionId: sessionId as never } : {}),
|
|
2311
2897
|
system: undefined,
|
|
2312
2898
|
messages: [
|
|
2313
|
-
|
|
2899
|
+
// 同上:多传的插件名在老版本运行时会被忽略,新版本类型上却是必填
|
|
2900
|
+
systemMessageOf(systemPrompt, name),
|
|
2314
2901
|
createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: conclusionUser }] }),
|
|
2315
2902
|
],
|
|
2316
2903
|
...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
|
|
@@ -2425,6 +3012,24 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2425
3012
|
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/promote`, handler: handlePromote }),
|
|
2426
3013
|
`${name}: promote route`,
|
|
2427
3014
|
)
|
|
3015
|
+
ctx.effect(
|
|
3016
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/quote-context`, handler: handleQuoteContext }),
|
|
3017
|
+
`${name}: quote-context route`,
|
|
3018
|
+
)
|
|
3019
|
+
ctx.effect(
|
|
3020
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech`, handler: handleSpeech }),
|
|
3021
|
+
`${name}: speech catalog route`,
|
|
3022
|
+
)
|
|
3023
|
+
ctx.effect(
|
|
3024
|
+
() =>
|
|
3025
|
+
ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech/transcribe`, handler: handleSpeechTranscribe }),
|
|
3026
|
+
`${name}: speech transcribe route`,
|
|
3027
|
+
)
|
|
3028
|
+
ctx.effect(
|
|
3029
|
+
() =>
|
|
3030
|
+
ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/speech/prepare`, handler: handleSpeechPrepare }),
|
|
3031
|
+
`${name}: speech prepare route`,
|
|
3032
|
+
)
|
|
2428
3033
|
|
|
2429
3034
|
ctx.logger?.info?.(`[${name}] 划词解读路由就绪:${API_PREFIX}/analyze`)
|
|
2430
3035
|
}
|