@kmchai/cortico-world-voice 0.2.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.2.1
2
+
3
+ - 新增 World 页的「模型」面板:模型与音色在面板里选,不再占系统 Config tab 的字段。
4
+ - 按服务 `/v1/models` 返回的参数元数据渲染字段:开关、枚举、数字与文本;IndexTTS2 的参数带中文名。
5
+ - 模型专属参数存进 `worlds.voice.modelOptions[model]`,请求时作为 `options` 发给服务;空对象删掉该模型的条目。
6
+ - 面板显示当前模型的提示词片段;目录缓存在进程内,服务不可达时保留上一次结果并报错。
7
+ - 新增 `scripts/build-webui.mjs`(esbuild)与 `dist/webui.js` / `dist/webui.css`。
8
+
1
9
  ## 0.2.0
2
10
 
3
11
  分层:协议、进程托管与 HTTP 网关移到 `@kmchai/cortico-world-service`;语音只保留工具、概率朗读、文本过滤、情绪与标签语义、队列和本机播放,经 HTTP 调服务。
package/README.md CHANGED
@@ -45,6 +45,10 @@ corepack pnpm add "D:/path/to/cortico-world-voice" --ignore-workspace
45
45
 
46
46
  `serviceUrl` 默认为 `http://127.0.0.1:9883`。`serviceTokenSecret` 填部署 `.env` 里的变量名,值只在请求时读取;服务监听非回环地址时必须配令牌。后端条目、模型 profile 与上游密钥都配在服务 World 里,见[服务包 README](https://github.com/Kmch-AI/cortico-world-service#readme)。
47
47
 
48
+ ## 模型面板
49
+
50
+ World 页的「模型」tab 选模型与音色,并按服务 `/v1/models` 返回的参数元数据渲染字段。保存写进 `worlds.voice.modelOptions[model]`,合成时作为 `options` 发给服务;空参数表删掉该模型的条目。模型专属的文本与情绪写法显示在同页的「模型提示词」折叠区。系统 Config tab 只留服务地址、风格、触发、过滤与输出。
51
+
48
52
  ## 工具
49
53
 
50
54
  | 工具 | 作用 |
@@ -95,7 +99,7 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
95
99
  - 情绪来源:工具参数 > 正文里的 `[emotion:开心]` 标记 > `defaultEmotion`。
96
100
  - `voice_style` 读写长期风格(情绪、指令、音色、语速),写回 `worlds.voice`。
97
101
  - 副语言标签:`[Breathing] [Cough] [Sigh] [Laughter] [Surprise-ah] [Surprise-oh] [Dissatisfaction-hnn] [Uhm] [Confirmation-en] [Question-ei] [Question-ah] [Question-en] [Question-oh] [Question-huh]`。
98
- `paralinguistic: auto` 保留标签,由服务里的模型决定演不演;`keep` 一律保留,`strip` 一律删除。表外 `[...]` 会从朗读文本里剥掉,防止被念出来。
102
+ `paralinguistic: auto` 只在当前模型支持标签(Ming 3.0 系)时保留,IndexTTS2 与通用模型剥掉;`keep` 一律保留,`strip` 一律删除。表外 `[...]` 会从朗读文本里剥掉,防止被念出来。
99
103
 
100
104
  ## 文本过滤
101
105
 
@@ -121,8 +125,8 @@ OpenAI 兼容条目把 `instructions` / `emotion` / `speed` 直接放进请求
121
125
  |---|---|
122
126
  | `POST /v1/audio/speech` | 合成音频;体 `{model?, input, voice?, response_format, speed?, instructions?, emotion?}`。 |
123
127
  | `GET /health` | 探活;World 页的服务灯每 15 秒刷新。 |
124
- | `GET /v1/models` | 模型下拉。 |
125
- | `GET /v1/audio/voices?model=` | 音色下拉。 |
128
+ | `GET /v1/models` | 模型、音色与请求参数元数据。 |
129
+ | `GET /v1/audio/voices?model=` | 当前模型的音色表。 |
126
130
 
127
131
  服务不可达时:模型与音色下拉保留上一次读到的表,服务灯变红,`voice_speak` 回失败。语音 0.2.0 不保留直连后端的回退路径。服务不重试上游,语音也不重试。
128
132
 
@@ -149,7 +153,7 @@ pnpm smoke:player out.wav # 本机播放器,音量 0
149
153
  pnpm smoke:chain http://127.0.0.1:9883 # 触发→过滤→经服务合成→tts.voice 全链路
150
154
  ```
151
155
 
152
- 装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.voice`。
156
+ 装进 Cortico 后应看到:World 页多一张「Kmch AI · 语音」卡,服务灯、队列灯与 `service` / `model` / `voice` 徽标;「模型」tab 能选模型与音色、按服务参数渲染字段并保存;服务在线时灯亮;让 bot 调 `voice_speak` 能听见;说「念给我听」会绕过概率;`playback: event` 时时间线出现 `tts.voice`。
153
157
 
154
158
  ## 许可
155
159
 
package/describe.json CHANGED
@@ -126,7 +126,7 @@
126
126
  "key": "worlds.voice.envPrompt",
127
127
  "title": "语音 · 环境提示词",
128
128
  "role": "envPrompt",
129
- "content": "语音由 Voice 提供:它只把文字变成声音,从对方的扬声器念出来,不负责把回复发到聊天渠道;可见的回复仍然走渠道自己的发送工具。\n\n自动朗读现在是 {{voice.auto}}(音色 {{voice.voice}},情绪 {{voice.emotion}},指令 {{voice.instruct}})。自动朗读开着时,普通回复不必调用语音工具;关着时,想出声就调用 voice_speak。\n\n需要出声的时候:\n- 对方明确要语音、要你念出来、要听你的声音时,调用 voice_speak,把要念的正文放进 text。它会绕过概率,必定出声。\n- 这一句需要特定情绪、语气、音色或语速时,调用 voice_speak 并填对应参数。想让同样的语气之后一直生效,调用 voice_style;它影响之后的自动朗读。\n- 某一句只用于别的渠道(例如语音条),不要在本机播放:voice_speak 带 silent: true,音频随回执交给渠道。\n- 对方让你安静、停下时调用 voice_hush。\n\n文本规则:\n- text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。\n- 可以在正文里放 [Laughter]、[Sigh]、[Breathing]、[Uhm]、[Question-ei] 这类副语言标签;模型支持时会被演出来,不会被念出字面。不确定模型是否支持时不要加,免得被原样念出来。\n- 不要把控制标记写进可见的回复正文;语音参数只放在工具调用里。\n"
129
+ "content": "语音由 Voice 提供:它只把文字变成声音,从对方的扬声器念出来,不负责把回复发到聊天渠道;可见的回复仍然走渠道自己的发送工具。\n\n自动朗读现在是 {{voice.auto}}(音色 {{voice.voice}},情绪 {{voice.emotion}},指令 {{voice.instruct}})。自动朗读开着时,普通回复不必调用语音工具;关着时,想出声就调用 voice_speak。\n\n需要出声的时候:\n- 对方明确要语音、要你念出来、要听你的声音时,调用 voice_speak,把要念的正文放进 text。它会绕过概率,必定出声。\n- 这一句需要特定情绪、语气、音色或语速时,调用 voice_speak 并填对应参数。想让同样的语气之后一直生效,调用 voice_style;它影响之后的自动朗读。\n- 某一句只用于别的渠道(例如语音条),不要在本机播放:voice_speak 带 silent: true,音频随回执交给渠道。\n- 对方让你安静、停下时调用 voice_hush。\n\n文本规则:\n- text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。\n- {{voice.modelPrompt}}\n- 不要把控制标记写进可见的回复正文;语音参数只放在工具调用里。\n"
130
130
  }
