返回 VideoClaw
page.tsx
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
401 lines Plain Text