dsh-audiogen 0.4.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.
@@ -11,7 +11,7 @@ import { audioModelOptions } from './settings-scope.ts'
11
11
  import { tt } from './helpers.ts'
12
12
  import {
13
13
  HISTORY_API,
14
- type AudioMode, type GeneratedAudio, type HistoryEntry, type LibraryEntry,
14
+ type AudioMode, type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry, type LibraryEntry,
15
15
  } from '../protocol.ts'
16
16
  import { AudioPlayer } from './audio-player.tsx'
17
17
  import { LibrarySaveDialog, type SaveDialogContext } from './library-save-dialog.tsx'
@@ -26,6 +26,55 @@ export interface StudioReuse {
26
26
  voiceId?: string
27
27
  }
28
28
 
29
+ /** 每模型可覆盖的参数行(留空 = 自动,沿用上方全局配置)。 */
30
+ const OVERRIDE_ROWS: Array<{ key: string; label: string; type: 'text' | 'number' | 'select'; options?: string[]; placeholder?: string }> = [
31
+ { key: 'format', label: '输出格式', type: 'select', options: ['mp3', 'wav', 'pcm', 'flac', 'ogg'], placeholder: '自动' },
32
+ { key: 'duration', label: '时长(秒)', type: 'number', placeholder: '自动' },
33
+ { key: 'voice', label: '音色', type: 'text', placeholder: '自动' },
34
+ { key: 'speed', label: '语速', type: 'number', placeholder: '自动' },
35
+ { key: 'emotion', label: '情绪', type: 'text', placeholder: '自动' },
36
+ { key: 'sample_rate', label: '采样率', type: 'number', placeholder: '自动' },
37
+ { key: 'bitrate', label: '码率', type: 'number', placeholder: '自动' },
38
+ { key: 'lyrics', label: '歌词', type: 'text', placeholder: '自动' },
39
+ { key: 'seed', label: 'seed', type: 'number', placeholder: '自动' },
40
+ { key: 'steps', label: 'steps', type: 'number', placeholder: '自动' },
41
+ { key: 'cfg_scale', label: 'cfg_scale', type: 'number', placeholder: '自动' },
42
+ ]
43
+
44
+ /** 每模型覆盖值 → 请求字段的数值/类型转换(空值跳过)。 */
45
+ function overrideSpread(override: Record<string, string>): Partial<GenerateAudioRequest> {
46
+ const out: Partial<GenerateAudioRequest> = {}
47
+ const val = override.format?.trim() ?? ''
48
+ if (val !== '') out.format = val
49
+ const num = (key: string): number | undefined => {
50
+ const raw = (override[key] ?? '').trim()
51
+ if (raw === '') return undefined
52
+ const parsed = Number(raw)
53
+ return Number.isFinite(parsed) ? parsed : undefined
54
+ }
55
+ const duration = num('duration')
56
+ if (duration !== undefined) out.duration = duration
57
+ const voice = override.voice?.trim() ?? ''
58
+ if (voice !== '') out.voice = voice
59
+ const speed = num('speed')
60
+ if (speed !== undefined) out.speed = speed
61
+ const emotion = override.emotion?.trim() ?? ''
62
+ if (emotion !== '') out.emotion = emotion
63
+ const sampleRate = num('sample_rate')
64
+ if (sampleRate !== undefined) out.sampleRate = sampleRate
65
+ const bitrate = num('bitrate')
66
+ if (bitrate !== undefined) out.bitrate = bitrate
67
+ const lyrics = override.lyrics?.trim() ?? ''
68
+ if (lyrics !== '') out.lyrics = lyrics
69
+ const seed = num('seed')
70
+ if (seed !== undefined) out.seed = seed
71
+ const steps = num('steps')
72
+ if (steps !== undefined) out.steps = steps
73
+ const cfgScale = num('cfg_scale')
74
+ if (cfgScale !== undefined) out.cfgScale = cfgScale
75
+ return out
76
+ }
77
+
29
78
  function useConfig(scope: AudiogenScope) {
30
79
  const [value, setValue] = useState(scope.getSnapshot().value)
31
80
  useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
@@ -121,6 +170,10 @@ export function StudioView(props: {
121
170
  const [prompt, setPrompt] = useState('')
122
171
  const [previewText, setPreviewText] = useState('')
123
172
  const [model, setModel] = useState('')
173
+ // 多模型对比:同一提示词逐模型生成
174
+ const [selectedModels, setSelectedModels] = useState<string[]>([])
175
+ // 每模型参数覆盖(留空 = 自动沿用全局配置)
176
+ const [overrides, setOverrides] = useState<Record<string, Record<string, string>>>({})
124
177
  const [voice, setVoice] = useState('')
125
178
  const [speed, setSpeed] = useState('')
126
179
  const [duration, setDuration] = useState('')
@@ -140,8 +193,9 @@ export function StudioView(props: {
140
193
  const [audioChannel, setAudioChannel] = useState('')
141
194
  const [subtitle, setSubtitle] = useState(false)
142
195
  const [loading, setLoading] = useState(false)
196
+ const [progress, setProgress] = useState('')
143
197
  const [error, setError] = useState<string | null>(null)
144
- const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
198
+ const [results, setResults] = useState<Array<{ model: string; outputs: GeneratedAudio[]; error?: string }>>([])
145
199
  // 资源库
146
200
  const [saveToLibrary, setSaveToLibrary] = useState(cfg?.autoSaveToLibrary === true)
147
201
  const [savedIds, setSavedIds] = useState<Set<string>>(new Set())
@@ -169,6 +223,33 @@ export function StudioView(props: {
169
223
  .map(entry => entry.alias)
170
224
  }, [modelOptions.models, mode])
171
225
 
226
+ useEffect(() => {
227
+ if (visibleModels.length === 0) return
228
+ setSelectedModels(current => {
229
+ const valid = current.filter(item => visibleModels.includes(item))
230
+ if (valid.length > 0) return valid
231
+ return [visibleModels[0]!]
232
+ })
233
+ }, [visibleModels])
234
+
235
+ // 每模型覆盖值随勾选模型收缩(保留仍在勾选中的覆盖)
236
+ useEffect(() => {
237
+ setOverrides(current => {
238
+ const next: Record<string, Record<string, string>> = {}
239
+ for (const alias of selectedModels) {
240
+ if (current[alias] !== undefined) next[alias] = current[alias]!
241
+ }
242
+ return next
243
+ })
244
+ }, [selectedModels])
245
+
246
+ const setOverrideValue = (model: string, key: string, value: string): void => {
247
+ setOverrides(current => ({
248
+ ...current,
249
+ [model]: { ...(current[model] ?? {}), [key]: value },
250
+ }))
251
+ }
252
+
172
253
  useEffect(() => {
173
254
  if (visibleModels.length > 0 && !visibleModels.includes(model)) {
174
255
  setModel(visibleModels[0]!)
@@ -180,7 +261,10 @@ export function StudioView(props: {
180
261
  if (reuse === undefined || reuse === null) return
181
262
  setMode(reuse.mode)
182
263
  if (reuse.voiceId !== undefined || reuse.voice !== undefined) setVoice(reuse.voiceId ?? reuse.voice ?? '')
183
- if (reuse.model !== undefined && reuse.model !== '') setModel(reuse.model)
264
+ if (reuse.model !== undefined && reuse.model !== '') {
265
+ setModel(reuse.model)
266
+ setSelectedModels([reuse.model])
267
+ }
184
268
  }, [reuse?.nonce])
185
269
 
186
270
  const submit = async (): Promise<void> => {
@@ -188,49 +272,80 @@ export function StudioView(props: {
188
272
  setError(tt('prompt.required'))
189
273
  return
190
274
  }
275
+ // 待生成模型列表:音色设计单渠道;其余支持多模型(同一提示词逐一生成,供对比)。
276
+ const targets = mode === 'voice_design'
277
+ ? ['']
278
+ : selectedModels.length > 0
279
+ ? selectedModels
280
+ : (model !== '' ? [model] : visibleModels.length > 0 ? [visibleModels[0]!] : [])
281
+ if (targets.length === 0) {
282
+ setError('当前模式暂无可用模型')
283
+ return
284
+ }
191
285
  setLoading(true)
192
286
  setError(null)
287
+ setProgress('')
288
+ const gathered: Array<{ model: string; outputs: GeneratedAudio[]; error?: string }> = []
289
+ let anySaved = false
193
290
  try {
194
- const response = await api.generate({
195
- mode,
196
- model: mode === 'voice_design' ? '' : (model || visibleModels[0]) ?? '',
197
- prompt: prompt.trim(),
198
- saveToLibrary,
199
- ...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
200
- ...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
201
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
202
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
203
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
204
- ...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
205
- ...(instrumental ? { isInstrumental: true } : {}),
206
- ...(loop ? { loop: true } : {}),
207
- ...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
208
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
209
- ...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
210
- ...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
211
- ...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
212
- ...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
213
- ...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
214
- ...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
215
- ...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
216
- ...(subtitle ? { subtitleEnable: true } : {}),
217
- })
218
- if (!response.ok) {
219
- setError(response.message ?? '生成失败')
220
- return
291
+ for (const [index, target] of targets.entries()) {
292
+ setProgress(`生成中 ${index + 1}/${targets.length}(${target || '音色设计'})…`)
293
+ let response
294
+ try {
295
+ response = await api.generate({
296
+ mode,
297
+ model: target,
298
+ prompt: prompt.trim(),
299
+ saveToLibrary,
300
+ ...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
301
+ ...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
302
+ ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
303
+ ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
304
+ ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
305
+ ...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
306
+ ...(instrumental ? { isInstrumental: true } : {}),
307
+ ...(loop ? { loop: true } : {}),
308
+ ...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
309
+ ...(format.trim() !== '' ? { format: format.trim() } : {}),
310
+ ...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
311
+ ...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
312
+ ...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
313
+ ...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
314
+ ...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
315
+ ...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
316
+ ...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
317
+ ...(subtitle ? { subtitleEnable: true } : {}),
318
+ // 每模型参数覆盖(后置覆盖全局;仅音色设计外生效)
319
+ ...(target === '' ? {} : overrideSpread(overrides[target] ?? {})),
320
+ })
321
+ } catch (err) {
322
+ gathered.push({ model: target, outputs: [], error: err instanceof Error ? err.message : String(err) })
323
+ continue
324
+ }
325
+ if (!response.ok) {
326
+ gathered.push({ model: target, outputs: [], error: response.message ?? '生成失败' })
327
+ continue
328
+ }
329
+ const generated = response.outputs ?? []
330
+ gathered.push({ model: target, outputs: generated })
331
+ if ((response.resources?.length ?? 0) > 0 && saveToLibrary) anySaved = true
221
332
  }
222
- const generated = response.outputs ?? []
223
- setOutputs(generated)
224
- if ((response.resources?.length ?? 0) > 0 && saveToLibrary) {
225
- setSavedIds(new Set(generated.map(item => item.id)))
333
+ setResults(gathered)
334
+ if (anySaved) {
335
+ setSavedIds(current => new Set([...current, ...gathered.flatMap(group => group.outputs.map(item => item.id))]))
226
336
  props.showToast('已保存到资源库')
227
337
  props.onLibraryChanged()
228
338
  }
339
+ const failed = gathered.filter(group => group.error !== undefined && group.outputs.length === 0)
340
+ if (failed.length > 0 && failed.length === gathered.length) {
341
+ setError(failed.map(group => `「${group.model || '音色设计'}」${group.error}`).join(';'))
342
+ }
229
343
  reload()
230
344
  } catch (err) {
231
345
  setError(err instanceof Error ? err.message : String(err))
232
346
  } finally {
233
347
  setLoading(false)
348
+ setProgress('')
234
349
  }
235
350
  }
236
351
 
@@ -301,14 +416,65 @@ export function StudioView(props: {
301
416
 
302
417
  {needModel ? (
303
418
  <label className={css.label}>
304
- <span>{tt('model.label')}</span>
305
- <select className={css.input} value={model} onChange={event => setModel(event.target.value)}>
306
- {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
307
- {visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
308
- </select>
419
+ <span>{tt('model.label')}(可多选,同一提示词逐个生成以对比)</span>
420
+ <div className={css.modelCheckList}>
421
+ {visibleModels.length === 0 ? <p className={css.hint}>(当前模式暂无可用模型)</p> : null}
422
+ {visibleModels.map(item => (
423
+ <label key={item} className={css.checkbox}>
424
+ <input
425
+ type="checkbox"
426
+ checked={selectedModels.includes(item)}
427
+ onChange={event => {
428
+ const checked = event.target.checked
429
+ setSelectedModels(current => checked ? [...new Set([...current, item])] : current.filter(alias => alias !== item))
430
+ if (checked && (model === '' || !visibleModels.includes(model))) setModel(item)
431
+ }}
432
+ />
433
+ <span title="点击生成对比">{item}</span>
434
+ </label>
435
+ ))}
436
+ </div>
309
437
  </label>
310
438
  ) : null}
311
439
 
440
+ {needModel && selectedModels.length > 0 ? (
441
+ <details className={css.advanced}>
442
+ <summary>每模型参数覆盖(默认自动:沿用上方相同配置,如输出格式均为 mp3)</summary>
443
+ <div className={css.overrideTable}>
444
+ <div className={css.overrideRow}>
445
+ <span className={`${css.overrideCell} ${css.overrideCellHead}`} />
446
+ {selectedModels.map(item => <span key={item} className={`${css.overrideCell} ${css.overrideCellHead}`}>{item}</span>)}
447
+ </div>
448
+ {OVERRIDE_ROWS.map(row => (
449
+ <div key={row.key} className={css.overrideRow}>
450
+ <span className={css.overrideCell} title={row.label}>{row.label}</span>
451
+ {selectedModels.map(item => {
452
+ const value = overrides[item]?.[row.key] ?? ''
453
+ return (
454
+ <span key={item} className={css.overrideCell}>
455
+ {row.type === 'select' ? (
456
+ <select className={css.input} value={value} onChange={event => setOverrideValue(item, row.key, event.target.value)}>
457
+ <option value="">自动</option>
458
+ {row.options!.map(option => <option key={option} value={option}>{option}</option>)}
459
+ </select>
460
+ ) : (
461
+ <input
462
+ className={css.input}
463
+ type={row.type === 'number' ? 'number' : 'text'}
464
+ value={value}
465
+ placeholder={row.placeholder ?? '自动'}
466
+ onChange={event => setOverrideValue(item, row.key, event.target.value)}
467
+ />
468
+ )}
469
+ </span>
470
+ )
471
+ })}
472
+ </div>
473
+ ))}
474
+ </div>
475
+ </details>
476
+ ) : null}
477
+
312
478
  {mode === 'tts' ? (
313
479
  <label className={css.label}>
314
480
  <span>{tt('voice.label')}</span>
@@ -448,13 +614,13 @@ export function StudioView(props: {
448
614
 
449
615
  {!connected && <p className={css.hint}>{tt('config.missing')}</p>}
450
616
  <button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
451
- {loading ? tt('generating') : tt('generate')}
617
+ {loading ? (progress !== '' ? progress : tt('generating')) : tt('generate')}
452
618
  </button>
453
619
  </div>
454
620
 
455
621
  <div className={css.resultCol}>
456
622
  {error !== null ? <p className={css.error}>{error}</p> : null}
457
- {outputs.length === 0 ? (
623
+ {results.length === 0 ? (
458
624
  <div className={css.resultEmpty}>
459
625
  <span className={css.resultEmptyIcon}>🎵</span>
460
626
  <p>{tt('result.empty')}</p>
@@ -463,59 +629,72 @@ export function StudioView(props: {
463
629
  ) : (
464
630
  <>
465
631
  <div className={css.resultMeta}>
466
- <span>{tt('result.done', { count: outputs.length })}</span>
632
+ <span>{tt('result.done', { count: results.reduce((sum, group) => sum + group.outputs.length, 0) })}</span>
467
633
  <span className={css.resultModeChip}>{modeLabel}</span>
468
634
  </div>
469
- <div className={css.audioList}>
470
- {outputs.map((audio, index) => {
471
- const saved = savedIds.has(audio.id)
472
- return (
473
- <div className={css.audioCard} key={audio.id} data-saved={saved ? 'true' : 'false'}>
474
- <div className={css.audioCardHead}>
475
- {audio.voiceId !== undefined ? <span className={css.voiceIdChip} title="新音色 ID">新音色 {audio.voiceId}</span> : null}
476
- {saved ? (
477
- <span className={css.savedChip}><CheckIcon /> 已入库</span>
478
- ) : null}
479
- <span className={css.audioCardIndex}>#{index + 1}</span>
480
- </div>
481
- <AudioPlayer src={dataUrlOf(audio)} itemKey={audio.id} />
482
- <div className={css.audioCardActions}>
483
- <a className={css.ghostButton} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>
484
- <DownloadIcon /> 下载
485
- </a>
486
- {saved ? (
487
- <button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
488
- <CheckIcon /> 已入库
489
- </button>
490
- ) : (
491
- <button
492
- type="button"
493
- className={css.ghostButton}
494
- onClick={() => openSaveDialog([audio], {
495
- mode,
496
- prompt: prompt.trim(),
497
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
498
- ...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
499
- ...(model !== '' ? { model } : {}),
500
- ...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
501
- params: {
502
- mode,
503
- model: mode === 'voice_design' ? '' : (model || visibleModels[0]) ?? '',
504
- prompt: prompt.trim(),
505
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
506
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
507
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
508
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
509
- },
510
- })}
511
- >
512
- <StarIcon /> 加入资源库
513
- </button>
514
- )}
515
- </div>
635
+ <div className={css.resultGroups}>
636
+ {results.map((group, groupIndex) => (
637
+ <div className={css.resultGroup} key={`${group.model}-${groupIndex}`}>
638
+ <div className={css.resultGroupHead}>
639
+ <span className={css.resultGroupChip}>{group.model || '音色设计'}</span>
640
+ {group.error !== undefined ? <span className={css.resultGroupError}>生成失败:{group.error}</span> : null}
641
+ {group.outputs.length > 0 ? <span className={css.resultGroupCount}>{group.outputs.length} 段</span> : null}
516
642
  </div>
517
- )
518
- })}
643
+ {group.outputs.length > 0 ? (
644
+ <div className={css.audioList}>
645
+ {group.outputs.map((audio, index) => {
646
+ const saved = savedIds.has(audio.id)
647
+ return (
648
+ <div className={css.audioCard} key={audio.id} data-saved={saved ? 'true' : 'false'}>
649
+ <div className={css.audioCardHead}>
650
+ {audio.voiceId !== undefined ? <span className={css.voiceIdChip} title="新音色 ID">新音色 {audio.voiceId}</span> : null}
651
+ {saved ? (
652
+ <span className={css.savedChip}><CheckIcon /> 已入库</span>
653
+ ) : null}
654
+ <span className={css.audioCardIndex}>#{index + 1}</span>
655
+ </div>
656
+ <AudioPlayer src={dataUrlOf(audio)} itemKey={audio.id} />
657
+ <div className={css.audioCardActions}>
658
+ <a className={css.ghostButton} href={dataUrlOf(audio)} download={`generated-${groupIndex + 1}-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>
659
+ <DownloadIcon /> 下载
660
+ </a>
661
+ {saved ? (
662
+ <button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
663
+ <CheckIcon /> 已入库
664
+ </button>
665
+ ) : (
666
+ <button
667
+ type="button"
668
+ className={css.ghostButton}
669
+ onClick={() => openSaveDialog([audio], {
670
+ mode,
671
+ prompt: prompt.trim(),
672
+ ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
673
+ ...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
674
+ ...(group.model !== '' ? { model: group.model } : {}),
675
+ ...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
676
+ params: {
677
+ mode,
678
+ model: mode === 'voice_design' ? '' : group.model,
679
+ prompt: prompt.trim(),
680
+ ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
681
+ ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
682
+ ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
683
+ ...(format.trim() !== '' ? { format: format.trim() } : {}),
684
+ },
685
+ })}
686
+ >
687
+ <StarIcon /> 加入资源库
688
+ </button>
689
+ )}
690
+ </div>
691
+ </div>
692
+ )
693
+ })}
694
+ </div>
695
+ ) : null}
696
+ </div>
697
+ ))}
519
698
  </div>
520
699
  </>
521
700
  )}
package/src/index.ts CHANGED
@@ -9,6 +9,9 @@
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
11
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
12
+ import { existsSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { dirname, join } from 'node:path'
12
15
  import z from 'schemastery'
13
16
  import type {} from '@deepseek-ai/dsh-host-webserver'
14
17
  import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -81,6 +84,34 @@ function guidanceFor(channels: AudioChannel[], defaultChannelId: string): string
81
84
  return `${AUDIOGEN_GUIDANCE} 当前渠道与模型:${table}。`
82
85
  }
83
86
 
87
+ /**
88
+ * 把随包分发的技能(skills/<id>/SKILL.md,含 frontmatter)同步到 DSH 用户技能根
89
+ * `~/.dsh/skills/<id>/SKILL.md` —— DSH web 会话的 skill-filesystem(standard 等
90
+ * preset 行)会扫描用户根,使会话可直接触发这些技能。仅创建缺失文件,绝不覆盖
91
+ * 用户已有内容;任何失败仅告警,不影响插件本身。
92
+ */
93
+ function syncBundledSkills(): void {
94
+ try {
95
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))
96
+ const sourceRoot = join(packageRoot, 'skills')
97
+ if (existsSync(sourceRoot) !== true) return
98
+ const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh')
99
+ const targetRoot = join(dshHome, 'skills')
100
+ for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
101
+ if (entry.isDirectory() !== true) continue
102
+ const sourceFile = join(sourceRoot, entry.name, 'SKILL.md')
103
+ if (existsSync(sourceFile) !== true) continue
104
+ const targetDir = join(targetRoot, entry.name)
105
+ const targetFile = join(targetDir, 'SKILL.md')
106
+ if (existsSync(targetFile)) continue
107
+ mkdirSync(targetDir, { recursive: true })
108
+ copyFileSync(sourceFile, targetFile)
109
+ }
110
+ } catch {
111
+ // 技能同步为最佳努力:失败不阻断插件启动。
112
+ }
113
+ }
114
+
84
115
  function normalizeChannels(value: unknown): ChannelConfig[] {
85
116
  if (!Array.isArray(value)) return []
86
117
  const out: ChannelConfig[] = []
@@ -122,6 +153,7 @@ export interface EffectiveConfig {
122
153
  }
123
154
 
124
155
  export function apply(ctx: Context, config?: Config): void {
156
+ syncBundledSkills()
125
157
  let current: () => Config = () => config ?? {}
126
158
 
127
159
  const resolve = (): EffectiveConfig => {
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const AUDIOGEN_SETTINGS_NAMESPACE = 'dsh-audiogen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '0.4.0'
11
+ export const PLUGIN_VERSION = '0.4.1'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
14
14
  export const SETTINGS_API = {