131
131
  ]
132
132
  }
package/dist/webui.css ADDED
@@ -0,0 +1 @@
1
+ .vx-top{display:flex;flex-wrap:wrap;align-items:end;gap:10px}.vx-pick{display:flex;flex:1 1 160px;min-width:0;flex-direction:column;gap:4px}.vx-pick-label{font:var(--text-detail)/1.2 var(--sans);color:var(--ink-soft)}.vx-pick select.field{width:100%;box-sizing:border-box}.vx-top>.btn{margin-left:auto;align-self:end}.vx-params{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 18px;margin-top:14px}.vx-params:empty{display:none}.vx-field{display:flex;min-width:0;flex-direction:column;gap:5px}.vx-head{display:flex;align-items:baseline;gap:7px}.vx-label{font:13px/1.3 var(--sans);color:var(--ink)}.vx-name{font:11px/1.3 var(--mono);color:var(--ink-soft)}.vx-field>.field,.vx-field>label.check{width:100%;box-sizing:border-box}.vx-desc{font:12px/1.4 var(--sans);color:var(--ink-soft)}.vx-params+.actions{margin-top:14px}.vx-prompt{margin:0;padding:10px 12px;max-height:260px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.55 var(--mono);color:var(--ink-soft);background:var(--sheet-2);border:1px solid var(--line);border-radius:8px}
package/dist/webui.js ADDED
@@ -0,0 +1 @@
1
+ var W={zh:{title:"\u6A21\u578B",model:"\u6A21\u578B",voice:"\u97F3\u8272",refresh:"\u5237\u65B0",save:"\u4FDD\u5B58",saved:"\u5DF2\u4FDD\u5B58",loading:"\u8BFB\u53D6\u4E2D\u2026",promptTitle:"\u6A21\u578B\u63D0\u793A\u8BCD"},en:{title:"\u6A21\u578B",model:"Model",voice:"Voice",refresh:"Refresh",save:"Save",saved:"Saved",loading:"Loading\u2026",promptTitle:"Model prompt"}};function O(e){return e.toLowerCase().startsWith("zh")?W.zh:W.en}var U={emotion_alpha:"\u60C5\u7EEA\u5F3A\u5EA6",emotion_text:"\u60C5\u7EEA\u63CF\u8FF0",use_emotion_text:"\u4F7F\u7528\u6587\u5B57\u60C5\u7EEA",use_random_emotion:"\u968F\u673A\u60C5\u7EEA",duration_scale:"\u65F6\u957F\u7F29\u653E",interval_silence_ms:"\u5206\u6BB5\u9759\u97F3",do_sample:"\u91C7\u6837",top_p:"top_p",top_k:"top_k",temperature:"\u6E29\u5EA6",length_penalty:"\u957F\u5EA6\u60E9\u7F5A",num_beams:"beam \u6570",repetition_penalty:"\u91CD\u590D\u60E9\u7F5A",max_tokens:"\u6700\u5927 token",seed:"\u79CD\u5B50",text_chunk_size:"\u6587\u672C\u5206\u5757\u5927\u5C0F",text_chunk_mode:"\u5206\u5757\u6A21\u5F0F"};function j(e,t){return Object.prototype.hasOwnProperty.call(t,e.name)?t[e.name]:e.default}function C(e){return e==null?"":Array.isArray(e)?e.map(t=>String(t)).join(", "):String(e)}function H(e){return typeof e=="number"&&Number.isFinite(e)?String(e):typeof e=="string"&&e.trim()&&Number.isFinite(Number(e))?e.trim():""}function I(e,t,s){let i=e.ui,r=i.h("div","vx-field"),m=i.h("div","vx-head"),p=U[t.name];p&&p!==t.name?m.append(i.h("span","vx-label",p),i.h("span","vx-name",t.name)):m.append(i.h("span","vx-label",t.name)),r.append(m);let f=j(t,s),u;switch(t.type){case"bool":{let a=i.checkbox("",{checked:f===!0});r.append(a.el),u=()=>a.checked;break}case"enum":{let a=t.values??[],d=C(f),c=[{value:"",label:"\u2014"},...a.map(k=>({value:k,label:k}))];d&&!a.includes(d)&&c.push({value:d,label:d});let b=i.select({options:c,value:d});r.append(b),u=()=>b.value||void 0;break}case"int":case"float":{let a=i.input({type:"number",value:H(f)});t.min!==void 0&&(a.min=String(t.min)),t.max!==void 0&&(a.max=String(t.max)),a.step=t.type==="int"?"1":"any",r.append(a),u=()=>{let d=a.value.trim();if(!d)return;let c=Number(d);if(Number.isFinite(c))return t.type==="int"?Math.trunc(c):c};break}case"string_list":case"path_list":{let a=i.input({type:"text",value:C(f)});r.append(a),u=()=>{let d=a.value.split(/[,,]/).map(c=>c.trim()).filter(Boolean);return d.length>0?d:void 0};break}default:{let a=i.input({type:"text",value:C(f)});r.append(a),u=()=>a.value.trim()||void 0;break}}return t.description&&r.append(i.h("div","vx-desc",t.description)),{param:t,el:r,read:u}}function X(e){let t=O(e.language),s=e.ui,i=null,r="",m=[],p=!1,f=s.sheet({title:t.title}),u=s.h("div","vx-top"),a=s.select({onChange:()=>B(a.value)}),d=s.select({}),c=s.h("label","vx-pick");c.append(s.h("span","vx-pick-label",t.model),a);let b=s.h("label","vx-pick");b.append(s.h("span","vx-pick-label",t.voice),d);let k=s.button(t.refresh,{size:"sm",onClick:()=>{E("refresh")}});u.append(c,b,k);let y=s.h("div","vx-params"),S=s.button(t.save,{variant:"primary",size:"sm",onClick:()=>{F()}}),w=s.msgline(),T=s.actions();T.append(S),f.body.append(u,y,T,w),e.root.append(f.el);let _=s.foldSheet("voice-model-prompt",{title:t.promptTitle,defaultOpen:!1}),z=s.h("pre","vx-prompt");_.body.append(z),e.root.append(_.el);function x(n,o=n){let l=s.h("option");return l.value=n,l.textContent=o,l}function h(n,o){w.textContent=n,w.classList.toggle("bad",o)}function v(){let n=i!==null&&i.models.length>0;a.disabled=p||!n,d.disabled=p||!n||!r,S.disabled=p||!n||!r,k.disabled=p}function L(){let o=i?.models.find(M=>M.id===r)?.voices??[],l=i?.model===r?i.voice:"",g=[...o];l&&!g.includes(l)&&g.unshift(l);let P=g.map(M=>x(M));l||P.unshift(x("","\u2014")),d.replaceChildren(...P.length>0?P:[x("","\u2014")]),d.value=l}function R(){let n=i?.models.find(l=>l.id===r),o=i&&i.model===r?i.options:{};m=[],y.replaceChildren();for(let l of n?.parameters??[]){let g=I(e,l,o);m.push(g),y.append(g.el)}}function B(n){r=n,L(),R(),v()}function A(n){i=n,r=n.model,N(),R(),z.textContent=n.prompt,_.note.textContent=n.model||"\u2014",v(),h(n.error??"",n.error!==void 0)}function N(){let n=i?.models??[],o=n.map(l=>x(l.id,l.label||l.id));!r&&n.length>0?o.unshift(x("","\u2014")):r&&!n.some(l=>l.id===r)&&o.unshift(x(r,r)),a.replaceChildren(...o),a.value=r,L()}function V(){let n={};for(let o of m){let l=o.read();l!==void 0&&(n[o.param.name]=l)}return n}async function E(n){if(!p){p=!0,v(),h(t.loading,!1);try{let o=await e.invoke(n,[]);e.signal.aborted||A(o)}catch(o){e.signal.aborted||h(o instanceof Error?o.message:String(o),!0)}finally{p=!1,e.signal.aborted||v()}}}async function F(){if(p||!i)return;let n={model:r,voice:d.value,options:V()};p=!0,v(),h("",!1);try{let o=await e.invoke("save",[n]);e.signal.aborted||(A(o),h(t.saved,!1))}catch(o){e.signal.aborted||h(o instanceof Error?o.message:String(o),!0)}finally{p=!1,e.signal.aborted||v()}}v(),E("state")}var q={panels:{model:{mount:X}}},Y=q;export{Y as default};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kmchai/cortico-world-voice",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Cortico World: voice tools, probabilistic reading, emotion and paralinguistic tags, text filtering and local playback; speech is synthesized by the Kmch AI service over HTTP",
6
6
  "author": "kmchai",
