dsh-audiogen 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/lib/client.js +2368 -630
- package/lib/client.js.map +1 -1
- package/lib/index.js +1022 -68
- package/package.json +1 -1
- package/skills/design/SKILL.md +7 -1
- package/skills/music/SKILL.md +37 -0
- package/skills/sfx/SKILL.md +17 -3
- package/src/agent-audio-tools.ts +159 -14
- package/src/audio-engine.ts +284 -18
- package/src/audio-presets.ts +10 -4
- package/src/audio-store.ts +251 -3
- package/src/client/AudioGenPanel.tsx +59 -334
- package/src/client/SettingsCard.tsx +9 -0
- package/src/client/api.ts +45 -2
- package/src/client/audio-panel.module.css +652 -79
- package/src/client/audio-player.tsx +87 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/library-save-dialog.tsx +173 -0
- package/src/client/library-view.tsx +511 -0
- package/src/client/library.module.css +686 -0
- package/src/client/locales.ts +6 -0
- package/src/client/settings-scope.ts +2 -0
- package/src/client/studio-view.tsx +560 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +130 -1
- package/src/routes.ts +247 -8
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom audio player: play/pause, seekable progress and time readout.
|
|
3
|
+
* Replaces the native `<audio controls>` look with a themed control so the
|
|
4
|
+
* panel matches the DSH design tokens in light and dark modes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { MuteIcon, PauseIcon, PlayIcon, VolumeIcon } from './icons.tsx'
|
|
9
|
+
import css from './audio-panel.module.css'
|
|
10
|
+
|
|
11
|
+
function format(seconds: number): string {
|
|
12
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
|
|
13
|
+
const whole = Math.floor(seconds)
|
|
14
|
+
const minutes = Math.floor(whole / 60)
|
|
15
|
+
const rest = whole % 60
|
|
16
|
+
return `${minutes}:${rest < 10 ? `0${rest}` : String(rest)}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AudioPlayer(props: {
|
|
20
|
+
src: string
|
|
21
|
+
compact?: boolean
|
|
22
|
+
/** Optional key to force a fresh element when the source changes identity. */
|
|
23
|
+
itemKey?: string
|
|
24
|
+
}): React.JSX.Element {
|
|
25
|
+
const ref = useRef<HTMLAudioElement | null>(null)
|
|
26
|
+
const [playing, setPlaying] = useState(false)
|
|
27
|
+
const [muted, setMuted] = useState(false)
|
|
28
|
+
const [current, setCurrent] = useState(0)
|
|
29
|
+
const [duration, setDuration] = useState(0)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
// Reset when the source changes (the media element re-loads).
|
|
33
|
+
setPlaying(false)
|
|
34
|
+
setCurrent(0)
|
|
35
|
+
setDuration(0)
|
|
36
|
+
}, [props.src])
|
|
37
|
+
|
|
38
|
+
const toggle = useCallback((): void => {
|
|
39
|
+
const el = ref.current
|
|
40
|
+
if (el === null) return
|
|
41
|
+
if (el.paused) void el.play().catch(() => { /* autoplay/interrupt best-effort */ })
|
|
42
|
+
else el.pause()
|
|
43
|
+
}, [])
|
|
44
|
+
|
|
45
|
+
const seek = useCallback((event: React.MouseEvent<HTMLDivElement>): void => {
|
|
46
|
+
const el = ref.current
|
|
47
|
+
if (el === null || duration <= 0) return
|
|
48
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
49
|
+
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
|
|
50
|
+
el.currentTime = ratio * duration
|
|
51
|
+
}, [duration])
|
|
52
|
+
|
|
53
|
+
const toggleMute = useCallback((): void => {
|
|
54
|
+
const el = ref.current
|
|
55
|
+
if (el === null) return
|
|
56
|
+
el.muted = !el.muted
|
|
57
|
+
setMuted(el.muted)
|
|
58
|
+
}, [])
|
|
59
|
+
|
|
60
|
+
const percent = duration > 0 ? Math.min(100, (current / duration) * 100) : 0
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={props.compact === true ? css.playerCompact : css.player} data-playing={playing ? 'true' : 'false'}>
|
|
64
|
+
<audio
|
|
65
|
+
ref={ref}
|
|
66
|
+
src={props.src}
|
|
67
|
+
preload="metadata"
|
|
68
|
+
onPlay={() => setPlaying(true)}
|
|
69
|
+
onPause={() => setPlaying(false)}
|
|
70
|
+
onEnded={() => setPlaying(false)}
|
|
71
|
+
onTimeUpdate={event => setCurrent(event.currentTarget.currentTime)}
|
|
72
|
+
onLoadedMetadata={event => setDuration(event.currentTarget.duration)}
|
|
73
|
+
/>
|
|
74
|
+
<button type="button" className={css.playButton} aria-label={playing ? '暂停' : '播放'} onClick={toggle}>
|
|
75
|
+
{playing ? <PauseIcon /> : <PlayIcon />}
|
|
76
|
+
</button>
|
|
77
|
+
<div className={css.track} role="slider" aria-label="播放进度" aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={Math.round(current)} onClick={seek}>
|
|
78
|
+
<div className={css.trackFill} style={{ width: `${percent}%` }} />
|
|
79
|
+
<div className={css.trackKnob} style={{ left: `${percent}%` }} />
|
|
80
|
+
</div>
|
|
81
|
+
<span className={css.time}>{format(current)}{duration > 0 ? ` / ${format(duration)}` : ''}</span>
|
|
82
|
+
<button type="button" className={css.muteButton} aria-label={muted ? '取消静音' : '静音'} onClick={toggleMute}>
|
|
83
|
+
{muted ? <MuteIcon /> : <VolumeIcon />}
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny inline SVG icon set for the AI 音频 panel (no icon dependency).
|
|
3
|
+
* Each icon is a 1em × 1em stroke/fill glyph that inherits currentColor.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { JSX, ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
function Svg(props: { children: ReactNode; viewBox?: string }): JSX.Element {
|
|
9
|
+
return (
|
|
10
|
+
<svg
|
|
11
|
+
width="1em"
|
|
12
|
+
height="1em"
|
|
13
|
+
viewBox={props.viewBox ?? '0 0 16 16'}
|
|
14
|
+
fill="none"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
strokeWidth="1.5"
|
|
17
|
+
strokeLinecap="round"
|
|
18
|
+
strokeLinejoin="round"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
>
|
|
21
|
+
{props.children}
|
|
22
|
+
</svg>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function PlayIcon(): JSX.Element {
|
|
27
|
+
return (
|
|
28
|
+
<Svg viewBox="0 0 16 16">
|
|
29
|
+
<path d="M5 3.2v9.6a.6.6 0 0 0 .9.5l7.5-4.8a.6.6 0 0 0 0-1L5.9 2.7a.6.6 0 0 0-.9.5Z" fill="currentColor" stroke="none" />
|
|
30
|
+
</Svg>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function PauseIcon(): JSX.Element {
|
|
35
|
+
return (
|
|
36
|
+
<Svg viewBox="0 0 16 16">
|
|
37
|
+
<rect x="4.2" y="2.8" width="2.6" height="10.4" rx="0.9" fill="currentColor" stroke="none" />
|
|
38
|
+
<rect x="9.2" y="2.8" width="2.6" height="10.4" rx="0.9" fill="currentColor" stroke="none" />
|
|
39
|
+
</Svg>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function StarIcon(props: { filled?: boolean }): JSX.Element {
|
|
44
|
+
return (
|
|
45
|
+
<Svg viewBox="0 0 16 16">
|
|
46
|
+
<path
|
|
47
|
+
d="M8 1.9l1.8 3.7 4.1.6-3 2.9.7 4.1L8 11.2l-3.6 1.9.7-4.1-3-2.9 4.1-.6L8 1.9Z"
|
|
48
|
+
fill={props.filled === true ? 'currentColor' : 'none'}
|
|
49
|
+
/>
|
|
50
|
+
</Svg>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function DownloadIcon(): JSX.Element {
|
|
55
|
+
return (
|
|
56
|
+
<Svg viewBox="0 0 16 16">
|
|
57
|
+
<path d="M8 2.5v7M8 9.5l-2.6-2.6M8 9.5l2.6-2.6" />
|
|
58
|
+
<path d="M2.8 10.8v1.7a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1v-1.7" />
|
|
59
|
+
</Svg>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function TrashIcon(): JSX.Element {
|
|
64
|
+
return (
|
|
65
|
+
<Svg viewBox="0 0 16 16">
|
|
66
|
+
<path d="M3 4.4h10M6.2 2.9h3.6M5.2 4.4l.5 8a1 1 0 0 0 1 .9h2.6a1 1 0 0 0 1-.9l.5-8" />
|
|
67
|
+
</Svg>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function SearchIcon(): JSX.Element {
|
|
72
|
+
return (
|
|
73
|
+
<Svg viewBox="0 0 16 16">
|
|
74
|
+
<circle cx="7" cy="7" r="4.2" />
|
|
75
|
+
<path d="m10.2 10.2 3 3" />
|
|
76
|
+
</Svg>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function CopyIcon(): JSX.Element {
|
|
81
|
+
return (
|
|
82
|
+
<Svg viewBox="0 0 16 16">
|
|
83
|
+
<rect x="5.4" y="5.4" width="7.4" height="7.4" rx="1.2" />
|
|
84
|
+
<path d="M10.6 5.4V4a1.2 1.2 0 0 0-1.2-1.2H4A1.2 1.2 0 0 0 2.8 4v5.4A1.2 1.2 0 0 0 4 10.6h1.4" />
|
|
85
|
+
</Svg>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function CheckIcon(): JSX.Element {
|
|
90
|
+
return (
|
|
91
|
+
<Svg viewBox="0 0 16 16">
|
|
92
|
+
<path d="m3.2 8.4 3.1 3.1L12.8 4.9" />
|
|
93
|
+
</Svg>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function CloseIcon(): JSX.Element {
|
|
98
|
+
return (
|
|
99
|
+
<Svg viewBox="0 0 16 16">
|
|
100
|
+
<path d="m4 4 8 8M12 4l-8 8" />
|
|
101
|
+
</Svg>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function VolumeIcon(): JSX.Element {
|
|
106
|
+
return (
|
|
107
|
+
<Svg viewBox="0 0 16 16">
|
|
108
|
+
<path d="M2.8 6.2v3.6h2.4l3 2.6V3.6l-3 2.6H2.8Z" fill="currentColor" stroke="none" />
|
|
109
|
+
<path d="M10.4 5.6a3.4 3.4 0 0 1 0 4.8M12 4.2a5.6 5.6 0 0 1 0 7.6" />
|
|
110
|
+
</Svg>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function MuteIcon(): JSX.Element {
|
|
115
|
+
return (
|
|
116
|
+
<Svg viewBox="0 0 16 16">
|
|
117
|
+
<path d="M2.8 6.2v3.6h2.4l3 2.6V3.6l-3 2.6H2.8Z" fill="currentColor" stroke="none" />
|
|
118
|
+
<path d="m10.4 6.2 3.2 3.6M13.6 6.2l-3.2 3.6" />
|
|
119
|
+
</Svg>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function MicIcon(): JSX.Element {
|
|
124
|
+
return (
|
|
125
|
+
<Svg viewBox="0 0 16 16">
|
|
126
|
+
<rect x="5.8" y="2" width="4.4" height="7.2" rx="2.2" />
|
|
127
|
+
<path d="M3.2 8a4.8 4.8 0 0 0 9.6 0M8 12.8V14" />
|
|
128
|
+
</Svg>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function MusicNoteIcon(): JSX.Element {
|
|
133
|
+
return (
|
|
134
|
+
<Svg viewBox="0 0 16 16">
|
|
135
|
+
<path d="M6.2 12.6V4l6-1.4v8.6" />
|
|
136
|
+
<circle cx="4.7" cy="12.6" r="1.7" />
|
|
137
|
+
<circle cx="10.7" cy="11.2" r="1.7" />
|
|
138
|
+
</Svg>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function WaveIcon(): JSX.Element {
|
|
143
|
+
return (
|
|
144
|
+
<Svg viewBox="0 0 16 16">
|
|
145
|
+
<path d="M2 6h1M4.5 3.5v9M7.2 2v12M9.9 4v8M12.6 5.5v5M14.5 6.5v3" />
|
|
146
|
+
</Svg>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function GridIcon(): JSX.Element {
|
|
151
|
+
return (
|
|
152
|
+
<Svg viewBox="0 0 16 16">
|
|
153
|
+
<rect x="2.5" y="2.5" width="4.6" height="4.6" rx="1" />
|
|
154
|
+
<rect x="8.9" y="2.5" width="4.6" height="4.6" rx="1" />
|
|
155
|
+
<rect x="2.5" y="8.9" width="4.6" height="4.6" rx="1" />
|
|
156
|
+
<rect x="8.9" y="8.9" width="4.6" height="4.6" rx="1" />
|
|
157
|
+
</Svg>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function ListIcon(): JSX.Element {
|
|
162
|
+
return (
|
|
163
|
+
<Svg viewBox="0 0 16 16">
|
|
164
|
+
<path d="M5.4 4.2h8.2M5.4 8h8.2M5.4 11.8h8.2" />
|
|
165
|
+
<circle cx="2.8" cy="4.2" r="0.8" fill="currentColor" stroke="none" />
|
|
166
|
+
<circle cx="2.8" cy="8" r="0.8" fill="currentColor" stroke="none" />
|
|
167
|
+
<circle cx="2.8" cy="11.8" r="0.8" fill="currentColor" stroke="none" />
|
|
168
|
+
</Svg>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* «保存到资源库» dialog: pick type/category, set name/tags/note, then save.
|
|
3
|
+
* Built from either freshly generated audio or a history entry.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMemo, useState } from 'react'
|
|
7
|
+
import type { AudiogenApi } from './api.ts'
|
|
8
|
+
import type { AudioMode, GeneratedAudio, LibraryEntry, LibraryType } from '../protocol.ts'
|
|
9
|
+
import css from './audio-panel.module.css'
|
|
10
|
+
|
|
11
|
+
export interface SaveDialogContext {
|
|
12
|
+
mode: AudioMode
|
|
13
|
+
prompt: string
|
|
14
|
+
voice?: string
|
|
15
|
+
voiceId?: string
|
|
16
|
+
model?: string
|
|
17
|
+
channel?: string
|
|
18
|
+
channelId?: string
|
|
19
|
+
params?: Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function typeOfMode(mode: AudioMode): LibraryType {
|
|
23
|
+
if (mode === 'voice_design') return 'voice'
|
|
24
|
+
return mode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const LIBRARY_TYPE_LABELS: Record<LibraryType, string> = {
|
|
28
|
+
voice: '音色',
|
|
29
|
+
music: '音乐',
|
|
30
|
+
sfx: '音效',
|
|
31
|
+
tts: 'TTS 语音',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseTags(text: string): string[] {
|
|
35
|
+
return [...new Set(text.split(/[,,、\n]/).map(tag => tag.trim()).filter(tag => tag !== ''))].slice(0, 20)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function guessCategory(type: LibraryType, context: SaveDialogContext): string {
|
|
39
|
+
if (type === 'voice') {
|
|
40
|
+
const probe = `${context.voiceId ?? ''} ${context.voice ?? ''}`.toLowerCase()
|
|
41
|
+
if (/male|男/.test(probe)) return 'male'
|
|
42
|
+
if (/female|女/.test(probe)) return 'female'
|
|
43
|
+
return 'custom'
|
|
44
|
+
}
|
|
45
|
+
if (type === 'tts') return (context.voice ?? context.voiceId ?? 'default').replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]+/g, '_')
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function LibrarySaveDialog(props: {
|
|
50
|
+
api: AudiogenApi
|
|
51
|
+
files: GeneratedAudio[]
|
|
52
|
+
context: SaveDialogContext
|
|
53
|
+
onClose: () => void
|
|
54
|
+
onSaved: (entry: LibraryEntry) => void
|
|
55
|
+
}): React.JSX.Element {
|
|
56
|
+
const { api, files, context } = props
|
|
57
|
+
const [type, setType] = useState<LibraryType>(typeOfMode(context.mode))
|
|
58
|
+
const [category, setCategory] = useState(() => guessCategory(typeOfMode(context.mode), context))
|
|
59
|
+
const [name, setName] = useState('')
|
|
60
|
+
const [tags, setTags] = useState('')
|
|
61
|
+
const [note, setNote] = useState('')
|
|
62
|
+
const [saving, setSaving] = useState(false)
|
|
63
|
+
const [error, setError] = useState<string | null>(null)
|
|
64
|
+
|
|
65
|
+
const isVoice = type === 'voice'
|
|
66
|
+
const isTts = type === 'tts'
|
|
67
|
+
|
|
68
|
+
const defaultName = useMemo(() => {
|
|
69
|
+
const flat = context.prompt.replace(/\s+/g, ' ').trim()
|
|
70
|
+
return flat === '' ? '未命名音频' : (flat.length > 40 ? `${flat.slice(0, 40)}…` : flat)
|
|
71
|
+
}, [context.prompt])
|
|
72
|
+
|
|
73
|
+
const switchType = (next: LibraryType): void => {
|
|
74
|
+
setType(next)
|
|
75
|
+
setCategory(guessCategory(next, context))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const save = async (): Promise<void> => {
|
|
79
|
+
setSaving(true)
|
|
80
|
+
setError(null)
|
|
81
|
+
try {
|
|
82
|
+
const result = await api.librarySave({
|
|
83
|
+
audioFiles: files.map(file => ({
|
|
84
|
+
id: file.id,
|
|
85
|
+
file: file.file,
|
|
86
|
+
mime: file.mime,
|
|
87
|
+
...(file.voiceId === undefined ? {} : { voiceId: file.voiceId }),
|
|
88
|
+
...(file.duration === undefined ? {} : { duration: file.duration }),
|
|
89
|
+
})),
|
|
90
|
+
type,
|
|
91
|
+
...(category.trim() !== '' && (isVoice || isTts) ? { category: category.trim() } : {}),
|
|
92
|
+
...(name.trim() !== '' ? { name: name.trim() } : {}),
|
|
93
|
+
...(parseTags(tags).length > 0 ? { tags: parseTags(tags) } : {}),
|
|
94
|
+
...(note.trim() !== '' ? { note: note.trim() } : {}),
|
|
95
|
+
provenance: {
|
|
96
|
+
mode: context.mode,
|
|
97
|
+
prompt: context.prompt,
|
|
98
|
+
...(context.channel === undefined ? {} : { channel: context.channel }),
|
|
99
|
+
...(context.channelId === undefined ? {} : { channelId: context.channelId }),
|
|
100
|
+
...(context.model === undefined ? {} : { model: context.model }),
|
|
101
|
+
...(context.voice === undefined ? {} : { voice: context.voice }),
|
|
102
|
+
...(context.voiceId === undefined ? {} : { voiceId: context.voiceId }),
|
|
103
|
+
...(context.params === undefined ? {} : { params: context.params }),
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
if (!result.ok || result.entry === undefined) {
|
|
107
|
+
setError(result.message ?? '保存失败')
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
props.onSaved(result.entry)
|
|
111
|
+
} catch (err) {
|
|
112
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
113
|
+
} finally {
|
|
114
|
+
setSaving(false)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className={css.modalMask}>
|
|
120
|
+
<div className={css.modal} role="dialog" aria-modal="true" aria-label="保存到资源库">
|
|
121
|
+
<div className={css.modalHead}>
|
|
122
|
+
<strong>{files.length > 1 ? `保存 ${files.length} 段音频到资源库` : '保存到资源库'}</strong>
|
|
123
|
+
<button type="button" className={css.iconButton} aria-label="关闭" onClick={props.onClose}>×</button>
|
|
124
|
+
</div>
|
|
125
|
+
<div className={css.modalBody}>
|
|
126
|
+
<div className={css.formRow}>
|
|
127
|
+
<label className={css.label}>
|
|
128
|
+
<span>资源类型</span>
|
|
129
|
+
<select className={css.input} value={type} onChange={event => switchType(event.target.value as LibraryType)}>
|
|
130
|
+
{(['voice', 'music', 'sfx', 'tts'] as LibraryType[]).map(item => (
|
|
131
|
+
<option key={item} value={item}>{LIBRARY_TYPE_LABELS[item]}</option>
|
|
132
|
+
))}
|
|
133
|
+
</select>
|
|
134
|
+
</label>
|
|
135
|
+
{isVoice || isTts ? (
|
|
136
|
+
<label className={css.label}>
|
|
137
|
+
<span>{isVoice ? '音色分级(男 / 女)' : '按音色归档(voice 键)'}</span>
|
|
138
|
+
{isVoice ? (
|
|
139
|
+
<select className={css.input} value={category} onChange={event => setCategory(event.target.value)}>
|
|
140
|
+
<option value="male">男声 male</option>
|
|
141
|
+
<option value="female">女声 female</option>
|
|
142
|
+
<option value="custom">未分级 custom</option>
|
|
143
|
+
</select>
|
|
144
|
+
) : (
|
|
145
|
+
<input className={css.input} value={category} onChange={event => setCategory(event.target.value)} placeholder={context.voice ?? 'default'} />
|
|
146
|
+
)}
|
|
147
|
+
</label>
|
|
148
|
+
) : null}
|
|
149
|
+
</div>
|
|
150
|
+
<label className={css.label}>
|
|
151
|
+
<span>名称(留空则使用提示词)</span>
|
|
152
|
+
<input className={css.input} value={name} onChange={event => setName(event.target.value)} placeholder={defaultName} />
|
|
153
|
+
</label>
|
|
154
|
+
<label className={css.label}>
|
|
155
|
+
<span>标签(逗号分隔,可多个)</span>
|
|
156
|
+
<input className={css.input} value={tags} onChange={event => setTags(event.target.value)} placeholder={'温暖, 男声, 复古…'} />
|
|
157
|
+
</label>
|
|
158
|
+
<label className={css.label}>
|
|
159
|
+
<span>备注(可选)</span>
|
|
160
|
+
<textarea className={css.textarea} rows={2} value={note} onChange={event => setNote(event.target.value)} placeholder="用途、风格备注…" />
|
|
161
|
+
</label>
|
|
162
|
+
{error !== null ? <p className={css.error}>{error}</p> : null}
|
|
163
|
+
</div>
|
|
164
|
+
<div className={css.modalFoot}>
|
|
165
|
+
<button type="button" className={css.secondaryButton} onClick={props.onClose}>取消</button>
|
|
166
|
+
<button type="button" className={css.primaryButton} disabled={saving || files.length === 0} onClick={() => void save()}>
|
|
167
|
+
{saving ? '保存中…' : '保存到资源库'}
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|