| 1 | 'use client'; |
| 2 | |
| 3 | import { useEffect, useState } from 'react'; |
| 4 | import { CheckCircle, Loader2, Save, Settings, XCircle } from 'lucide-react'; |
| 5 | import BrandHeader from '@/components/BrandHeader'; |
| 6 | import { fetchModelGroupsByType, fetchVideoModelGroupsByAbility } from '@/lib/modelRegistry'; |
| 7 | import { |
| 8 | VIDEO_RATIOS, |
| 9 | VIDEO_RESOLUTIONS, |
| 10 | VIDEO_GENERATION_MODES, |
| 11 | STYLES, |
| 12 | type ProviderGroup, |
| 13 | } from '@/config/models'; |
| 14 | |
| 15 | type ConfigTree = Record<string, any>; |
| 16 | |
| 17 | type Field = { |
| 18 | path: string; |
| 19 | label: string; |
| 20 | type?: 'text' | 'number' | 'boolean' | 'password' | 'select'; |
| 21 | options?: Array<{ id: string; label: string }> | ProviderGroup[]; |
| 22 | }; |
| 23 | |
| 24 | type ModelSelectKey = 'llm' | 'vlm' | 'image_it2i' | 'image_t2i' | 'video_first_frame' | 'video_start_end' | 'video_reference'; |
| 25 | |
| 26 | const EMPTY_MODEL_SELECTS: Record<ModelSelectKey, ProviderGroup[]> = { |
| 27 | llm: [], |
| 28 | vlm: [], |
| 29 | image_it2i: [], |
| 30 | image_t2i: [], |
| 31 | video_first_frame: [], |
| 32 | video_start_end: [], |
| 33 | video_reference: [], |
| 34 | }; |
| 35 | |
| 36 | const LOG_LEVEL_OPTIONS = [ |
| 37 | { id: 'DEBUG', label: 'DEBUG - 最详细' }, |
| 38 | { id: 'INFO', label: 'INFO - 常规' }, |
| 39 | { id: 'WARNING', label: 'WARNING - 仅警告及错误' }, |
| 40 | { id: 'ERROR', label: 'ERROR - 仅错误' }, |
| 41 | { id: 'CRITICAL', label: 'CRITICAL - 严重错误' }, |
| 42 | ]; |
| 43 | |
| 44 | const GROUPS: Array<{ title: string; description: string; fields: Field[] }> = [ |
| 45 | { |
| 46 | title: 'API Server', |
| 47 | description: '服务启动与日志配置。host / port 保存后需要重启后端完全生效。', |
| 48 | fields: [ |
| 49 | { path: 'server.host', label: 'host 主机地址' }, |
| 50 | { path: 'server.port', label: 'port 端口', type: 'number' }, |
| 51 | { path: 'server.log_level', label: 'log_level 日志层级', type: 'select', options: LOG_LEVEL_OPTIONS }, |
| 52 | { path: 'server.access_log', label: 'access_log 请求访问日志', type: 'boolean' }, |
| 53 | ], |
| 54 | }, |
| 55 | { |
| 56 | title: 'Common Provider Settings', |
| 57 | description: '模型调用公共配置和代理设置。', |
| 58 | fields: [ |
| 59 | { path: 'api_providers.common.print_model_input', label: 'print_model_input 打印模型输入', type: 'boolean' }, |
| 60 | { path: 'api_providers.common.proxy', label: 'proxy 代理地址' }, |
| 61 | ], |
| 62 | }, |
| 63 | { |
| 64 | title: 'OpenAI', |
| 65 | description: 'OpenAI / 兼容 OpenAI 接口配置。', |
| 66 | fields: [ |
| 67 | { path: 'api_providers.openai.api_key', label: 'api_key API 密钥', type: 'password' }, |
| 68 | { path: 'api_providers.openai.base_url', label: 'base_url 接口地址' }, |
| 69 | { path: 'api_providers.openai.enable_proxy', label: 'enable_proxy 启用代理', type: 'boolean' }, |
| 70 | ], |
| 71 | }, |
| 72 | { |
| 73 | title: 'Gemini', |
| 74 | description: 'Gemini 及兼容接口配置。', |
| 75 | fields: [ |
| 76 | { path: 'api_providers.gemini.api_key', label: 'api_key API 密钥', type: 'password' }, |
| 77 | { path: 'api_providers.gemini.base_url', label: 'base_url 接口地址' }, |
| 78 | { path: 'api_providers.gemini.enable_proxy', label: 'enable_proxy 启用代理', type: 'boolean' }, |
| 79 | ], |
| 80 | }, |
| 81 | { |
| 82 | title: 'DeepSeek', |
| 83 | description: 'DeepSeek 接口配置。', |
| 84 | fields: [ |
| 85 | { path: 'api_providers.deepseek.api_key', label: 'api_key API 密钥', type: 'password' }, |
| 86 | { path: 'api_providers.deepseek.base_url', label: 'base_url 接口地址' }, |
| 87 | { path: 'api_providers.deepseek.enable_proxy', label: 'enable_proxy 启用代理', type: 'boolean' }, |
| 88 | ], |
| 89 | }, |
| 90 | { |
| 91 | title: 'DashScope', |
| 92 | description: '通义千问、通义万相等 DashScope 服务配置。', |
| 93 | fields: [ |
| 94 | { path: 'api_providers.dashscope.api_key', label: 'api_key API 密钥', type: 'password' }, |
| 95 | { path: 'api_providers.dashscope.base_url', label: 'base_url 接口地址' }, |
| 96 | { path: 'api_providers.dashscope.enable_proxy', label: 'enable_proxy 启用代理', type: 'boolean' }, |
| 97 | ], |
| 98 | }, |
| 99 | { |
| 100 | title: 'ARK', |
| 101 | description: 'Seedream / Seedance 使用的火山方舟配置。', |
| 102 | fields: [ |
| 103 | { path: 'api_providers.ark.api_key', label: 'api_key API 密钥', type: 'password' }, |
| 104 | { path: 'api_providers.ark.base_url', label: 'base_url 接口地址' }, |
| 105 | { path: 'api_providers.ark.enable_proxy', label: 'enable_proxy 启用代理', type: 'boolean' }, |
| 106 | ], |
| 107 | }, |
| 108 | { |
| 109 | title: 'Kling', |
| 110 | description: '可灵视频生成接口配置。', |
| 111 | fields: [ |
| 112 | { path: 'api_providers.kling.base_url', label: 'base_url 接口地址' }, |
| 113 | { path: 'api_providers.kling.api_key', label: 'api_key API 密钥', type: 'password' }, |
| 114 | { path: 'api_providers.kling.enable_proxy', label: 'enable_proxy 启用代理', type: 'boolean' }, |
| 115 | ], |
| 116 | }, |
| 117 | { |
| 118 | title: 'Default Models', |
| 119 | description: '主流程和 Pipeline 使用的默认模型。', |
| 120 | fields: [ |
| 121 | { path: 'models.llm', label: 'llm 文本模型', type: 'select', options: [] }, |
| 122 | { path: 'models.vlm', label: 'vlm 视觉语言模型', type: 'select', options: [] }, |
| 123 | { path: 'models.image_it2i', label: 'image_it2i 图生图模型', type: 'select', options: [] }, |
| 124 | { path: 'models.image_t2i', label: 'image_t2i 文生图模型', type: 'select', options: [] }, |
| 125 | { path: 'models.video_first_frame', label: 'video_first_frame 首帧生视频模型', type: 'select', options: [] }, |
| 126 | { path: 'models.video_start_end', label: 'video_start_end 首尾帧生视频模型', type: 'select', options: [] }, |
| 127 | { path: 'models.video_reference', label: 'video_reference 参考图生视频模型', type: 'select', options: [] }, |
| 128 | ], |
| 129 | }, |
| 130 | { |
| 131 | title: '视频生成配置', |
| 132 | description: '只对主流程生效:选择视频生成方式、风格、画幅比例和视频分辨率。', |
| 133 | fields: [ |
| 134 | { path: 'generation.video_generation_mode', label: 'video_generation_mode 视频生成方式', type: 'select', options: VIDEO_GENERATION_MODES }, |
| 135 | { path: 'generation.style', label: 'style 风格', type: 'select', options: STYLES }, |
| 136 | { path: 'generation.video_ratio', label: 'video_ratio 视频长宽比', type: 'select', options: VIDEO_RATIOS }, |
| 137 | { path: 'generation.video_resolution', label: 'video_resolution 视频分辨率', type: 'select', options: VIDEO_RESOLUTIONS }, |
| 138 | ], |
| 139 | }, |
| 140 | ]; |
| 141 | |
| 142 | function getValue(config: ConfigTree, path: string) { |
| 143 | return path.split('.').reduce((current, key) => current?.[key], config); |
| 144 | } |
| 145 | |
| 146 | function setValue(config: ConfigTree, path: string, value: any): ConfigTree { |
| 147 | const next = structuredClone(config || {}); |
| 148 | const parts = path.split('.'); |
| 149 | let current = next; |
| 150 | for (const part of parts.slice(0, -1)) { |
| 151 | current[part] = current[part] || {}; |
| 152 | current = current[part]; |
| 153 | } |
| 154 | current[parts[parts.length - 1]] = value; |
| 155 | return next; |
| 156 | } |
| 157 | |
| 158 | function formatConfigPath(path: string) { |
| 159 | if (!path) return 'backend/config.yaml'; |
| 160 | const normalized = path.replace(/\\/g, '/'); |
| 161 | const marker = '/video-claw/video-claw/'; |
| 162 | const markerIndex = normalized.lastIndexOf(marker); |
| 163 | if (markerIndex >= 0) return normalized.slice(markerIndex + marker.length); |
| 164 | const backendIndex = normalized.lastIndexOf('/backend/config.yaml'); |
| 165 | if (backendIndex >= 0) return normalized.slice(backendIndex + 1); |
| 166 | return normalized; |
| 167 | } |
| 168 | |
| 169 | function maskSecret(value: unknown) { |
| 170 | const text = String(value ?? ''); |
| 171 | if (!text) return ''; |
| 172 | if (text.length <= 10) return '*'.repeat(text.length); |
| 173 | return `${text.slice(0, 5)}${'*'.repeat(Math.min(12, text.length - 10))}${text.slice(-5)}`; |
| 174 | } |
| 175 | |
| 176 | function isProviderOptions(options: Field['options']): options is ProviderGroup[] { |
| 177 | return Array.isArray(options) && options.some(option => 'models' in option); |
| 178 | } |
| 179 | |
| 180 | export default function SettingsPage() { |
| 181 | const [config, setConfig] = useState<ConfigTree>({}); |
| 182 | const [path, setPath] = useState(''); |
| 183 | const [loading, setLoading] = useState(true); |
| 184 | const [saving, setSaving] = useState(false); |
| 185 | const [message, setMessage] = useState(''); |
| 186 | const [error, setError] = useState(''); |
| 187 | const [secretDrafts, setSecretDrafts] = useState<Record<string, string>>({}); |
| 188 | const [modelSelects, setModelSelects] = useState<Record<ModelSelectKey, ProviderGroup[]>>(EMPTY_MODEL_SELECTS); |
| 189 | |
| 190 | useEffect(() => { |
| 191 | const load = async () => { |
| 192 | setLoading(true); |
| 193 | setError(''); |
| 194 | try { |
| 195 | const resp = await fetch('/api/config'); |
| 196 | if (!resp.ok) throw new Error('读取配置失败'); |
| 197 | const data = await resp.json(); |
| 198 | setConfig(data.config || {}); |
| 199 | setPath(data.path || ''); |
| 200 | setSecretDrafts({}); |
| 201 | } catch (e: any) { |
| 202 | setError(e.message || '读取配置失败'); |
| 203 | } finally { |
| 204 | setLoading(false); |
| 205 | } |
| 206 | }; |
| 207 | load(); |
| 208 | }, []); |
| 209 | |
| 210 | useEffect(() => { |
| 211 | let cancelled = false; |
| 212 | Promise.all([ |
| 213 | fetchModelGroupsByType('llm'), |
| 214 | fetchModelGroupsByType('vlm'), |
| 215 | fetchModelGroupsByType('i2i'), |
| 216 | fetchModelGroupsByType('t2i'), |
| 217 | fetchVideoModelGroupsByAbility('first_frame_i2v'), |
| 218 | fetchVideoModelGroupsByAbility('start_end_frame_i2v'), |
| 219 | fetchVideoModelGroupsByAbility('reference_to_video'), |
| 220 | ]) |
| 221 | .then(([llm, vlm, imageIt2i, imageT2i, firstFrameVideo, startEndVideo, referenceVideo]) => { |
| 222 | if (cancelled) return; |
| 223 | setModelSelects({ |
| 224 | llm, |
| 225 | vlm, |
| 226 | image_it2i: imageIt2i, |
| 227 | image_t2i: imageT2i, |
| 228 | video_first_frame: firstFrameVideo, |
| 229 | video_start_end: startEndVideo, |
| 230 | video_reference: referenceVideo, |
| 231 | }); |
| 232 | }) |
| 233 | .catch(() => {}); |
| 234 | return () => { cancelled = true; }; |
| 235 | }, []); |
| 236 | |
| 237 | const groups = GROUPS.map(group => { |
| 238 | if (group.title !== 'Default Models') return group; |
| 239 | return { |
| 240 | ...group, |
| 241 | fields: group.fields.map(field => { |
| 242 | if (field.path === 'models.llm') return { ...field, options: modelSelects.llm }; |
| 243 | if (field.path === 'models.vlm') return { ...field, options: modelSelects.vlm }; |
| 244 | if (field.path === 'models.image_it2i') return { ...field, options: modelSelects.image_it2i }; |
| 245 | if (field.path === 'models.image_t2i') return { ...field, options: modelSelects.image_t2i }; |
| 246 | if (field.path === 'models.video_first_frame') return { ...field, options: modelSelects.video_first_frame }; |
| 247 | if (field.path === 'models.video_start_end') return { ...field, options: modelSelects.video_start_end }; |
| 248 | if (field.path === 'models.video_reference') return { ...field, options: modelSelects.video_reference }; |
| 249 | return field; |
| 250 | }), |
| 251 | }; |
| 252 | }); |
| 253 | |
| 254 | const updateField = (field: Field, raw: string | boolean) => { |
| 255 | const value = field.type === 'number' ? Number(raw) || 0 : raw; |
| 256 | setConfig(current => setValue(current, field.path, value)); |
| 257 | }; |
| 258 | |
| 259 | const save = async () => { |
| 260 | setSaving(true); |
| 261 | setMessage(''); |
| 262 | setError(''); |
| 263 | try { |
| 264 | const resp = await fetch('/api/config', { |
| 265 | method: 'PUT', |
| 266 | headers: { 'Content-Type': 'application/json' }, |
| 267 | body: JSON.stringify({ values: config }), |
| 268 | }); |
| 269 | if (!resp.ok) throw new Error('保存配置失败'); |
| 270 | const data = await resp.json(); |
| 271 | setConfig(data.config || {}); |
| 272 | setPath(data.path || ''); |
| 273 | setSecretDrafts({}); |
| 274 | setMessage('配置已保存'); |
| 275 | } catch (e: any) { |
| 276 | setError(e.message || '保存配置失败'); |
| 277 | } finally { |
| 278 | setSaving(false); |
| 279 | } |
| 280 | }; |
| 281 | |
| 282 | const updateSecretField = (field: Field, raw: string) => { |
| 283 | setSecretDrafts(current => ({ ...current, [field.path]: raw })); |
| 284 | setConfig(current => setValue(current, field.path, raw)); |
| 285 | }; |
| 286 | |
| 287 | return ( |
| 288 | <div className="min-h-screen bg-gray-50/50"> |
| 289 | <BrandHeader /> |
| 290 | <main className="w-full max-w-6xl mx-auto px-6 pt-10 pb-12"> |
| 291 | <div className="mb-8 text-center"> |
| 292 | <div className="inline-flex items-center gap-2 mb-3"> |
| 293 | <Settings className="w-7 h-7 text-blue-500" /> |
| 294 | <h1 className="text-2xl font-bold text-gray-800">设置</h1> |
| 295 | </div> |
| 296 | <p className="text-sm text-gray-500"> |
| 297 | 修改后端配置并保存到 <span className="font-mono">{formatConfigPath(path)}</span> |
| 298 | </p> |
| 299 | </div> |
| 300 | |
| 301 | {loading ? ( |
| 302 | <div className="h-56 rounded-2xl border border-gray-200 bg-white flex items-center justify-center text-sm text-gray-400"> |
| 303 | <Loader2 className="w-4 h-4 mr-2 animate-spin" /> |
| 304 | 正在读取配置 |
| 305 | </div> |
| 306 | ) : ( |
| 307 | <div className="space-y-5"> |
| 308 | {groups.map(group => ( |
| 309 | <section key={group.title} className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> |
| 310 | <div className="mb-4"> |
| 311 | <h2 className="text-sm font-semibold text-gray-800">{group.title}</h2> |
| 312 | <p className="mt-1 text-xs text-gray-500">{group.description}</p> |
| 313 | </div> |
| 314 | <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> |
| 315 | {group.fields.map(field => { |
| 316 | const value = getValue(config, field.path); |
| 317 | return ( |
| 318 | <label key={field.path} className="flex flex-col gap-1.5 min-w-0"> |
| 319 | <span className="text-xs font-medium text-gray-500">{field.label}</span> |
| 320 | {field.type === 'boolean' ? ( |
| 321 | <select |
| 322 | value={String(Boolean(value))} |
| 323 | onChange={event => updateField(field, event.target.value === 'true')} |
| 324 | className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300" |
| 325 | > |
| 326 | <option value="true">true</option> |
| 327 | <option value="false">false</option> |
| 328 | </select> |
| 329 | ) : field.type === 'select' ? ( |
| 330 | <select |
| 331 | value={String(value ?? '')} |
| 332 | onChange={event => updateField(field, event.target.value)} |
| 333 | className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300" |
| 334 | > |
| 335 | {isProviderOptions(field.options) ? ( |
| 336 | field.options.map(group => ( |
| 337 | <optgroup key={group.provider} label={group.label}> |
| 338 | {group.models.map(model => ( |
| 339 | <option key={model.id} value={model.id}>{model.label}</option> |
| 340 | ))} |
| 341 | </optgroup> |
| 342 | )) |
| 343 | ) : ( |
| 344 | (field.options || []).map(option => ( |
| 345 | <option key={option.id} value={option.id}>{option.label}</option> |
| 346 | )) |
| 347 | )} |
| 348 | </select> |
| 349 | ) : field.type === 'password' ? ( |
| 350 | <input |
| 351 | type="text" |
| 352 | value={secretDrafts[field.path] ?? maskSecret(value)} |
| 353 | onFocus={event => event.currentTarget.select()} |
| 354 | onChange={event => updateSecretField(field, event.target.value)} |
| 355 | placeholder="输入新密钥覆盖" |
| 356 | className="h-10 rounded-lg border border-gray-200 bg-white px-3 font-mono text-sm text-gray-700 outline-none focus:border-blue-300" |
| 357 | /> |
| 358 | ) : ( |
| 359 | <input |
| 360 | type={field.type === 'number' ? 'number' : 'text'} |
| 361 | value={String(value ?? '')} |
| 362 | onChange={event => updateField(field, event.target.value)} |
| 363 | className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300" |
| 364 | /> |
| 365 | )} |
| 366 | </label> |
| 367 | ); |
| 368 | })} |
| 369 | </div> |
| 370 | </section> |
| 371 | ))} |
| 372 | |
| 373 | <div className="sticky bottom-4 flex items-center gap-3 rounded-2xl border border-gray-200 bg-white/95 p-3 shadow-lg backdrop-blur"> |
| 374 | {message && ( |
| 375 | <span className="flex items-center gap-1.5 text-sm text-green-600"> |
| 376 | <CheckCircle className="w-4 h-4" /> |
| 377 | {message} |
| 378 | </span> |
| 379 | )} |
| 380 | {error && ( |
| 381 | <span className="flex items-center gap-1.5 text-sm text-red-600"> |
| 382 | <XCircle className="w-4 h-4" /> |
| 383 | {error} |
| 384 | </span> |
| 385 | )} |
| 386 | <button |
| 387 | onClick={save} |
| 388 | disabled={saving} |
| 389 | className="ml-auto flex items-center gap-2 rounded-xl bg-blue-500 px-5 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-200" |
| 390 | > |
| 391 | {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} |
| 392 | 保存配置 |
| 393 | </button> |
| 394 | </div> |
| 395 | </div> |
| 396 | )} |
| 397 | </main> |
| 398 | </div> |
| 399 | ); |
| 400 | } |
| 401 |