@@ -49,23 +49,29 @@
49
49
  "requires-extension",
50
50
  "experimental"
51
51
  ],
52
- "describe": "describe.json"
52
+ "describe": "describe.json",
53
+ "webUiClient": "dist/webui.js",
54
+ "webUiStyle": "dist/webui.css"
53
55
  },
54
56
  "files": [
55
57
  "src",
58
+ "dist/webui.js",
59
+ "dist/webui.css",
56
60
  "README.md",
57
61
  "CHANGELOG.md",
58
62
  "describe.json"
59
63
  ],
60
64
  "scripts": {
61
65
  "test": "vitest run",
62
- "typecheck": "tsc --noEmit",
66
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.webui.json",
67
+ "build:webui": "node scripts/build-webui.mjs",
63
68
  "smoke:player": "tsx scripts/smoke-player.ts",
64
69
  "smoke:chain": "tsx scripts/smoke-chain.ts"
65
70
  },
66
71
  "devDependencies": {
67
72
  "@types/node": "^22.10.0",
68
73
  "cortico": "^0.2.12",
74
+ "esbuild": "^0.28.2",
69
75
  "tsx": "^4.19.2",
70
76
  "typescript": "^5.7.2",
71
77
  "vitest": "^2.1.8"
package/src/ENV_PROMPT.md CHANGED
@@ -10,5 +10,5 @@
10
10
 
11
11
  文本规则:
12
12
  - text 只放真正要念的正文,不要放动作描写、Markdown、代码、网址或解释。
13
- - 可以在正文里放 [Laughter]、[Sigh]、[Breathing]、[Uhm]、[Question-ei] 这类副语言标签;模型支持时会被演出来,不会被念出字面。不确定模型是否支持时不要加,免得被原样念出来。
13
+ - {{voice.modelPrompt}}
14
14
  - 不要把控制标记写进可见的回复正文;语音参数只放在工具调用里。
package/src/config.ts CHANGED
@@ -4,10 +4,8 @@ import { pick, type Language, type LanguageTable } from 'cortico/core/language.t
4
4
  import type { VoiceConfigSection } from './types.ts';
5
5
 
6
6
  export const VOICE_ID = 'voice';
7
- /** 配置里 `x-options` 用的动态音色表。 */
8
- export const VOICE_OPTIONS_KIND = 'voice.voices';
9
- /** 配置里 `x-options` 用的动态模型表。 */
10
- export const VOICE_MODELS_KIND = 'voice.models';
7
+ /** World 定义、WebUI 与配置页共用同一个显示名。 */
8
+ export const VOICE_LABEL = 'Kmch AI · 语音';
11
9
 
12
10
  export const VOICE_DEFAULTS: VoiceConfigSection = {
13
11
  enabled: false,
@@ -16,6 +14,7 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
16
14
  serviceTokenSecret: '',
17
15
  model: '',
18
16
  voice: '',
17
+ modelOptions: {},
19
18
  format: 'wav',
20
19
  timeoutMs: 180_000,
21
20
 
@@ -52,19 +51,23 @@ export const VOICE_DEFAULTS: VoiceConfigSection = {
52
51
 
53
52
  const TEXT = {
54
53
  zh: {
55
- label: '语音',
54
+ label: 'Kmch AI · 语音',
56
55
  envTitle: '语音 · 环境提示词',
57
56
  lampService: '语音服务',
58
57
  lampQueue: '朗读队列',
59
58
  servicePage: '服务设置',
59
+ modelPanel: '模型',
60
+ modelSaveShape: '保存失败:参数格式不对。',
61
+ modelSaveModel: '保存失败:模型 id 不能为空。',
62
+ modelSaveVoice: '保存失败:音色必须是字符串。',
63
+ modelSaveOptions: '保存失败:模型参数必须是对象。',
64
+ modelUnknownMethod: '未知的面板方法。',
60
65
  connection: '连接',
61
66
  connectionDesc: '语音服务的 HTTP 地址与令牌。',
62
67
  serviceUrl: '服务地址',
63
68
  serviceUrlDesc: 'Kmch AI · 服务 的网关地址。',
64
69
  serviceTokenSecret: '服务令牌变量名',
65
70
  serviceTokenSecretDesc: '部署 .env 里的变量名,留空表示服务不校验。',
66
- model: '模型',
67
- voice: '音色',
68
71
  format: '音频格式',
69
72
  timeoutMs: '超时(毫秒)',
70
73
  style: '音色与情绪',
@@ -75,7 +78,7 @@ const TEXT = {
75
78
  defaultInstruct: '默认表演指令',
76
79
  speed: '语速',
77
80
  paralinguistic: '副语言标签',
78
- paralinguisticDesc: 'auto 保留,由服务与模型决定;strip 一律从朗读文本里删掉。',
81
+ paralinguisticDesc: 'auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律从朗读文本里删掉。',
79
82
  triggers: '触发',
80
83
  triggersDesc: '',
81
84
  autoSpeak: '自动朗读',
@@ -111,19 +114,23 @@ const TEXT = {
111
114
  shareAudio: '回执带音频附件',
112
115
  },
113
116
  en: {
114
- label: 'Voice',
117
+ label: 'Kmch AI · 语音',
115
118
  envTitle: 'Voice · environment prompt',
116
119
  lampService: 'Speech service',
117
120
  lampQueue: 'Speech queue',
118
121
  servicePage: 'Service settings',
122
+ modelPanel: '模型',
123
+ modelSaveShape: 'Save failed: invalid arguments.',
124
+ modelSaveModel: 'Save failed: the model id must not be empty.',
125
+ modelSaveVoice: 'Save failed: voice must be a string.',
126
+ modelSaveOptions: 'Save failed: model options must be an object.',
127
+ modelUnknownMethod: 'Unknown panel method.',
119
128
  connection: 'Connection',
120
129
  connectionDesc: 'Where the speech service is and which token it expects.',
121
130
  serviceUrl: 'Service URL',
122
131
  serviceUrlDesc: 'Gateway address of Kmch AI · Service.',
123
132
  serviceTokenSecret: 'Service token variable',
124
133
  serviceTokenSecretDesc: 'Name in the deployment .env; empty means the service does not check.',
125
- model: 'Model',
126
- voice: 'Voice',
127
134
  format: 'Audio format',
128
135
  timeoutMs: 'Timeout (ms)',
129
136
  style: 'Voice and emotion',
@@ -134,7 +141,7 @@ const TEXT = {
134
141
  defaultInstruct: 'Default direction',
135
142
  speed: 'Speed',
136
143
  paralinguistic: 'Paralinguistic tags',
137
- paralinguisticDesc: 'auto keeps them and lets the service and model decide; strip removes them from the spoken text.',
144
+ paralinguisticDesc: 'auto keeps them only when the current model supports them; keep always keeps; strip removes them from the spoken text.',
138
145
  triggers: 'Triggers',
139
146
  triggersDesc: '',
140
147
  autoSpeak: 'Automatic speech',
@@ -192,8 +199,6 @@ export function voiceConfigGroups(language: Language): ConfigGroup[] {
192
199
  properties: {
193
200
  [`${p}serviceUrl`]: { type: 'string', title: t.serviceUrl, description: t.serviceUrlDesc, 'x-hot': true },
194
201
  [`${p}serviceTokenSecret`]: { type: 'string', title: t.serviceTokenSecret, description: t.serviceTokenSecretDesc, 'x-hot': true },
195
- [`${p}model`]: { type: 'string', title: t.model, 'x-options': VOICE_MODELS_KIND, 'x-hot': true },
196
- [`${p}voice`]: { type: 'string', title: t.voice, 'x-options': VOICE_OPTIONS_KIND, 'x-hot': true },
197
202
  [`${p}format`]: { type: 'string', title: t.format, enum: ['wav', 'mp3'], 'x-hot': true },
198
203
  [`${p}timeoutMs`]: { type: 'integer', title: t.timeoutMs, minimum: 1000, maximum: 600_000, 'x-hot': true },
199
204
  },
package/src/definition.ts CHANGED
@@ -1,15 +1,13 @@
1
1
  import { describeTools, type WorldDefinition } from 'cortico/world.ts';
2
- import { VOICE_DEFAULTS, VOICE_ID } from './config.ts';
3
- import { currentOptions } from './options.ts';
2
+ import { VOICE_DEFAULTS, VOICE_ID, VOICE_LABEL } from './config.ts';
4
3
  import { VOICE_TOOL_DECLS } from './tools.ts';
5
4
  import type { VoiceConfigSection } from './types.ts';
6
5
  import { VoiceWorld } from './world.ts';
7
6
 
8
7
  export const VOICE: WorldDefinition<VoiceConfigSection> = {
9
8
  id: VOICE_ID,
10
- label: 'Voice',
11
- defaults: () => ({ ...VOICE_DEFAULTS }),
12
- configOptions: (kind) => currentOptions(kind),
9
+ label: VOICE_LABEL,
10
+ defaults: () => ({ ...VOICE_DEFAULTS, modelOptions: {} }),
13
11
  create: (ctx) => new VoiceWorld({
14
12
  cfg: ctx.cfg,
15
13
  timezone: ctx.timezone,
package/src/models.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 模型能力与提示词片段。
3
+ *
4
+ * 服务返回的模型 id 决定两件事:正文里能不能写副语言标签,以及环境提示词
5
+ * 该怎么描述文本与情绪。按 id 前缀匹配,大小写不敏感;空模型与未知模型按
6
+ * 通用 OpenAI 兼容模型处理。
7
+ */
8
+
9
+ interface ModelInfo {
10
+ /** 模型 id 的前缀,小写。 */
11
+ readonly match: readonly string[];
12
+ /** 给人看的名字;未知模型为空。 */
13
+ readonly label: string;
14
+ /** 是否支持 `[Laughter]` 这类副语言标签。 */
15
+ readonly tags: boolean;
16
+ /** 环境提示词里的文本与情绪写法。 */
17
+ readonly prompt: string;
18
+ }
19
+
20
+ const MING3: ModelInfo = {
21
+ match: ['ming3', 'ming-3', 'ming_3'],
22
+ label: 'Ming 3.0',
23
+ tags: true,
24
+ prompt: '支持 [Laughter] [Sigh] [Breathing] [Uhm] [Question-ei] 等副语言标签;情绪可用 instruct/emotion.',
25
+ };
26
+
27
+ const INDEXTTS2: ModelInfo = {
28
+ match: ['index-tts2', 'index_tts2', 'index-tts2.5', 'index_tts2_5', '2.5'],
29
+ label: 'IndexTTS2',
30
+ tags: false,
31
+ prompt: '情绪用自然语言(emotion_text),不要写 [Laughter] 这类方括号标签;采样/时长参数在语音模型页配置,不要写进正文.',
32
+ };
33
+
34
+ const GENERIC: ModelInfo = {
35
+ match: [],
36
+ label: '',
37
+ tags: false,
38
+ prompt: '用 instructions 描述语气;不要用方括号标签,除非确认模型支持.',
39
+ };
40
+
41
+ const KNOWN: readonly ModelInfo[] = [MING3, INDEXTTS2];
42
+
43
+ function normalize(model: string): string {
44
+ return typeof model === 'string' ? model.trim().toLowerCase() : '';
45
+ }
46
+
47
+ function profileOf(model: string): ModelInfo | null {
48
+ const id = normalize(model);
49
+ if (!id) return null;
50
+ return KNOWN.find((info) => info.match.some((prefix) => id.startsWith(prefix))) ?? null;
51
+ }
52
+
53
+ /** 当前模型是否支持副语言标签;未知与空模型按通用模型处理,返回 false。 */
54
+ export function modelSupportsTags(model: string): boolean {
55
+ return profileOf(model)?.tags ?? GENERIC.tags;
56
+ }
57
+
58
+ /** 给人看的模型名;未知或空模型返回空串,由调用方退回模型 id。 */
59
+ export function modelLabel(model: string): string {
60
+ return profileOf(model)?.label ?? '';
61
+ }
62
+
63
+ /** 环境提示词里的文本与情绪写法;空模型与未知模型用通用片段。 */
64
+ export function modelPrompt(model: string): string {
65
+ return (profileOf(model) ?? GENERIC).prompt;
66
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 「模型」面板的数据形状。服务端经由 `World.webUi().invoke` 构造,浏览器端只
3
+ * `import type` 这些声明;面板不接触服务地址与令牌。
4
+ */
5
+ import type { VoiceModel } from './types.ts';
6
+
7
+ export interface PanelModelState {
8
+ /** `worlds.voice.model`。 */
9
+ model: string;
10
+ /** `worlds.voice.voice`。 */
11
+ voice: string;
12
+ /** 当前模型的环境提示词片段,来自 `models.ts`。 */
13
+ prompt: string;
14
+ /** 当前模型已保存的请求参数。 */
15
+ options: Record<string, unknown>;
16
+ /** 服务目录;服务不可达时是上一次可用的结果。 */
17
+ models: VoiceModel[];
18
+ /** 最近一次读取服务目录失败的原因。 */
19
+ error?: string;
20
+ }
21
+
22
+ /** `invoke('model','save', [save])` 的请求体。 */
23
+ export interface PanelModelSave {
24
+ model: string;
25
+ voice: string;
26
+ options: Record<string, unknown>;
27
+ }
28
+
29
+ /** 只认普通对象:数组、null 与类对象都不算。 */
30
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
32
+ const proto = Object.getPrototypeOf(value);
33
+ return proto === Object.prototype || proto === null;
34
+ }
package/src/service.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * 令牌只从 secret() 现读,不写日志、不留副本。
6
6
  */
7
7
  import type {
8
- AudioFormat, ModelOption, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceOption,
8
+ AudioFormat, ModelParameter, ModelParameterType, ServiceHealth, TtsAudio, TtsRequest,
9
+ VoiceConfigSection, VoiceModel, VoiceOption,
9
10
  } from './types.ts';
10
11
 
11
12
  const PROBE_TIMEOUT_MS = 8000;
@@ -37,6 +38,9 @@ export class ServiceClient implements SpeechService {
37
38
  };
38
39
  const model = cfg.model.trim();
39
40
  if (model) body.model = model;
41
+ // 只带当前模型自己的参数;其他模型的配置不混进这次请求。
42
+ const options = cfg.modelOptions?.[model];
43
+ if (options && Object.keys(options).length > 0) body.options = options;
40
44
  const voice = (req.voice ?? '').trim() || cfg.voice.trim();
41
45
  if (voice) body.voice = voice;
42
46
  const instruct = (req.instruct ?? '').trim();
@@ -96,7 +100,7 @@ export class ServiceClient implements SpeechService {
96
100
  }
97
101
  }
98
102
 
99
- async models(): Promise<ModelOption[]> {
103
+ async models(): Promise<VoiceModel[]> {
100
104
  const payload = await this.request(
101
105
  this.endpoint('/v1/models'),
102
106
  { method: 'GET', headers: this.headers() },
@@ -105,9 +109,9 @@ export class ServiceClient implements SpeechService {
105
109
  (res) => this.readJson(res),
106
110
  ) as { data?: unknown } | unknown[] | null;
107
111
  const raw = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : [];
108
- return raw.flatMap((item): ModelOption[] => {
109
- const option = optionOf(item);
110
- return option ? [option] : [];
112
+ return raw.flatMap((item): VoiceModel[] => {
113
+ const model = modelOf(item);
114
+ return model ? [model] : [];
111
115
  });
112
116
  }
113
117
 
@@ -234,6 +238,83 @@ function optionOf(item: unknown): { value: string; label: string } | null {
234
238
  return { value, label };
235
239
  }
236
240
 
241
+ const PARAMETER_TYPES = new Set<ModelParameterType>([
242
+ 'string', 'enum', 'bool', 'int', 'float', 'string_list', 'path', 'path_list',
243
+ ]);
244
+
245
+ function firstString(...values: unknown[]): string {
246
+ for (const value of values) {
247
+ if (typeof value === 'string' && value.trim()) return value.trim();
248
+ }
249
+ return '';
250
+ }
251
+
252
+ /** 目录里的音色/取值可能写成字符串,也可能写成对象;只留去重后的字符串。 */
253
+ function stringItems(value: unknown): string[] {
254
+ if (!Array.isArray(value)) return [];
255
+ const out: string[] = [];
256
+ for (const item of value) {
257
+ const text = typeof item === 'string'
258
+ ? item.trim()
259
+ : item && typeof item === 'object' && !Array.isArray(item)
260
+ ? firstString(
261
+ (item as Record<string, unknown>).value,
262
+ (item as Record<string, unknown>).id,
263
+ (item as Record<string, unknown>).name,
264
+ (item as Record<string, unknown>).label,
265
+ )
266
+ : '';
267
+ if (text && !out.includes(text)) out.push(text);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function finiteNumber(value: unknown): number | undefined {
273
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
274
+ }
275
+
276
+ function parameterOf(item: unknown): ModelParameter | null {
277
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
278
+ const o = item as Record<string, unknown>;
279
+ const name = firstString(o.name);
280
+ const type = firstString(o.type) as ModelParameterType;
281
+ if (!name || !PARAMETER_TYPES.has(type)) return null;
282
+ const description = firstString(o.description);
283
+ const values = stringItems(o.values);
284
+ const min = finiteNumber(o.min);
285
+ const max = finiteNumber(o.max);
286
+ return {
287
+ name,
288
+ type,
289
+ ...(description ? { description } : {}),
290
+ ...(values.length ? { values } : {}),
291
+ ...(o.default !== undefined ? { default: o.default } : {}),
292
+ ...(min !== undefined ? { min } : {}),
293
+ ...(max !== undefined ? { max } : {}),
294
+ };
295
+ }
296
+
297
+ /** 服务目录的一项:模型 id、显示名、音色与请求参数;不认识的字段不带走。 */
298
+ function modelOf(item: unknown): VoiceModel | null {
299
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
300
+ const o = item as Record<string, unknown>;
301
+ const id = firstString(o.id, o.value, o.name);
302
+ if (!id) return null;
303
+ const label = firstString(o.display_name, o.displayName, o.label);
304
+ const parameters = Array.isArray(o.parameters)
305
+ ? o.parameters.flatMap((entry): ModelParameter[] => {
306
+ const parameter = parameterOf(entry);
307
+ return parameter ? [parameter] : [];
308
+ })
309
+ : [];
310
+ return {
311
+ id,
312
+ ...(label ? { label } : {}),
313
+ voices: stringItems(o.voices),
314
+ ...(parameters.length ? { parameters } : {}),
315
+ };
316
+ }
317
+
237
318
  /** 音频回应只接受 wav / mpeg;octet-stream 按请求的格式归一。 */
238
319
  function normalizeMime(raw: string | null, format: AudioFormat): string | null {
239
320
  const type = (raw ?? '').split(';')[0]!.trim().toLowerCase();
@@ -57,10 +57,15 @@ export function stripUnknownTags(text: string): string {
57
57
  }
58
58
 
59
59
  /**
60
- * auto 保留标签,由服务里的模型决定演不演;keep 一律保留;strip 一律删掉。
60
+ * auto 只在当前模型支持标签时保留;keep 一律保留;strip 一律删掉。
61
61
  */
62
- export function keepsParalinguistic(mode: 'auto' | 'keep' | 'strip'): boolean {
63
- return mode !== 'strip';
62
+ export function keepsParalinguistic(
63
+ mode: 'auto' | 'keep' | 'strip',
64
+ tagsSupported: boolean,
65
+ ): boolean {
66
+ if (mode === 'strip') return false;
67
+ if (mode === 'keep') return true;
68
+ return tagsSupported;
64
69
  }
65
70
 
66
71
  const EMOTION_MARKERS: ReadonlyArray<RegExp> = [
@@ -1,4 +1,5 @@
1
1
  /** 把一段回复变成能念的正文:取 `<tts>`、去代码/网址/Markdown/动作标记/符号。 */
2
+ import { modelSupportsTags } from '../models.ts';
2
3
  import type { VoiceConfigSection } from '../types.ts';
3
4
  import {
4
5
  canonicalizeTags,
@@ -86,7 +87,9 @@ export function filterSpeech(raw: string, cfg: VoiceConfigSection): FilteredSpee
86
87
 
87
88
  text = canonicalizeTags(text);
88
89
  text = stripUnknownTags(text);
89
- if (!keepsParalinguistic(cfg.paralinguistic)) text = stripParalinguisticTags(text);
90
+ if (!keepsParalinguistic(cfg.paralinguistic, modelSupportsTags(cfg.model))) {
91
+ text = stripParalinguisticTags(text);
92
+ }
90
93
 
91
94
  if (cfg.filterEmoji) text = text.replace(EMOJI_RE, '').replace(SYMBOL_RE, '');
92
95
 
package/src/types.ts CHANGED
@@ -18,6 +18,8 @@ export interface VoiceConfigSection {
18
18
  serviceTokenSecret: string;
19
19
  model: string;
20
20
  voice: string;
21
+ /** 模型专属的 request 参数,按模型 id 分组;由「模型」面板写回,不进 ConfigGroup。 */
22
+ modelOptions: Record<string, Record<string, unknown>>;
21
23
  format: AudioFormat;
22
24
  timeoutMs: number;
23
25
 
@@ -90,7 +92,36 @@ export interface SelectOption {
90
92
  }
91
93
 
92
94
  export type VoiceOption = SelectOption;
93
- export type ModelOption = SelectOption;
95
+
96
+ /** 服务 `GET /v1/models` 里 `parameters[].type` 的取值。 */
97
+ export type ModelParameterType =
98
+ | 'string'
99
+ | 'enum'
100
+ | 'bool'
101
+ | 'int'
102
+ | 'float'
103
+ | 'string_list'
104
+ | 'path'
105
+ | 'path_list';
106
+
107
+ /** 模型请求参数的元数据;服务按模型给出,面板据此渲染字段。 */
108
+ export interface ModelParameter {
109
+ name: string;
110
+ type: ModelParameterType;
111
+ description?: string;
112
+ values?: string[];
113
+ default?: unknown;
114
+ min?: number;
115
+ max?: number;
116
+ }
117
+
118
+ /** 服务目录里的一项模型;音色与请求参数元数据随模型走。 */
119
+ export interface VoiceModel {
120
+ id: string;
121
+ label?: string;
122
+ voices: string[];
123
+ parameters?: ModelParameter[];
124
+ }
94
125
 
95
126
  /** `GET /health` 的摘要;`ok` 只表示网关有回应,`ready` 表示至少有一个服务能合成。 */
96
127
  export interface ServiceHealth {
package/src/world.ts CHANGED
@@ -14,11 +14,12 @@ import type {
14
14
  Logger, ToolDef, ToolOutcome, World, WorldHost, WorldLamp, WorldWebUiDecl,
15
15
  } from 'cortico/core/types.ts';
16
16
  import { nullLogger, nowIso } from 'cortico/core/util.ts';
17
- import { VOICE_DEFAULTS, VOICE_ID, VOICE_MODELS_KIND, VOICE_OPTIONS_KIND, voiceConfigGroups, voiceText } from './config.ts';
17
+ import { VOICE_DEFAULTS, VOICE_ID, voiceConfigGroups, voiceText } from './config.ts';
18
18
  import type { AudioPlayer } from './audio/player.ts';
19
19
  import { createPlayer } from './audio/player.ts';
20
20
  import { SpeechQueue, type SpeechJob } from './audio/queue.ts';
21
- import { registerOptions, unregisterOptions } from './options.ts';
21
+ import { modelPrompt } from './models.ts';
22
+ import { isPlainObject, type PanelModelState } from './panel.ts';
22
23
  import { ServiceClient, type SpeechService } from './service.ts';
23
24
  import { splitSpeech, truncateSpeech } from './speech/chunk.ts';
24
25
  import { buildInstruct, resolveStyle } from './speech/style.ts';
@@ -26,14 +27,13 @@ import { filterSpeech } from './speech/text.ts';
26
27
  import { EventPoller, eventTypes, hasKeyword, latestUserText, splitList, VoiceTap } from './triggers.ts';
27
28
  import { VOICE_TOOL_DECLS } from './tools.ts';
28
29
  import type {
29
- ModelOption, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceOption,
30
+ ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceModel,
30
31
  } from './types.ts';
31
32
 
32
33
  const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
33
34
  const DEDUPE_WINDOW_MS = 5000;
34
35
  const HEALTH_INTERVAL_MS = 15_000;
35
36
  const HEALTH_TTL_MS = 15_000;
36
- const CATALOG_TTL_MS = 10 * 60_000;
37
37
 
38
38
  export interface VoiceWorldDeps {
39
39
  fetch: typeof fetch;
@@ -130,18 +130,11 @@ export class VoiceWorld implements World {
130
130
  private tempSeq = 0;
131
131
  private serviceState: ServiceState | null = null;
132
132
  private probing = false;
133
- private voices: VoiceOption[] = [];
134
- private voicesAt = 0;
135
- private voicesSignature = '';
136
- private voicesLoading = false;
137
- private models: ModelOption[] = [];
138
- private modelsAt = 0;
139
- private modelsSignature = '';
140
- private modelsLoading = false;
133
+ private catalog: VoiceModel[] | null = null;
134
+ private catalogError: string | undefined;
135
+ private catalogLoad: Promise<void> | null = null;
141
136
  private readonly lastSpoken = new Map<string, number>();
142
137
  private lastFailureAt = 0;
143
- private readonly modelOptions = (): readonly ModelOption[] => this.models;
144
- private readonly voiceOptions = (): readonly VoiceOption[] => this.voices;
145
138
 
146
139
  constructor(private readonly opts: VoiceWorldOptions) {
147
140
  this.log = opts.log ?? nullLogger();
@@ -180,6 +173,7 @@ export class VoiceWorld implements World {
180
173
  'voice.voice': cfg.voice || 'default',
181
174
  'voice.emotion': cfg.defaultEmotion || 'neutral',
182
175
  'voice.instruct': cfg.defaultInstruct || 'none',
176
+ 'voice.modelPrompt': modelPrompt(cfg.model),
183
177
  };
184
178
  }
185
179
 
@@ -224,8 +218,22 @@ export class VoiceWorld implements World {
224
218
  { label: 'model', value: cfg.model || '—', tone: 'plain' },
225
219
  { label: 'voice', value: cfg.voice || '—', tone: 'plain' },
226
220
  ],
221
+ panels: [{ id: 'model', title: t.modelPanel, getMethods: ['state'] }],
227
222
  config: voiceConfigGroups(language),
228
223
  links: [{ label: t.servicePage, href: '#/provider/world:service' }],
224
+ invoke: async (panel, method, args) => {
225
+ if (panel !== 'model') throw new Error(t.modelUnknownMethod);
226
+ switch (method) {
227
+ case 'state':
228
+ return this.modelPanelState();
229
+ case 'refresh':
230
+ return this.refreshModelPanel();
231
+ case 'save':
232
+ return this.saveModelPanel(language, args);
233
+ default:
234
+ throw new Error(t.modelUnknownMethod);
235
+ }
236
+ },
229
237
  promptDocs: [{
230
238
  key: 'worlds.voice.envPrompt',
231
239
  title: t.envTitle,
@@ -237,11 +245,111 @@ export class VoiceWorld implements World {
237
245
  { name: 'voice.voice', description: 'Current voice id.' },
238
246
  { name: 'voice.emotion', description: 'Standing emotion.' },
239
247
  { name: 'voice.instruct', description: 'Standing direction.' },
248
+ { name: 'voice.modelPrompt', description: 'How to write text and emotion for the current model.' },
240
249
  ],
241
250
  }],
242
251
  };
243
252
  }
244
253
 
254
+ // ———————— 模型面板 ————————
255
+
256
+ /**
257
+ * 第一次读面板时拉一次目录;之后直接回缓存。刷新失败保留上一次可用的目录,
258
+ * 只把错误一起交回面板。
259
+ */
260
+ private async modelPanelState(): Promise<PanelModelState> {
261
+ if (!this.catalog) await this.loadCatalog(this.cfg.model);
262
+ return this.panelSnapshot();
263
+ }
264
+
265
+ private async refreshModelPanel(): Promise<PanelModelState> {
266
+ if (this.catalogLoad) await this.catalogLoad;
267
+ await this.loadCatalog(this.cfg.model);
268
+ return this.panelSnapshot();
269
+ }
270
+
271
+ private panelSnapshot(): PanelModelState {
272
+ const cfg = this.cfg;
273
+ const saved = cfg.modelOptions?.[cfg.model];
274
+ return {
275
+ model: cfg.model,
276
+ voice: cfg.voice,
277
+ prompt: modelPrompt(cfg.model),
278
+ options: isPlainObject(saved) ? { ...saved } : {},
279
+ models: this.catalog ?? [],
280
+ ...(this.catalogError ? { error: this.catalogError } : {}),
281
+ };
282
+ }
283
+
284
+ private loadCatalog(selected: string): Promise<void> {
285
+ if (this.catalogLoad) return this.catalogLoad;
286
+ this.catalogLoad = this.fetchCatalog(selected).finally(() => {
287
+ this.catalogLoad = null;
288
+ });
289
+ return this.catalogLoad;
290
+ }
291
+
292
+ /**
293
+ * 先取模型表,再尽力为当前模型补一次更细的音色表。模型表是权威来源,已经带
294
+ * 音色与参数;补音色失败只损失一次补全,不能让整份目录跟着作废。
295
+ */
296
+ private async fetchCatalog(selected: string): Promise<void> {
297
+ let models: VoiceModel[];
298
+ try {
299
+ models = await this.service.models();
300
+ } catch (error) {
301
+ this.catalogError = this.catalogFailure(error);
302
+ this.markService({ ok: false, detail: this.catalogError });
303
+ return;
304
+ }
305
+
306
+ const id = selected.trim() || this.cfg.model.trim() || models[0]?.id || '';
307
+ let catalog = models;
308
+ if (id) {
309
+ try {
310
+ const voices = await this.service.voices(id);
311
+ const names = [...new Set(voices.map((voice) => voice.value.trim()).filter(Boolean))];
312
+ if (names.length > 0) {
313
+ catalog = models.map((model) => (model.id === id ? { ...model, voices: names } : model));
314
+ }
315
+ } catch (error) {
316
+ this.log.debug('音色表读取失败,保留模型表里的音色', {
317
+ err: error instanceof Error ? error.message : String(error),
318
+ });
319
+ }
320
+ }
321
+
322
+ this.catalog = catalog;
323
+ this.catalogError = undefined;
324
+ this.markService({ ok: true });
325
+ }
326
+
327
+ /** 错误要回面板,但不能把配置里的服务地址(可能带令牌)一起带出去。 */
328
+ private catalogFailure(error: unknown): string {
329
+ const message = error instanceof Error ? error.message : String(error);
330
+ const url = this.cfg.serviceUrl.trim();
331
+ return url && message.includes(url) ? message.split(url).join('[service]') : message;
332
+ }
333
+
334
+ private saveModelPanel(language: Language, args: unknown[]): PanelModelState {
335
+ const t = voiceText(language);
336
+ const raw = args[0];
337
+ if (!isPlainObject(raw)) throw new Error(t.modelSaveShape);
338
+ const model = typeof raw.model === 'string' ? raw.model.trim() : '';
339
+ if (!model) throw new Error(t.modelSaveModel);
340
+ if (typeof raw.voice !== 'string') throw new Error(t.modelSaveVoice);
341
+ if (!isPlainObject(raw.options)) throw new Error(t.modelSaveOptions);
342
+
343
+ const next: VoiceConfigSection['modelOptions'] = { ...(this.cfg.modelOptions ?? {}) };
344
+ // 空对象表示删掉这个模型的条目,回到服务的默认参数。
345
+ if (Object.keys(raw.options).length === 0) delete next[model];
346
+ else next[model] = { ...raw.options };
347
+ // 配置层对对象做深合并,删掉的键不会随 patch 传播;先让活引用指向新对象再写盘。
348
+ this.cfg.modelOptions = next;
349
+ this.opts.persist({ model, voice: raw.voice.trim(), modelOptions: next });
350
+ return this.panelSnapshot();
351
+ }
352
+
245
353
  outputTap() {
246
354
  const cfg = this.cfg;
247
355
  if (!cfg.autoSpeak) return undefined;
@@ -252,7 +360,6 @@ export class VoiceWorld implements World {
252
360
  async start(host: WorldHost): Promise<void> {
253
361
  this.host = host;
254
362
  this.started = true;
255
- this.registerCatalogOptions();
256
363
  this.log.info('语音 World 启动', {
257
364
  service: serviceHost(this.cfg.serviceUrl),
258
365
  playback: this.cfg.playback,
@@ -271,7 +378,6 @@ export class VoiceWorld implements World {
271
378
 
272
379
  async stop(): Promise<void> {
273
380
  this.started = false;
274
- this.unregisterCatalogOptions();
275
381
  if (this.healthTimer) clearInterval(this.healthTimer);
276
382
  this.healthTimer = null;
277
383
  this.poller?.stop();
@@ -404,7 +510,6 @@ export class VoiceWorld implements World {
404
510
  }
405
511
  if (Object.keys(patch).length > 0) {
406
512
  this.opts.persist(patch);
407
- if (patch.voice !== undefined) void this.refreshVoices(true);
408
513
  return { text: `Voice style updated. ${this.styleSummary()}` };
409
514
  }
410
515
  return { text: this.styleSummary() };
@@ -527,7 +632,7 @@ export class VoiceWorld implements World {
527
632
  } catch { /* 只归档,失败就只留日志 */ }
528
633
  }
529
634
 
530
- // ———————— 状态与目录 ————————
635
+ // ———————— 状态 ————————
531
636
 
532
637
  private markService(state: Pick<ServiceState, 'ok' | 'detail'>): void {
533
638
  const previous = this.serviceState;
@@ -540,8 +645,6 @@ export class VoiceWorld implements World {
540
645
 
541
646
  private async probe(): Promise<void> {
542
647
  if (this.probing) return;
543
- // 探活会刷新目录;每次探活都重新登记,停用后重新启用的实例也能接上。
544
- this.registerCatalogOptions();
545
648
  this.probing = true;
546
649
  try {
547
650
  const health: ServiceHealth = await this.service.health();
@@ -554,9 +657,6 @@ export class VoiceWorld implements World {
554
657
  } finally {
555
658
  this.probing = false;
556
659
  }
557
- // 探活顺带刷新目录;读不到时 refresh 会保留上一次的表。
558
- await this.refreshVoices(true);
559
- await this.refreshModels(true);
560
660
  }
561
661
 
562
662
  private maybeRefresh(): void {
@@ -565,56 +665,6 @@ export class VoiceWorld implements World {
565
665
  if (!this.probing && (this.serviceState === null || now - this.serviceState.at > HEALTH_TTL_MS)) {
566
666
  void this.probe();
567
667
  }
568
- if (now - this.voicesAt > CATALOG_TTL_MS) void this.refreshVoices();
569
- if (now - this.modelsAt > CATALOG_TTL_MS) void this.refreshModels();
570
- }
571
-
572
- private async refreshVoices(force = false): Promise<void> {
573
- const cfg = this.cfg;
574
- const signature = `${cfg.serviceUrl.trim()}|${cfg.model.trim()}`;
575
- if (this.voicesLoading && !force) return;
576
- if (!force && signature === this.voicesSignature && this.deps.now() - this.voicesAt < CATALOG_TTL_MS) return;
577
- this.voicesLoading = true;
578
- try {
579
- const list = await this.service.voices(cfg.model);
580
- if (signature !== `${this.cfg.serviceUrl.trim()}|${this.cfg.model.trim()}`) return;
581
- this.voices = list;
582
- this.voicesSignature = signature;
583
- this.voicesAt = this.deps.now();
584
- } catch (error) {
585
- this.log.debug('音色表读取失败', { err: error instanceof Error ? error.message : String(error) });
586
- } finally {
587
- this.voicesLoading = false;
588
- }
589
- }
590
-
591
- private async refreshModels(force = false): Promise<void> {
592
- const signature = this.cfg.serviceUrl.trim();
593
- if (this.modelsLoading && !force) return;
594
- if (!force && signature === this.modelsSignature && this.deps.now() - this.modelsAt < CATALOG_TTL_MS) return;
595
- this.modelsLoading = true;
596
- try {
597
- const list = await this.service.models();
598
- if (signature !== this.cfg.serviceUrl.trim()) return;
599
- this.models = list;
600
- this.modelsSignature = signature;
601
- this.modelsAt = this.deps.now();
602
- } catch (error) {
603
- this.log.debug('模型表读取失败', { err: error instanceof Error ? error.message : String(error) });
604
- } finally {
605
- this.modelsLoading = false;
606
- }
607
- }
608
-
609
- /** 下拉读的是缓存表;服务读不到时保留上一次的结果。当前值由框架补入。 */
610
- private registerCatalogOptions(): void {
611
- registerOptions(VOICE_MODELS_KIND, this.modelOptions);
612
- registerOptions(VOICE_OPTIONS_KIND, this.voiceOptions);
613
- }
614
-
615
- private unregisterCatalogOptions(): void {
616
- unregisterOptions(VOICE_MODELS_KIND, this.modelOptions);
617
- unregisterOptions(VOICE_OPTIONS_KIND, this.voiceOptions);
618
668
  }
619
669
 
620
670
  private syncPoller(): void {
@@ -655,4 +705,4 @@ export class VoiceWorld implements World {
655
705
  }
656
706
  }
657
707
 
658
- export { VOICE_DEFAULTS, VOICE_ID, VOICE_MODELS_KIND, VOICE_OPTIONS_KIND };
708
+ export { VOICE_DEFAULTS, VOICE_ID };
package/src/options.ts DELETED
@@ -1,36 +0,0 @@
1
- /**
2
- * 配置页的模型与音色下拉要读运行中实例从服务探到的目录,但
3
- * `WorldDefinition.configOptions` 是静态签名。实例启动或探活时把读取函数注册进来;
4
- * 只支持 `voice.models` 与 `voice.voices` 两种 kind,未知 kind 返回空。
5
- * 读的是已经缓存的表,不在配置页里发请求;服务不可达时保留上次的表,没有就是空表。
6
- * 当前值不在表里时由框架补入,这里不重复做。
7
- */
8
- import { VOICE_MODELS_KIND, VOICE_OPTIONS_KIND } from './config.ts';
9
- import type { SelectOption } from './types.ts';
10
-
11
- export const OPTION_KINDS = [VOICE_MODELS_KIND, VOICE_OPTIONS_KIND] as const;
12
- export type OptionKind = (typeof OPTION_KINDS)[number];
13
-
14
- type Provider = () => readonly SelectOption[];
15
-
16
- const providers = new Map<OptionKind, Provider>();
17
-
18
- function isOptionKind(kind: string): kind is OptionKind {
19
- return kind === VOICE_MODELS_KIND || kind === VOICE_OPTIONS_KIND;
20
- }
21
-
22
- /** 实例启动或探活时登记;同一 kind 后登记的覆盖先前的。 */
23
- export function registerOptions(kind: OptionKind, provider: Provider): void {
24
- providers.set(kind, provider);
25
- }
26
-
27
- /** 实例停止时撤销自己登记的那一项;已有更新实例登记时不动它。 */
28
- export function unregisterOptions(kind: OptionKind, provider: Provider): void {
29
- if (providers.get(kind) === provider) providers.delete(kind);
30
- }
31
-
32
- /** 未知 kind 返回空数组,框架继续查询其他 World。 */
33
- export function currentOptions(kind: string): SelectOption[] {
34
- if (!isOptionKind(kind)) return [];
35
- return [...(providers.get(kind)?.() ?? [])];
36
- }