Files
video-create/web/client/src/components/config/ConfigForm.tsx
sion123 088bdb9a8e feat(web): 重构前端UI并支持OpenAI协议
- 添加账号管理详情页(基本信息、提示词、CapCut、参考图标签页)
- 重构资产页面,按项目组分开展示图片/视频
- 聊天界面支持深度思考内容折叠展示、复制、删除消息
- 设置页面支持Agent配置(Anthropic/OpenAI协议)和工具配置
- 后端支持OpenAI兼容协议流式输出和DeepSeek思考模式
- 添加对话置顶/删除功能、数据库迁移、资产清单API
- 添加账号参考图上传/删除、技能配置持久化、连接测试API
2026-05-07 23:48:26 +08:00

359 lines
16 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { api } from '@/lib/api';
import { Save, Check, Loader2, TestTube2 } from 'lucide-react';
type SkillsConfig = Record<string, unknown>;
const SECTIONS = [
{
title: 'CapCut / 剪映',
fields: [
{ key: 'jianyingDraftPath', label: '剪映草稿路径', placeholder: 'C:/Users/.../com.lveditor.draft' },
{ key: 'capcutMateDir', label: 'CapCut Mate 目录', placeholder: 'C:/Users/.../capcut-mate' },
{ key: 'capcutMateApiBase', label: 'CapCut Mate API', placeholder: 'http://localhost:30000' },
],
},
{
title: 'Gemini 生图',
fields: [
{ key: 'geminiApiBaseUrl', label: 'API Base URL', placeholder: 'https://generativelanguage.googleapis.com' },
{ key: 'geminiApiKey', label: 'API Key', type: 'password' as const },
{ key: 'geminiModel', label: '模型', placeholder: 'gemini-3.1-flash-image-preview' },
],
},
{
title: 'Midjourney',
fields: [
{ key: 'mjApiBaseUrl', label: 'API Base URL' },
{ key: 'mjApiKey', label: 'API Key', type: 'password' as const },
],
},
{
title: 'Veo 视频生成',
fields: [
{ key: 'veoApiBaseUrl', label: 'API Base URL' },
{ key: 'veoApiKey', label: 'API Key', type: 'password' as const },
{ key: 'veoModel', label: '模型', placeholder: 'veo3-fast-frames' },
{ key: 'veoEnhancePrompt', label: '增强提示词', type: 'toggle' as const },
{ key: 'veoEnableUpsample', label: '启用 Upsample', type: 'toggle' as const },
],
},
{
title: 'Grok 视频生成',
fields: [
{ key: 'grokApiBaseUrl', label: 'API Base URL' },
{ key: 'grokApiKey', label: 'API Key', type: 'password' as const },
{ key: 'grokModel', label: '模型', placeholder: 'grok-video-3' },
],
},
{
title: 'GPT Image',
fields: [
{ key: 'gptImageApiBaseUrl', label: 'API Base URL' },
{ key: 'gptImageApiKey', label: 'API Key', type: 'password' as const },
{ key: 'gptImageModel', label: '模型', placeholder: 'gpt-image-2' },
],
},
{
title: 'Kling 可灵',
fields: [
{ key: 'kelingApiBaseUrl', label: 'API Base URL', placeholder: 'https://api-beijing.klingai.com' },
{ key: 'kelingApiKey', label: 'API Key', type: 'password' as const },
{ key: 'kelingSecretAccessKey', label: 'Secret Key', type: 'password' as const },
{ key: 'kelingModel', label: '模型', placeholder: 'kling-v2-5-turbo' },
],
},
{
title: 'OSS 存储',
fields: [
{ key: 'ossRegion', label: 'Region', placeholder: 'oss-cn-hangzhou' },
{ key: 'ossAccessKeyId', label: 'Access Key ID' },
{ key: 'ossAccessKeySecret', label: 'Access Key Secret', type: 'password' as const },
{ key: 'ossBucket', label: 'Bucket', placeholder: 'my-bucket' },
{ key: 'ossFolder', label: '目录前缀', placeholder: 'tmp/' },
{ key: 'ossExpires', label: '链接有效期(秒)', placeholder: '31536000' },
],
},
{
title: 'TTS 语音合成',
fields: [
{ key: 'ttsApiBaseUrl', label: 'API Base URL', placeholder: 'https://dashscope.aliyuncs.com/api/v1' },
{ key: 'ttsApiKey', label: 'API Key', type: 'password' as const },
{ key: 'ttsModel', label: '模型', placeholder: 'cosyvoice-v3.5-plus' },
{ key: 'ttsVoice', label: '音色 ID', placeholder: 'cosyvoice-v3.5-plus-bailian-xxx' },
{ key: 'ttsLanguage', label: '语言', placeholder: 'Chinese' },
],
},
];
export function ConfigForm() {
const [tab, setTab] = useState<'agent' | 'skills'>('agent');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; models?: string[] } | null>(null);
// Agent config (DB-based)
const [agentForm, setAgentForm] = useState({
protocol: 'anthropic' as 'anthropic' | 'openai',
model: '',
baseUrl: '',
authToken: '',
defaultImageModel: '',
defaultVideoModel: '',
defaultFormat: '',
});
// Skills config (file-based)
const [skillsForm, setSkillsForm] = useState<SkillsConfig>({});
useEffect(() => {
api.getConfigs().then((list) => {
const next = { ...agentForm };
for (const item of list) {
try {
const v = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
if (item.key === 'api_keys') {
if (v.ANTHROPIC_MODEL) next.model = v.ANTHROPIC_MODEL as string;
if (v.ANTHROPIC_BASE_URL) next.baseUrl = v.ANTHROPIC_BASE_URL as string;
if (v.ANTHROPIC_AUTH_TOKEN) next.authToken = v.ANTHROPIC_AUTH_TOKEN as string;
if (v.PROTOCOL) next.protocol = v.PROTOCOL as 'anthropic' | 'openai';
}
if (item.key === 'defaults') {
if (v.imageModel) next.defaultImageModel = v.imageModel;
if (v.videoModel) next.defaultVideoModel = v.videoModel;
if (v.format) next.defaultFormat = v.format;
}
} catch {}
}
setAgentForm(next);
});
api.getSkillsConfig().then(setSkillsForm).catch(() => {});
}, []);
const handleAgentChange = (key: string, value: string) => {
setAgentForm((f) => ({ ...f, [key]: value }));
};
const handleSkillsChange = (key: string, value: unknown) => {
setSkillsForm((f) => ({ ...f, [key]: value }));
};
const handleSave = async () => {
setSaving(true);
try {
if (tab === 'agent') {
await api.saveConfig('api_keys', {
PROTOCOL: agentForm.protocol,
ANTHROPIC_MODEL: agentForm.model,
ANTHROPIC_BASE_URL: agentForm.baseUrl,
ANTHROPIC_AUTH_TOKEN: agentForm.authToken,
});
await api.saveConfig('defaults', {
imageModel: agentForm.defaultImageModel,
videoModel: agentForm.defaultVideoModel,
format: agentForm.defaultFormat,
});
} else {
await api.saveSkillsConfig(skillsForm);
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch {}
setSaving(false);
};
return (
<div className="flex flex-col h-full">
<div className="flex gap-0 border-b border-zinc-200 px-6 bg-white">
<button
onClick={() => setTab('agent')}
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
${tab === 'agent' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
>
Agent
</button>
<button
onClick={() => setTab('skills')}
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
${tab === 'skills' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
>
</button>
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-2xl mx-auto p-6 space-y-6">
{tab === 'agent' ? (
<>
<section className="space-y-4">
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">API </h3>
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<div className="mt-1 flex gap-2">
{(['anthropic', 'openai'] as const).map((p) => (
<button
key={p}
onClick={() => handleAgentChange('protocol', p)}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors border ${
agentForm.protocol === p
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-zinc-50 border-zinc-200 text-zinc-500 hover:bg-zinc-100'
}`}
>
{p === 'anthropic' ? 'Anthropic' : 'OpenAI'}
</button>
))}
</div>
<p className="text-[10px] text-zinc-400 mt-1">
{agentForm.protocol === 'anthropic'
? 'Anthropic 兼容协议(支持 Claude、GLM、DeepSeek 代理等)'
: 'OpenAI 兼容协议(支持 GPT、DeepSeek、Qwen、Ollama 等)'}
</p>
</div>
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<Input value={agentForm.model} onChange={(e) => handleAgentChange('model', e.target.value)}
placeholder={agentForm.protocol === 'anthropic' ? 'claude-sonnet-4-6 或 GLM-5.1' : 'gpt-4o 或 deepseek-chat'}
className="mt-1 bg-zinc-50 border-zinc-200" />
</div>
<div>
<label className="text-xs font-medium text-zinc-500">Base URL</label>
<Input value={agentForm.baseUrl} onChange={(e) => handleAgentChange('baseUrl', e.target.value)}
placeholder={agentForm.protocol === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1'}
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs" />
</div>
<div>
<label className="text-xs font-medium text-zinc-500">Auth Token</label>
<Input value={agentForm.authToken} onChange={(e) => handleAgentChange('authToken', e.target.value)}
type="password" placeholder="sk-..." className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs" />
</div>
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
variant="outline"
className="text-xs"
disabled={testing}
onClick={async () => {
setTesting(true);
setTestResult(null);
try {
await handleSave();
const res = await fetch('/api/configs/test-connection', { method: 'POST' });
const data = await res.json();
setTestResult(data);
} catch (e) {
setTestResult({ ok: false, error: (e as Error).message });
}
setTesting(false);
}}
>
{testing ? <Loader2 size={12} className="mr-1 animate-spin" /> : <TestTube2 size={12} className="mr-1" />}
{testing ? '测试中...' : '测试连接'}
</Button>
{testResult && (
<span className={`text-xs ${testResult.ok ? 'text-green-600' : 'text-red-500'}`}>
{testResult.ok
? `连接成功 ${testResult.models?.length ? `(${testResult.models.join(', ')})` : ''}`
: testResult.error}
</span>
)}
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide"></h3>
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={agentForm.defaultImageModel} onChange={(e) => handleAgentChange('defaultImageModel', e.target.value)}
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
<option value=""></option>
<option value="gemini">Gemini</option>
<option value="mj">Midjourney</option>
<option value="gpt">GPT Image</option>
<option value="kling">Kling</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={agentForm.defaultVideoModel} onChange={(e) => handleAgentChange('defaultVideoModel', e.target.value)}
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
<option value=""></option>
<option value="veo3-fast">Veo3 Fast</option>
<option value="veo3-fast-frames">Veo3 Fast Frames</option>
<option value="kling">Kling</option>
<option value="grok">Grok</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={agentForm.defaultFormat} onChange={(e) => handleAgentChange('defaultFormat', e.target.value)}
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
<option value=""></option>
<option value="9:16">9:16 </option>
<option value="16:9">16:9 </option>
<option value="1:1">1:1 </option>
</select>
</div>
</div>
</div>
</section>
</>
) : (
<>
{SECTIONS.map((section) => (
<section key={section.title} className="space-y-3">
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">{section.title}</h3>
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
{section.fields.map((field) => {
const val = skillsForm[field.key];
if (field.type === 'toggle') {
return (
<div key={field.key} className="flex items-center justify-between">
<label className="text-xs font-medium text-zinc-500">{field.label}</label>
<button
onClick={() => handleSkillsChange(field.key, !val)}
className={`w-10 h-5 rounded-full transition-colors relative ${val ? 'bg-indigo-600' : 'bg-zinc-300'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${val ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
);
}
return (
<div key={field.key}>
<label className="text-xs font-medium text-zinc-500">{field.label}</label>
<Input
value={typeof val === 'string' ? val : val != null ? String(val) : ''}
onChange={(e) => handleSkillsChange(field.key, e.target.value)}
type={field.type === 'password' ? 'password' : 'text'}
placeholder={field.placeholder}
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
/>
</div>
);
})}
</div>
</section>
))}
</>
)}
<Button onClick={handleSave} disabled={saving} className="w-full">
{saved ? <><Check size={14} className="mr-1" /></> :
saving ? <><Loader2 size={14} className="mr-1 animate-spin" />...</> :
<><Save size={14} className="mr-1" /></>}
</Button>
</div>
</div>
</div>
);
}