dsh-audiogen 0.4.2 → 0.4.4

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/lib/index.js CHANGED
@@ -3035,7 +3035,7 @@ const Config = z.object({
3035
3035
  defaultChannelId: z.string().default(""),
3036
3036
  defaultModel: z.string().default(""),
3037
3037
  autoSaveToLibrary: z.boolean().default(false),
3038
- maxConcurrentGenerations: z.number().default(DEFAULT_MAX_CONCURRENT)
3038
+ maxConcurrentGenerations: z.union([z.number(), z.string()]).default(DEFAULT_MAX_CONCURRENT)
3039
3039
  });
3040
3040
  const DEFAULT_ENABLED = true;
3041
3041
  const DEFAULT_ANNOUNCE = true;
@@ -3129,7 +3129,11 @@ function apply(ctx, config) {
3129
3129
  defaultChannelId,
3130
3130
  defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : "",
3131
3131
  autoSaveToLibrary: value.autoSaveToLibrary === true,
3132
- maxConcurrentGenerations: typeof value.maxConcurrentGenerations === "number" && Number.isFinite(value.maxConcurrentGenerations) ? Math.max(1, Math.min(20, Math.floor(value.maxConcurrentGenerations))) : DEFAULT_MAX_CONCURRENT
3132
+ maxConcurrentGenerations: (() => {
3133
+ const rawMax = value.maxConcurrentGenerations;
3134
+ const parsedMax = typeof rawMax === "number" ? rawMax : typeof rawMax === "string" && rawMax.trim() !== "" ? Number(rawMax.trim()) : NaN;
3135
+ return Number.isFinite(parsedMax) ? Math.max(1, Math.min(20, Math.floor(parsedMax))) : DEFAULT_MAX_CONCURRENT;
3136
+ })()
3133
3137
  };
3134
3138
  };
3135
3139
  const budget = createGenerationBudget(() => resolve().maxConcurrentGenerations);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-audiogen",
3
3
  "description": "AI audio generation plugin for the dsh web GUI: multi-vendor TTS/music/sound-effect channels (OpenAI-compatible, ElevenLabs, MiniMax, Stability AI and custom), per-channel model/voice catalogs, Agent tool and a sidebar AI 音频 panel.",
4
- "version": "0.4.2",
4
+ "version": "0.4.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -737,7 +737,12 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
737
737
  className={css.input}
738
738
  value={state.maxConcurrentGenerations.text}
739
739
  disabled={!state.writable}
740
- onChange={event => props.edit('maxConcurrentGenerations', event.target.value)}
740
+ onChange={event => {
741
+ const raw = event.target.value
742
+ const parsed = Number(raw)
743
+ const value = raw === '' || !Number.isFinite(parsed) ? '' : String(Math.max(1, Math.min(20, Math.floor(parsed))))
744
+ props.edit('maxConcurrentGenerations', value)
745
+ }}
741
746
  />
742
747
  </label>
743
748
  </div>
package/src/client/api.ts CHANGED
@@ -48,6 +48,13 @@ export class AudiogenApi {
48
48
  return body.ok === true ? (body.history ?? []) : []
49
49
  }
50
50
 
51
+ /** 删除一条历史记录(返回删后的列表)。 */
52
+ async removeHistory(id: string): Promise<HistoryEntry[]> {
53
+ const response = await postJson(HISTORY_API.remove, { id })
54
+ const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
55
+ return body.ok === true ? (body.history ?? []) : []
56
+ }
57
+
51
58
  async clearHistory(): Promise<void> {
52
59
  await postJson(HISTORY_API.clear, {})
53
60
  }
@@ -953,7 +953,7 @@
953
953
  font-weight: 600;
954
954
  padding: 3px 10px;
955
955
  border-radius: 999px;
956
- background: var(--dsw-alias-bg-hover, #f3f4f6);
956
+ background: var(--dsw-alias-interactive-bg-hover-accent, rgba(38, 49, 72, 0.14));
957
957
  border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
958
958
  color: var(--dsw-alias-label-primary, #1f2328);
959
959
  }
@@ -1083,7 +1083,8 @@
1083
1083
  }
1084
1084
 
1085
1085
  .historyTab[data-active='true'] {
1086
- background: var(--dsw-alias-bg-hover, #f3f4f6);
1086
+ /* 半透明交互强调色(亮色=深色 tint,暗色=白色 tint),避免选中整块白底 */
1087
+ background: var(--dsw-alias-interactive-bg-hover-accent, rgba(38, 49, 72, 0.14));
1087
1088
  color: var(--dsw-alias-label-primary, #1f2328);
1088
1089
  font-weight: 600;
1089
1090
  }
@@ -1133,3 +1134,100 @@
1133
1134
  color: var(--dsw-alias-label-tertiary, #9ca3af);
1134
1135
  padding: 0 6px;
1135
1136
  }
1137
+
1138
+ .historyTime {
1139
+ font-size: 11px;
1140
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
1141
+ margin: 2px 0;
1142
+ white-space: nowrap;
1143
+ }
1144
+
1145
+ /* 表单分区与两列排布 */
1146
+ .formSection {
1147
+ font-size: 11px;
1148
+ font-weight: 700;
1149
+ letter-spacing: 0.04em;
1150
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
1151
+ border-bottom: 1px solid var(--dsw-alias-border-l1, #e5e7eb);
1152
+ padding-bottom: 4px;
1153
+ margin: 10px 0 2px;
1154
+ }
1155
+
1156
+ .formFields {
1157
+ display: grid;
1158
+ grid-template-columns: 1fr 1fr;
1159
+ gap: 10px;
1160
+ align-items: end;
1161
+ }
1162
+
1163
+ .fieldCell {
1164
+ min-width: 0;
1165
+ }
1166
+
1167
+ .fieldFull {
1168
+ grid-column: 1 / -1;
1169
+ }
1170
+
1171
+ /* 模式胶囊 */
1172
+ .modeIcon {
1173
+ font-size: 13px;
1174
+ margin-right: 4px;
1175
+ }
1176
+
1177
+ .modeButton[data-active='true'] {
1178
+ border-color: var(--dsw-alias-interactive-bg-hover-accent, rgba(38, 49, 72, 0.14));
1179
+ background: var(--dsw-alias-interactive-bg-hover-accent, rgba(38, 49, 72, 0.14));
1180
+ color: var(--dsw-alias-label-primary, #1f2328);
1181
+ font-weight: 600;
1182
+ }
1183
+
1184
+ /* 任务细进度条 */
1185
+ .taskBar {
1186
+ display: block;
1187
+ flex: 1;
1188
+ min-width: 60px;
1189
+ height: 4px;
1190
+ border-radius: 999px;
1191
+ background: var(--dsw-alias-border-l1, #e5e7eb);
1192
+ overflow: hidden;
1193
+ }
1194
+
1195
+ .taskBar i {
1196
+ display: block;
1197
+ height: 100%;
1198
+ border-radius: 999px;
1199
+ background: var(--dsw-alias-interactive-bg-hover-accent, rgba(38, 49, 72, 0.35));
1200
+ transition: width 0.3s;
1201
+ }
1202
+
1203
+ /* 历史行内图标操作 */
1204
+ .historyIcon {
1205
+ width: 26px;
1206
+ height: 26px;
1207
+ display: inline-flex;
1208
+ align-items: center;
1209
+ justify-content: center;
1210
+ border-radius: 8px;
1211
+ border: 1px solid transparent;
1212
+ background: none;
1213
+ color: var(--dsw-alias-label-secondary, #6b7280);
1214
+ font-size: 14px;
1215
+ cursor: pointer;
1216
+ }
1217
+
1218
+ .historyIcon:hover {
1219
+ background: var(--dsw-alias-interactive-bg-hover-accent, rgba(38, 49, 72, 0.14));
1220
+ color: var(--dsw-alias-label-primary, #1f2328);
1221
+ }
1222
+
1223
+ .historyIcon[title^='删除']:hover {
1224
+ color: #dc2626;
1225
+ }
1226
+
1227
+ /* 历史 prompt 两行截断 */
1228
+ .historyPrompt {
1229
+ display: -webkit-box;
1230
+ -webkit-line-clamp: 2;
1231
+ -webkit-box-orient: vertical;
1232
+ overflow: hidden;
1233
+ }
@@ -101,6 +101,14 @@ function modeLabelOf(mode: AudioMode): string {
101
101
  return tt('mode.voiceDesign')
102
102
  }
103
103
 
104
+ /** 历史时间显示(YYYY-MM-DD HH:mm)。 */
105
+ function formatClock(timestamp: number): string {
106
+ const date = new Date(timestamp)
107
+ if (Number.isNaN(date.getTime())) return ''
108
+ const pad = (n: number): string => String(n).padStart(2, '0')
109
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
110
+ }
111
+
104
112
  function useConfig(scope: AudiogenScope) {
105
113
  const [value, setValue] = useState(scope.getSnapshot().value)
106
114
  useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
@@ -499,6 +507,87 @@ export function StudioView(props: {
499
507
  setTasks(current => current.filter(task => task.id !== taskId))
500
508
  }
501
509
 
510
+ /** 从历史参数回填表单(参考 AI 生图「恢复」):配置 + prompt 一键复用。 */
511
+ const restoreFromParams = (params: Record<string, unknown>, modeValue: AudioMode, singleModel: string, compareModelsRestore?: string[]): void => {
512
+ const str = (key: string): string | undefined => {
513
+ const v = params[key]
514
+ return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
515
+ }
516
+ const num = (key: string): number | undefined => {
517
+ const v = params[key]
518
+ if (typeof v === 'number' && Number.isFinite(v)) return v
519
+ if (typeof v === 'string' && v.trim() !== '') {
520
+ const parsed = Number(v)
521
+ return Number.isFinite(parsed) ? parsed : undefined
522
+ }
523
+ return undefined
524
+ }
525
+ const bool = (key: string): boolean | undefined => typeof params[key] === 'boolean' ? params[key] as boolean : undefined
526
+ setMode(modeValue)
527
+ const modelValue = str('model') ?? singleModel
528
+ if (modelValue !== '') setModel(modelValue)
529
+ if (compareModelsRestore !== undefined && compareModelsRestore.length > 0) {
530
+ setCompareMode(true)
531
+ setCompareModels(compareModelsRestore)
532
+ } else {
533
+ setCompareMode(false)
534
+ }
535
+ const voiceValue = str('voice')
536
+ if (voiceValue !== undefined) setVoice(voiceValue)
537
+ const speedValue = num('speed')
538
+ if (speedValue !== undefined) setSpeed(String(speedValue))
539
+ const durationValue = num('duration')
540
+ if (durationValue !== undefined) setDuration(String(durationValue))
541
+ const formatValue = str('format')
542
+ if (formatValue !== undefined) setFormat(formatValue)
543
+ const lyricsValue = str('lyrics')
544
+ if (lyricsValue !== undefined) setLyrics(lyricsValue)
545
+ const instrumentalValue = bool('isInstrumental')
546
+ if (instrumentalValue !== undefined) setInstrumental(instrumentalValue)
547
+ const loopValue = bool('loop')
548
+ if (loopValue !== undefined) setLoop(loopValue)
549
+ const influenceValue = num('promptInfluence')
550
+ if (influenceValue !== undefined) setPromptInfluence(String(influenceValue))
551
+ const emotionValue = str('emotion')
552
+ if (emotionValue !== undefined) setEmotion(emotionValue)
553
+ const volValue = num('vol')
554
+ if (volValue !== undefined) setVol(String(volValue))
555
+ const pitchValue = num('pitch')
556
+ if (pitchValue !== undefined) setPitch(String(pitchValue))
557
+ if (Array.isArray(params.pronunciationTone)) {
558
+ setToneText(params.pronunciationTone.filter((item): item is string => typeof item === 'string').join('\n'))
559
+ }
560
+ const sampleRateValue = num('sampleRate')
561
+ if (sampleRateValue !== undefined) setSampleRate(String(sampleRateValue))
562
+ const bitrateValue = num('bitrate')
563
+ if (bitrateValue !== undefined) setBitrate(String(bitrateValue))
564
+ const channelValue = num('audioChannel')
565
+ if (channelValue !== undefined) setAudioChannel(String(channelValue))
566
+ const subtitleValue = bool('subtitleEnable')
567
+ if (subtitleValue !== undefined) setSubtitle(subtitleValue)
568
+ const seedValue = num('seed')
569
+ if (seedValue !== undefined) setSeed(String(seedValue))
570
+ const stepsValue = num('steps')
571
+ if (stepsValue !== undefined) setSteps(String(stepsValue))
572
+ const cfgValue = num('cfgScale')
573
+ if (cfgValue !== undefined) setCfgScale(String(cfgValue))
574
+ const previewValue = str('previewText')
575
+ if (previewValue !== undefined) setPreviewText(previewValue)
576
+ const channelIdValue = str('channelId')
577
+ if (channelIdValue !== undefined && modeValue === 'voice_design') setDesignChannelId(channelIdValue)
578
+ props.showToast('已恢复该次生成的配置,可直接再次生成')
579
+ }
580
+
581
+ /** 删除历史记录(对比任务卡删除该任务的全部模型条目)。 */
582
+ const deleteHistoryEntries = async (ids: string[]): Promise<void> => {
583
+ try {
584
+ for (const id of ids) await api.removeHistory(id)
585
+ } catch {
586
+ // best-effort
587
+ }
588
+ reload()
589
+ }
590
+
502
591
  const openSaveDialog = (files: GeneratedAudio[], context: SaveDialogContext): void => {
503
592
  setSaveDialog({ files, context })
504
593
  }
@@ -767,7 +856,7 @@ export function StudioView(props: {
767
856
  <div className={css.studio}>
768
857
  <div className={css.formCol}>
769
858
  <div className={css.modeRow}>
770
- {(['tts', 'music', 'sfx', 'voice_design'] as const).map(item => (
859
+ {([['tts', '🎙️'], ['music', '🎵'], ['sfx', '🔊'], ['voice_design', '🎨']] as Array<[AudioMode, string]>).map(([item, icon]) => (
771
860
  <button
772
861
  key={item}
773
862
  type="button"
@@ -775,11 +864,13 @@ export function StudioView(props: {
775
864
  data-active={mode === item ? 'true' : 'false'}
776
865
  onClick={() => setMode(item)}
777
866
  >
778
- {item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : item === 'sfx' ? tt('mode.sfx') : tt('mode.voiceDesign')}
867
+ <span className={css.modeIcon}>{icon}</span>
868
+ {modeLabelOf(item)}
779
869
  </button>
780
870
  ))}
781
871
  </div>
782
872
 
873
+ <p className={css.formSection}>输入</p>
783
874
  <label className={css.label}>
784
875
  <span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
785
876
  <textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
@@ -806,6 +897,7 @@ export function StudioView(props: {
806
897
  </>
807
898
  ) : null}
808
899
 
900
+ <p className={css.formSection}>模型</p>
809
901
  {needModel ? (
810
902
  <label className={css.checkbox} title="选择多个模型,用相同参数逐个生成,便于对比效果">
811
903
  <input type="checkbox" checked={compareMode} onChange={event => setCompareMode(event.target.checked)} />
@@ -902,7 +994,14 @@ export function StudioView(props: {
902
994
  )
903
995
  ) : null}
904
996
 
905
- {globalSpecs.filter(spec => spec.advanced !== true).map(spec => renderField(spec))}
997
+ {globalSpecs.some(spec => spec.advanced !== true) ? <p className={css.formSection}>生成参数</p> : null}
998
+ <div className={css.formFields}>
999
+ {globalSpecs.filter(spec => spec.advanced !== true).map(spec => (
1000
+ <div key={spec.key} className={spec.key === 'lyrics' || spec.key === 'toneText' ? css.fieldFull : css.fieldCell}>
1001
+ {renderField(spec)}
1002
+ </div>
1003
+ ))}
1004
+ </div>
906
1005
 
907
1006
  {mode === 'tts' && globalSpecs.some(spec => spec.advanced === true) ? (
908
1007
  <details className={css.advanced}>
@@ -935,6 +1034,15 @@ export function StudioView(props: {
935
1034
  <span className={css.resultEmptyIcon}>🎵</span>
936
1035
  <p>{tt('result.empty')}</p>
937
1036
  <p className={css.resultEmptyHint}>点击「开始生成」即创建一个任务,可同时进行多个;勾选「模型对比」用多个模型同参数生成对比</p>
1037
+ <button type="button" className={css.ghostButton} onClick={() => {
1038
+ const examples: Record<AudioMode, string> = {
1039
+ tts: '今天是不是很开心呀(laughs),当然了!我们一起去公园散步吧。',
1040
+ music: 'Cinematic orchestral piece with a clear "before/after" transition at 1:00, starting minimalist piano + strings, then full orchestra entrance with timpani and brass at the 1-minute mark.',
1041
+ sfx: '科技感 UI 提示音:清脆短促,带轻微回声与空气感。',
1042
+ voice_design: '讲述悬疑故事的播音员,声音低沉富有磁性,语速时快时慢,营造紧张神秘的氛围。',
1043
+ }
1044
+ setPrompt(examples[mode] ?? '')
1045
+ }}>填入示例 prompt</button>
938
1046
  </div>
939
1047
  ) : (
940
1048
  <div className={css.taskList}>
@@ -955,6 +1063,9 @@ export function StudioView(props: {
955
1063
  <span className={css.resultModeChip}>{task.mode}</span>
956
1064
  <span className={css.taskLabel} title={task.prompt}>{task.label}</span>
957
1065
  <span className={css.taskStatus} data-state={task.status}>{statusText}</span>
1066
+ {task.status === 'running' ? (
1067
+ <span className={css.taskBar}><i style={{ width: `${task.progress.total > 0 ? Math.round((task.progress.done / task.progress.total) * 100) : 0}%` }} /></span>
1068
+ ) : null}
958
1069
  <span className={css.taskActions}>
959
1070
  {task.status === 'running' ? (
960
1071
  <button type="button" className={css.ghostButton} onClick={() => cancelTask(task.id)}>取消</button>
@@ -1023,6 +1134,7 @@ export function StudioView(props: {
1023
1134
  <summary className={css.historyCompareSummary}>
1024
1135
  <span className={css.historyPrompt}>{item.prompt}</span>
1025
1136
  <span className={css.historyCompareBadge}>对比 · {item.models.length} 个模型</span>
1137
+ <span className={css.historyTime}>{formatClock(item.createdAt)}</span>
1026
1138
  </summary>
1027
1139
  <div className={css.historyMeta}>{modeLabelOf(item.mode)} · {item.models.map(model => model.model).join(' / ')}</div>
1028
1140
  {item.models.map(model => (
@@ -1040,6 +1152,15 @@ export function StudioView(props: {
1040
1152
  </div>
1041
1153
  </div>
1042
1154
  ))}
1155
+ <div className={css.historyActions}>
1156
+ <button type="button" className={css.historyIcon} title="恢复(回填配置与全部模型)" onClick={() => restoreFromParams(
1157
+ (item.models[0]?.entry.params ?? {}) as Record<string, unknown>,
1158
+ item.mode,
1159
+ item.models[0]?.model ?? '',
1160
+ item.models.map(model => model.model),
1161
+ )}>↺</button>
1162
+ <button type="button" className={css.historyIcon} title="删除整个对比任务" onClick={() => void deleteHistoryEntries(item.models.map(model => model.entry.id))}>✕</button>
1163
+ </div>
1043
1164
  </details>
1044
1165
  )
1045
1166
  }
@@ -1048,12 +1169,19 @@ export function StudioView(props: {
1048
1169
  <div className={css.historyItem} key={item.key}>
1049
1170
  <div className={css.historyPrompt}>{entry.prompt}</div>
1050
1171
  <div className={css.historyMeta}>{modeLabelOf(entry.mode)} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
1172
+ <div className={css.historyTime}>{formatClock(entry.createdAt)}</div>
1051
1173
  {entry.audio.map((audio, index) => (
1052
1174
  <AudioPlayer key={index} src={audio.url} compact itemKey={`${entry.id}-${index}`} />
1053
1175
  ))}
1054
1176
  <div className={css.historyActions}>
1055
- <button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(entry), contextOfEntry(entry))}>
1056
- <StarIcon /> 入库
1177
+ <button type="button" className={css.historyIcon} title="恢复(回填配置与 prompt)" onClick={() => restoreFromParams(
1178
+ (entry.params ?? {}) as Record<string, unknown>,
1179
+ entry.mode,
1180
+ entry.model,
1181
+ )}>↺</button>
1182
+ <button type="button" className={css.historyIcon} title="删除这条记录" onClick={() => void deleteHistoryEntries([entry.id])}>✕</button>
1183
+ <button type="button" className={css.historyIcon} title="加入资源库" onClick={() => openSaveDialog(audioRefsOfEntry(entry), contextOfEntry(entry))}>
1184
+ <StarIcon />
1057
1185
  </button>
1058
1186
  </div>
1059
1187
  </div>
package/src/index.ts CHANGED
@@ -41,7 +41,8 @@ export interface Config {
41
41
  defaultChannelId?: string
42
42
  defaultModel?: string
43
43
  autoSaveToLibrary?: boolean
44
- maxConcurrentGenerations?: number
44
+ /** 设置卡按文本编辑,保存值可能是数字或数字字符串。 */
45
+ maxConcurrentGenerations?: number | string
45
46
  }
46
47
 
47
48
  const DEFAULT_MAX_CONCURRENT = 5
@@ -64,7 +65,7 @@ export const Config: z<Config> = z.object({
64
65
  defaultChannelId: z.string().default(''),
65
66
  defaultModel: z.string().default(''),
66
67
  autoSaveToLibrary: z.boolean().default(false),
67
- maxConcurrentGenerations: z.number().default(DEFAULT_MAX_CONCURRENT),
68
+ maxConcurrentGenerations: z.union([z.number(), z.string()]).default(DEFAULT_MAX_CONCURRENT),
68
69
  })
69
70
 
70
71
  const DEFAULT_ENABLED = true
@@ -184,9 +185,11 @@ export function apply(ctx: Context, config?: Config): void {
184
185
  defaultChannelId,
185
186
  defaultModel: typeof value.defaultModel === 'string' ? value.defaultModel.trim() : '',
186
187
  autoSaveToLibrary: value.autoSaveToLibrary === true,
187
- maxConcurrentGenerations: typeof value.maxConcurrentGenerations === 'number' && Number.isFinite(value.maxConcurrentGenerations)
188
- ? Math.max(1, Math.min(20, Math.floor(value.maxConcurrentGenerations)))
189
- : DEFAULT_MAX_CONCURRENT,
188
+ maxConcurrentGenerations: (() => {
189
+ const rawMax = value.maxConcurrentGenerations
190
+ const parsedMax = typeof rawMax === 'number' ? rawMax : typeof rawMax === 'string' && rawMax.trim() !== '' ? Number(rawMax.trim()) : NaN
191
+ return Number.isFinite(parsedMax) ? Math.max(1, Math.min(20, Math.floor(parsedMax))) : DEFAULT_MAX_CONCURRENT
192
+ })(),
190
193
  }
191
194
  }
192
195
 
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.2'
11
+ export const PLUGIN_VERSION = '0.4.4'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
14
14
  export const SETTINGS_API = {