feat(web): 重构前端UI并支持OpenAI协议
- 添加账号管理详情页(基本信息、提示词、CapCut、参考图标签页) - 重构资产页面,按项目组分开展示图片/视频 - 聊天界面支持深度思考内容折叠展示、复制、删除消息 - 设置页面支持Agent配置(Anthropic/OpenAI协议)和工具配置 - 后端支持OpenAI兼容协议流式输出和DeepSeek思考模式 - 添加对话置顶/删除功能、数据库迁移、资产清单API - 添加账号参考图上传/删除、技能配置持久化、连接测试API
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Edit(/.claude/skills/video-from-script/**)"
|
||||
"Edit(/.claude/skills/video-from-script/**)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npx vite *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1
web/client/dist/assets/index-CXGrBwuC.css
vendored
Normal file
1
web/client/dist/assets/index-CXGrBwuC.css
vendored
Normal file
File diff suppressed because one or more lines are too long
274
web/client/dist/assets/index-V7y9417K.js
vendored
Normal file
274
web/client/dist/assets/index-V7y9417K.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
web/client/dist/index.html
vendored
Normal file
13
web/client/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>美图 Agent</title>
|
||||
<script type="module" crossorigin src="/assets/index-V7y9417K.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CXGrBwuC.css">
|
||||
</head>
|
||||
<body class="bg-white text-zinc-900 antialiased">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
108
web/client/package-lock.json
generated
108
web/client/package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "meitu-agent-client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-react": "^2.1.1",
|
||||
"@chatscope/chat-ui-kit-styles": "^1.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
@@ -329,6 +331,32 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chatscope/chat-ui-kit-react": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-react/-/chat-ui-kit-react-2.1.1.tgz",
|
||||
"integrity": "sha512-rCtE9abdmAbBDkAAUYBC1TDTBMZHquqFIZhADptAfHcJ8z8W3XH/z/ZuwBSJXtzi6h1mwCNc3tBmm1A2NLGhNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-styles": "^1.2.0",
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"classnames": "^2.2.6",
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0",
|
||||
"react-dom": "^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chatscope/chat-ui-kit-styles": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-styles/-/chat-ui-kit-styles-1.4.0.tgz",
|
||||
"integrity": "sha512-016mBJD3DESw7Nh+lkKcPd22xG92ghA0VpIXIbjQtmXhC7Ve6wRazTy8z1Ahut+Tbv179+JxrftuMngsj/yV8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -720,6 +748,62 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
|
||||
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
|
||||
"integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==",
|
||||
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
|
||||
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
|
||||
"integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/react-fontawesome": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.6.tgz",
|
||||
"integrity": "sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==",
|
||||
"deprecated": "v0.2.x is no longer supported. Unless you are still using FontAwesome 5, please update to v3.1.1 or greater.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7",
|
||||
"react": "^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1553,6 +1637,12 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2120,7 +2210,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -2355,6 +2444,17 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
@@ -2410,6 +2510,12 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-react": "^2.1.1",
|
||||
"@chatscope/chat-ui-kit-styles": "^1.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
|
||||
115
web/client/src/components/accounts/AccountBasicTab.tsx
Normal file
115
web/client/src/components/accounts/AccountBasicTab.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Account } from '@/types';
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
onSave: (data: Partial<Account>) => void;
|
||||
}
|
||||
|
||||
export function AccountBasicTab({ account, onSave }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: account.name || '',
|
||||
description: account.description || '',
|
||||
pipeline: account.pipeline || 'image-video',
|
||||
defaultFormat: account.defaultFormat || '9:16',
|
||||
imageModel: account.imageModel || 'gemini',
|
||||
videoModel: account.videoModel || 'veo3-fast',
|
||||
batchSize: account.batchSize || 30,
|
||||
ttsVoice: account.ttsVoice || '',
|
||||
ttsInstruction: account.ttsInstruction || '',
|
||||
});
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleChange = (key: string, value: string | number) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(form);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">名称</label>
|
||||
<Input value={form.name} onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="mt-1 bg-zinc-50 border-zinc-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">描述</label>
|
||||
<Input value={form.description} onChange={(e) => handleChange('description', e.target.value)}
|
||||
placeholder="账号描述..." className="mt-1 bg-zinc-50 border-zinc-200" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">画幅</label>
|
||||
<select value={form.defaultFormat} onChange={(e) => handleChange('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="9:16">9:16 竖屏</option>
|
||||
<option value="16:9">16:9 横屏</option>
|
||||
<option value="1:1">1:1 方形</option>
|
||||
<option value="4:3">4:3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">生图模型</label>
|
||||
<select value={form.imageModel} onChange={(e) => handleChange('imageModel', 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="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={form.videoModel} onChange={(e) => handleChange('videoModel', 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="veo3-fast">Veo3 Fast</option>
|
||||
<option value="veo3-fast-frames">Veo3 Frames</option>
|
||||
<option value="kling">Kling</option>
|
||||
<option value="grok">Grok</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Pipeline</label>
|
||||
<select value={form.pipeline} onChange={(e) => handleChange('pipeline', 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="image-video">图片+视频</option>
|
||||
<option value="single">单图模式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">批量数量</label>
|
||||
<Input type="number" value={form.batchSize} onChange={(e) => handleChange('batchSize', parseInt(e.target.value) || 30)}
|
||||
className="mt-1 bg-zinc-50 border-zinc-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">TTS 语音 ID</label>
|
||||
<Input value={form.ttsVoice} onChange={(e) => handleChange('ttsVoice', e.target.value)}
|
||||
placeholder="cosyvoice-xxx" className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">TTS 指令</label>
|
||||
<textarea value={form.ttsInstruction} onChange={(e) => handleChange('ttsInstruction', e.target.value)}
|
||||
rows={3} placeholder="用沉稳有力的男性声音朗读..."
|
||||
className="mt-1 w-full rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm resize-y" />
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave}>
|
||||
{saved ? '已保存' : '保存基本信息'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
web/client/src/components/accounts/AccountCapCutTab.tsx
Normal file
122
web/client/src/components/accounts/AccountCapCutTab.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Account, CapCutConfig } from '@/types';
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
onSave: (data: Partial<Account>) => void;
|
||||
}
|
||||
|
||||
const defaultCapcut: CapCutConfig = {
|
||||
effects: [],
|
||||
filter: '',
|
||||
subtitleStyle: { fontSize: 24, color: '#FFFFFF', highlightColor: '#FF6B35', bold: true, transformY: -380, inAnimation: '淡入', outAnimation: '淡出' },
|
||||
defaultBGM: '',
|
||||
};
|
||||
|
||||
export function AccountCapCutTab({ account, onSave }: Props) {
|
||||
const capcut = account.capcut || defaultCapcut;
|
||||
const [effects, setEffects] = useState((capcut.effects || []).join(', '));
|
||||
const [filter, setFilter] = useState(capcut.filter || '');
|
||||
const [sub, setSub] = useState(capcut.subtitleStyle || {});
|
||||
const [bgm, setBgm] = useState(capcut.defaultBGM || '');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
capcut: {
|
||||
...capcut,
|
||||
effects: effects.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
filter,
|
||||
subtitleStyle: sub,
|
||||
defaultBGM: bgm,
|
||||
},
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-5">
|
||||
{/* Effects */}
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-xs font-semibold text-zinc-600 uppercase tracking-wide">特效</legend>
|
||||
<Input value={effects} onChange={(e) => setEffects(e.target.value)}
|
||||
placeholder="特效名1, 特效名2" className="bg-zinc-50 border-zinc-200 text-xs" />
|
||||
<p className="text-[10px] text-zinc-400">多个特效用逗号分隔</p>
|
||||
</fieldset>
|
||||
|
||||
{/* Filter */}
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-xs font-semibold text-zinc-600 uppercase tracking-wide">滤镜</legend>
|
||||
<Input value={filter} onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="质感暗调:30" className="bg-zinc-50 border-zinc-200 text-xs font-mono" />
|
||||
</fieldset>
|
||||
|
||||
{/* Subtitle Style */}
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-xs font-semibold text-zinc-600 uppercase tracking-wide">字幕样式</legend>
|
||||
<div className="bg-zinc-50 rounded-lg border border-zinc-200 p-3 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500">字号</label>
|
||||
<Input type="number" value={sub.fontSize || ''} onChange={(e) => setSub({ ...sub, fontSize: parseInt(e.target.value) || undefined })}
|
||||
className="h-8 text-xs bg-white border-zinc-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500">颜色</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<input type="color" value={sub.color || '#FFFFFF'} onChange={(e) => setSub({ ...sub, color: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-zinc-200 cursor-pointer" />
|
||||
<Input value={sub.color || ''} onChange={(e) => setSub({ ...sub, color: e.target.value })}
|
||||
className="h-8 text-xs bg-white border-zinc-200 font-mono flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500">高亮色</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<input type="color" value={sub.highlightColor || '#FF6B35'} onChange={(e) => setSub({ ...sub, highlightColor: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-zinc-200 cursor-pointer" />
|
||||
<Input value={sub.highlightColor || ''} onChange={(e) => setSub({ ...sub, highlightColor: e.target.value })}
|
||||
className="h-8 text-xs bg-white border-zinc-200 font-mono flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500">Y偏移</label>
|
||||
<Input type="number" value={sub.transformY || ''} onChange={(e) => setSub({ ...sub, transformY: parseInt(e.target.value) || undefined })}
|
||||
className="h-8 text-xs bg-white border-zinc-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500">入动画</label>
|
||||
<Input value={sub.inAnimation || ''} onChange={(e) => setSub({ ...sub, inAnimation: e.target.value })}
|
||||
placeholder="淡入" className="h-8 text-xs bg-white border-zinc-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500">出动画</label>
|
||||
<Input value={sub.outAnimation || ''} onChange={(e) => setSub({ ...sub, outAnimation: e.target.value })}
|
||||
placeholder="淡出" className="h-8 text-xs bg-white border-zinc-200" />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-zinc-600">
|
||||
<input type="checkbox" checked={sub.bold || false} onChange={(e) => setSub({ ...sub, bold: e.target.checked })} />
|
||||
粗体
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* BGM */}
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-xs font-semibold text-zinc-600 uppercase tracking-wide">背景音乐</legend>
|
||||
<Input value={bgm} onChange={(e) => setBgm(e.target.value)}
|
||||
placeholder="BGM URL" className="bg-zinc-50 border-zinc-200 text-xs font-mono" />
|
||||
</fieldset>
|
||||
|
||||
<Button onClick={handleSave}>
|
||||
{saved ? '已保存' : '保存 CapCut 配置'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
web/client/src/components/accounts/AccountCard.tsx
Normal file
64
web/client/src/components/accounts/AccountCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Video, Image, Square } from 'lucide-react';
|
||||
import type { Account } from '@/types';
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
gemini: 'Gemini',
|
||||
mj: 'MJ',
|
||||
gpt: 'GPT',
|
||||
kling: 'Kling',
|
||||
'veo3-fast': 'Veo3',
|
||||
'veo3-fast-frames': 'Veo3F',
|
||||
grok: 'Grok',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AccountCard({ account, isSelected, onClick }: Props) {
|
||||
const formatBadge = account.defaultFormat || '9:16';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left rounded-xl border p-4 transition-all cursor-pointer group
|
||||
${isSelected
|
||||
? 'border-indigo-400 bg-indigo-50 shadow-sm'
|
||||
: 'border-zinc-200 bg-white hover:border-indigo-300 hover:shadow-md'}`}
|
||||
>
|
||||
{/* Top: name + format badge */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-semibold text-sm text-zinc-800 truncate flex-1 mr-2">{account.name}</h3>
|
||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
isSelected ? 'bg-indigo-200 text-indigo-700' : 'bg-zinc-100 text-zinc-500'
|
||||
}`}>
|
||||
{formatBadge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{account.description && (
|
||||
<p className="text-xs text-zinc-500 line-clamp-2 mb-3">{account.description}</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
||||
<Image size={10} />
|
||||
{MODEL_LABELS[account.imageModel] || account.imageModel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
|
||||
<Video size={10} />
|
||||
{MODEL_LABELS[account.videoModel] || account.videoModel}
|
||||
</span>
|
||||
{account.ttsVoice && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-50 text-green-600">
|
||||
TTS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
70
web/client/src/components/accounts/AccountDetail.tsx
Normal file
70
web/client/src/components/accounts/AccountDetail.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AccountBasicTab } from './AccountBasicTab';
|
||||
import { AccountPromptTab } from './AccountPromptTab';
|
||||
import { AccountCapCutTab } from './AccountCapCutTab';
|
||||
import { AccountReferencesTab } from './AccountReferencesTab';
|
||||
import type { Account } from '@/types';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'basic', label: '基本信息' },
|
||||
{ key: 'prompts', label: '提示词' },
|
||||
{ key: 'capcut', label: 'CapCut' },
|
||||
{ key: 'references', label: '参考图' },
|
||||
] as const;
|
||||
|
||||
type TabKey = typeof TABS[number]['key'];
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
onSave: (data: Partial<Account>) => void;
|
||||
onDelete: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function AccountDetail({ account, onSave, onDelete, onBack }: Props) {
|
||||
const [tab, setTab] = useState<TabKey>('basic');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-200 bg-white flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="p-1 rounded hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 transition-colors">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<h2 className="text-base font-semibold text-zinc-800">{account.name}</h2>
|
||||
<span className="text-xs text-zinc-400">{account.id}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="text-red-500 hover:bg-red-50 hover:text-red-600" onClick={() => {
|
||||
if (confirm(`确定删除账户「${account.name}」?`)) onDelete();
|
||||
}}>
|
||||
<Trash2 size={14} className="mr-1" />删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-0 border-b border-zinc-200 px-6 bg-white flex-shrink-0">
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||
${tab === key ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{tab === 'basic' && <AccountBasicTab account={account} onSave={onSave} />}
|
||||
{tab === 'prompts' && <AccountPromptTab accountId={account.id} />}
|
||||
{tab === 'capcut' && <AccountCapCutTab account={account} onSave={onSave} />}
|
||||
{tab === 'references' && <AccountReferencesTab account={account} onSave={onSave} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +1,92 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAccounts } from '@/hooks/useAccounts';
|
||||
import { AccountForm } from './AccountForm';
|
||||
import { PromptEditor } from '@/components/prompts/PromptEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { AccountCard } from './AccountCard';
|
||||
import { AccountDetail } from './AccountDetail';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export function AccountList() {
|
||||
const { accountId } = useParams<{ accountId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { accounts, create, update, remove } = useAccounts();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const editing = accountId ? accounts.find((a) => a.id === accountId) || null : null;
|
||||
const selected = accountId ? accounts.find((a) => a.id === accountId) || null : null;
|
||||
|
||||
// Reset creating when navigating to an account
|
||||
useEffect(() => { setCreating(false); }, [accountId]);
|
||||
const filtered = accounts.filter((a) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return a.name.toLowerCase().includes(q) || (a.description || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const handleSelectAccount = (id: string) => {
|
||||
navigate(`/accounts/${id}`);
|
||||
const handleCreate = async () => {
|
||||
const name = `新账号 ${accounts.length + 1}`;
|
||||
const acc = await create({ name, pipeline: 'image-video', defaultFormat: '9:16', imageModel: 'gemini', videoModel: 'veo3-fast' });
|
||||
if (acc?.id) navigate(`/accounts/${acc.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<ScrollArea className="w-56 border-r border-zinc-200 bg-zinc-50 p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-zinc-700">账户列表</h2>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-zinc-500 hover:text-zinc-700" onClick={() => { setCreating(true); navigate('/accounts'); }}>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => handleSelectAccount(a.id)}
|
||||
className={`block w-full text-left px-3 py-2 rounded-md text-sm transition-colors mb-0.5
|
||||
${a.id === accountId
|
||||
? 'bg-indigo-50 text-indigo-700 font-medium'
|
||||
: 'text-zinc-600 hover:bg-white hover:text-zinc-900'}`}
|
||||
>
|
||||
<div className="font-medium truncate">{a.name}</div>
|
||||
<div className="text-xs opacity-60">{a.imageModel} · {a.defaultFormat}</div>
|
||||
</button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
// In detail view
|
||||
if (selected) {
|
||||
return (
|
||||
<AccountDetail
|
||||
account={selected}
|
||||
onSave={(data) => update(selected.id, data)}
|
||||
onDelete={() => remove(selected.id).then(() => navigate('/accounts'))}
|
||||
onBack={() => navigate('/accounts')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
<div className="flex gap-0 border-b border-zinc-200 px-6">
|
||||
<button
|
||||
onClick={() => setSubTab('info')}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||
${subTab === 'info' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
|
||||
>
|
||||
账户设置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('prompts')}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||
${subTab === 'prompts' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
|
||||
>
|
||||
提示词模板
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{subTab === 'info' ? (
|
||||
(editing || creating) ? (
|
||||
<div className="p-6">
|
||||
<AccountForm
|
||||
account={creating ? undefined : editing!}
|
||||
onSave={(data) => {
|
||||
if (creating) {
|
||||
create(data).then(() => { setCreating(false); if (data.id) navigate(`/accounts/${data.id}`); });
|
||||
} else {
|
||||
update(editing!.id, data);
|
||||
}
|
||||
}}
|
||||
onDelete={editing ? () => {
|
||||
if (confirm(`确定删除账户「${editing.name}」?`)) {
|
||||
remove(editing.id).then(() => navigate('/accounts'));
|
||||
}
|
||||
} : undefined}
|
||||
onCancel={() => { setCreating(false); }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-zinc-400 text-sm">
|
||||
选择一个账户或创建新账户
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<PromptEditor />
|
||||
)}
|
||||
// Card grid view
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 bg-white flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-base font-semibold text-zinc-800">账号管理</h1>
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索账号..."
|
||||
className="pl-8 h-8 w-48 text-xs bg-zinc-50 border-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{filtered.map((a) => (
|
||||
<AccountCard
|
||||
key={a.id}
|
||||
account={a}
|
||||
isSelected={a.id === accountId}
|
||||
onClick={() => navigate(`/accounts/${a.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-400">
|
||||
<p className="text-sm">{search ? '没有匹配的账号' : '暂无账号'}</p>
|
||||
{!search && (
|
||||
<button onClick={handleCreate} className="mt-2 text-xs text-indigo-600 hover:underline">
|
||||
创建第一个账号
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
9
web/client/src/components/accounts/AccountPromptTab.tsx
Normal file
9
web/client/src/components/accounts/AccountPromptTab.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PromptEditor } from '@/components/prompts/PromptEditor';
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function AccountPromptTab({ accountId }: Props) {
|
||||
return <PromptEditor accountId={accountId} />;
|
||||
}
|
||||
108
web/client/src/components/accounts/AccountReferencesTab.tsx
Normal file
108
web/client/src/components/accounts/AccountReferencesTab.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Upload, Trash2, ImageIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Account, ReferenceItem } from '@/types';
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
onSave: (data: Partial<Account>) => void;
|
||||
}
|
||||
|
||||
export function AccountReferencesTab({ account, onSave }: Props) {
|
||||
const refs = account.references || [];
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await fetch(`/api/accounts/${account.id}/references/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
// Reload account to get updated references
|
||||
const updated = await api.getAccount(account.id);
|
||||
onSave({ references: updated.references });
|
||||
} catch (err) {
|
||||
alert('上传失败: ' + (err as Error).message);
|
||||
}
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleDelete = async (index: number) => {
|
||||
try {
|
||||
await fetch(`/api/accounts/${account.id}/references/${index}`, { method: 'DELETE' });
|
||||
const updated = await api.getAccount(account.id);
|
||||
onSave({ references: updated.references });
|
||||
} catch (err) {
|
||||
alert('删除失败: ' + (err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
{/* Upload */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||
<Upload size={14} className="mr-1" />
|
||||
{uploading ? '上传中...' : '添加参考图'}
|
||||
</Button>
|
||||
<span className="text-xs text-zinc-400">{refs.length} 张参考图</span>
|
||||
</div>
|
||||
|
||||
{/* Reference grid */}
|
||||
{refs.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{refs.map((ref, i) => (
|
||||
<div key={i} className="group relative aspect-square bg-zinc-100 rounded-lg overflow-hidden border border-zinc-200">
|
||||
{ref.url ? (
|
||||
<img src={ref.url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon size={20} className="text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(i)}
|
||||
className="absolute top-1 right-1 p-1 rounded bg-white/80 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
|
||||
>
|
||||
<Trash2 size={11} className="text-red-500" />
|
||||
</button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/30 to-transparent p-1">
|
||||
<span className="text-[9px] text-white truncate block">{ref.file}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-zinc-400">
|
||||
<ImageIcon size={28} className="mx-auto mb-2 opacity-40" />
|
||||
<p className="text-xs">暂无参考图</p>
|
||||
<p className="text-[10px] mt-1">点击上方按钮上传参考图</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Styles section */}
|
||||
{account.styles && Object.keys(account.styles).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-zinc-600 mb-2">风格预设</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(account.styles).map(([name, style]) => (
|
||||
<div key={name} className="flex items-center justify-between px-3 py-2 bg-zinc-50 rounded-lg text-xs">
|
||||
<span className="font-medium text-zinc-700">{name}</span>
|
||||
<span className="text-zinc-400">{style.references?.length || 0} 张</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,81 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trash2, RefreshCw } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { RefreshCw, FolderOpen } from 'lucide-react';
|
||||
import { useAssets } from '@/hooks/useAssets';
|
||||
import { AssetProjectGroup } from './AssetProjectGroup';
|
||||
import { AssetPreview } from './AssetPreview';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Asset } from '@/types';
|
||||
|
||||
interface ManifestGroup {
|
||||
manifest_path: string;
|
||||
account_id: string;
|
||||
image_count: number;
|
||||
video_count: number;
|
||||
latest_at: string;
|
||||
}
|
||||
|
||||
export function AssetGallery() {
|
||||
const [accountFilter, setAccountFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]);
|
||||
const [manifests, setManifests] = useState<ManifestGroup[]>([]);
|
||||
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
|
||||
const { assets, loading, remove } = useAssets({
|
||||
accountId: accountFilter || undefined,
|
||||
type: typeFilter || undefined,
|
||||
});
|
||||
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/accounts').then((r) => r.json()).then(setAccounts).catch(() => {});
|
||||
fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Group assets by manifest_path
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Asset[]>();
|
||||
for (const asset of assets) {
|
||||
const key = asset.manifest_path || 'ungrouped';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(asset);
|
||||
}
|
||||
return map;
|
||||
}, [assets]);
|
||||
|
||||
const handleScan = async () => {
|
||||
await fetch('/api/assets/scan', { method: 'POST' });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const getGroupName = (manifestPath: string) => {
|
||||
const parts = manifestPath.replace(/\\/g, '/').split('/');
|
||||
// output/<name>/manifest.json → <name>
|
||||
const dir = parts.length >= 2 ? parts[parts.length - 2] : manifestPath;
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getGroupSubtitle = (manifestPath: string) => {
|
||||
const m = manifests.find((m) => m.manifest_path === manifestPath);
|
||||
if (!m) return '';
|
||||
const parts: string[] = [];
|
||||
if (m.image_count) parts.push(`${m.image_count} 图`);
|
||||
if (m.video_count) parts.push(`${m.video_count} 视频`);
|
||||
const acc = accounts.find((a) => a.id === m.account_id);
|
||||
if (acc) parts.push(acc.name);
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
// Filter manifests by account
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!accountFilter) return [...grouped.entries()];
|
||||
return [...grouped.entries()].filter(([key]) => {
|
||||
if (key === 'ungrouped') return true;
|
||||
const m = manifests.find((m) => m.manifest_path === key);
|
||||
return m?.account_id === accountFilter;
|
||||
});
|
||||
}, [grouped, manifests, accountFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-200 bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-200 bg-white flex-shrink-0">
|
||||
<select
|
||||
value={accountFilter}
|
||||
onChange={(e) => setAccountFilter(e.target.value)}
|
||||
@@ -37,15 +86,7 @@ export function AssetGallery() {
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-zinc-200 bg-zinc-50 px-2 text-xs"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="image">图片</option>
|
||||
<option value="video">视频</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-zinc-400">{assets.length} 个资产</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}>
|
||||
<RefreshCw size={12} className="mr-1" />
|
||||
@@ -53,55 +94,27 @@ export function AssetGallery() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-zinc-400 text-sm text-center mt-8">加载中...</p>
|
||||
) : assets.length === 0 ? (
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center mt-16 text-zinc-400">
|
||||
<FolderOpen size={28} className="mb-2 opacity-40" />
|
||||
<p className="text-sm">暂无资产</p>
|
||||
<p className="text-xs mt-1">点击"扫描"从 output 目录导入</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="group relative aspect-square bg-zinc-100 rounded-lg overflow-hidden cursor-pointer border border-zinc-200 hover:border-indigo-300 hover:shadow-sm transition-all"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
>
|
||||
{asset.type === 'image' ? (
|
||||
<img
|
||||
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); remove(asset.id); }}
|
||||
className="absolute top-1 right-1 p-1 rounded bg-white/80 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
|
||||
>
|
||||
<Trash2 size={12} className="text-red-500" />
|
||||
</button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/40 to-transparent p-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-white font-medium">
|
||||
{asset.type === 'image' ? '🖼' : '🎬'} #{asset.shot_index || '?'}
|
||||
</span>
|
||||
<span className="text-[8px] text-white/70">
|
||||
{asset.created_at ? new Date(asset.created_at).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
filteredGroups.map(([manifestPath, groupAssets]) => (
|
||||
<AssetProjectGroup
|
||||
key={manifestPath}
|
||||
title={getGroupName(manifestPath)}
|
||||
subtitle={getGroupSubtitle(manifestPath)}
|
||||
assets={groupAssets}
|
||||
onPreview={setPreviewAsset}
|
||||
onDelete={remove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
78
web/client/src/components/assets/AssetProjectGroup.tsx
Normal file
78
web/client/src/components/assets/AssetProjectGroup.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Trash2, Film, Image as ImageIcon } from 'lucide-react';
|
||||
import type { Asset } from '@/types';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
assets: Asset[];
|
||||
onPreview: (asset: Asset) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete }: Props) {
|
||||
const images = assets.filter((a) => a.type === 'image');
|
||||
const videos = assets.filter((a) => a.type === 'video');
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-700 truncate">{title}</h3>
|
||||
{subtitle && <span className="text-[10px] text-zinc-400">{subtitle}</span>}
|
||||
<div className="flex items-center gap-2 ml-auto text-[10px] text-zinc-400">
|
||||
{images.length > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ImageIcon size={10} /> {images.length}
|
||||
</span>
|
||||
)}
|
||||
{videos.length > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Film size={10} /> {videos.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset strip */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="group relative flex-shrink-0 w-24 h-24 bg-zinc-100 rounded-lg overflow-hidden cursor-pointer border border-zinc-200 hover:border-indigo-300 hover:shadow-sm transition-all"
|
||||
onClick={() => onPreview(asset)}
|
||||
>
|
||||
{asset.type === 'image' ? (
|
||||
<img
|
||||
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-800">
|
||||
<Film size={20} className="text-zinc-400" />
|
||||
<video
|
||||
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
muted
|
||||
preload="none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(asset.id); }}
|
||||
className="absolute top-1 right-1 p-0.5 rounded bg-white/80 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
|
||||
>
|
||||
<Trash2 size={10} className="text-red-500" />
|
||||
</button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/40 to-transparent px-1 py-0.5">
|
||||
<span className="text-[8px] text-white">
|
||||
{asset.type === 'image' ? 'IMG' : 'VID'} #{asset.shot_index ?? '?'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,146 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { renderMarkdown } from '@/lib/markdown';
|
||||
import { RefreshCw, ArrowRight, Quote, Bot, User } from 'lucide-react';
|
||||
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Check, Brain } from 'lucide-react';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
type MessageState = 'streaming' | 'thinking' | 'done';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
isLast?: boolean;
|
||||
isThinking?: boolean;
|
||||
onRegenerate?: (msgId: string) => void;
|
||||
onContinue?: (msgId: string) => void;
|
||||
onContinue?: () => void;
|
||||
onQuote?: (content: string) => void;
|
||||
onDelete?: (msgId: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, onRegenerate, onContinue, onQuote }: Props) {
|
||||
function getState(msg: Message, isLast: boolean, isThinking: boolean): MessageState {
|
||||
if (msg.role === 'user') return 'done';
|
||||
if (isThinking && isLast) return 'thinking';
|
||||
if (!msg.content && !msg.reasoningContent) return 'streaming';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isLast, isThinking, onRegenerate, onContinue, onQuote, onDelete }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
const isEmpty = !message.content;
|
||||
const isTool = message.role === 'tool';
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [reasoningOpen, setReasoningOpen] = useState(false);
|
||||
const state = getState(message, !!isLast, !!isThinking);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [message.content]);
|
||||
|
||||
// Tool messages
|
||||
if (isTool) {
|
||||
let toolName = '';
|
||||
try { const p = JSON.parse(message.content); toolName = p.tool || ''; } catch {}
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1 px-3">
|
||||
<Bot size={13} className="text-zinc-400" />
|
||||
<span className="text-xs text-zinc-400">工具调用: {toolName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showActions = state === 'done' && !isUser && message.content;
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-3 mb-6', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
||||
<div className={cn('flex gap-3 mb-5 group/msg', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
||||
{/* Avatar */}
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
'w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-xs',
|
||||
isUser ? 'bg-indigo-600 text-white' : 'bg-zinc-100 text-zinc-500'
|
||||
)}>
|
||||
{isUser ? <User size={15} /> : <Bot size={15} />}
|
||||
{isUser ? <User size={13} /> : <Bot size={13} />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn('flex-1 max-w-[75%]', isUser && 'flex flex-col items-end')}>
|
||||
{/* Role label */}
|
||||
<div className={cn('text-[10px] font-medium mb-1 px-1', isUser ? 'text-right text-indigo-500' : 'text-zinc-400')}>
|
||||
{isUser ? '你' : '美图 Agent'}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div className={cn(
|
||||
'rounded-2xl px-4 py-3 text-sm leading-relaxed',
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white rounded-tr-md'
|
||||
: 'bg-white text-zinc-700 border border-zinc-200/60 shadow-sm rounded-tl-md'
|
||||
)}>
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center gap-1 text-zinc-400">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse delay-100" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse delay-200" />
|
||||
{/* Body */}
|
||||
<div className={cn('flex-1 max-w-[78%]', isUser && 'flex flex-col items-end')}>
|
||||
{/* Thinking indicator */}
|
||||
{state === 'thinking' && !message.content && !message.reasoningContent && (
|
||||
<div className="rounded-2xl px-3.5 py-2.5 bg-zinc-50 border border-zinc-100 rounded-tl-md">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm">
|
||||
<Brain size={14} className="animate-pulse" />
|
||||
<span>深度思考中...</span>
|
||||
</div>
|
||||
) : isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="markdown-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions - only for assistant messages */}
|
||||
{!isUser && !isEmpty && (
|
||||
<div className="flex gap-0.5 mt-1 opacity-0 hover:opacity-100 transition-opacity px-1">
|
||||
{/* Reasoning block (collapsible) */}
|
||||
{message.reasoningContent && (
|
||||
<div className="mb-1.5 w-full">
|
||||
<button
|
||||
onClick={() => setReasoningOpen(!reasoningOpen)}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-600 transition-colors py-1"
|
||||
>
|
||||
{state === 'streaming' || state === 'thinking' ? (
|
||||
<Brain size={12} className="animate-pulse text-indigo-400" />
|
||||
) : (
|
||||
<Brain size={12} />
|
||||
)}
|
||||
<span>{state === 'done' ? '深度思考' : '思考中...'}</span>
|
||||
{state === 'done' && (
|
||||
reasoningOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
||||
)}
|
||||
</button>
|
||||
{(reasoningOpen || state !== 'done') && (
|
||||
<div className="mt-1 px-3 py-2 rounded-lg bg-indigo-50/50 border border-indigo-100 text-xs text-zinc-500 leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap">
|
||||
{message.reasoningContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content bubble */}
|
||||
{!(message.content === '(调用工具)' && state === 'done' && message.reasoningContent) && (
|
||||
<div className={cn(
|
||||
'rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed',
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white rounded-tr-md'
|
||||
: 'bg-zinc-50 text-zinc-700 border border-zinc-100 rounded-tl-md'
|
||||
)}>
|
||||
{state === 'streaming' && !message.content ? (
|
||||
<div className="flex items-center gap-1 text-zinc-400">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:100ms]" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:200ms]" />
|
||||
</div>
|
||||
) : isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="markdown-body prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions - visible on hover or when done */}
|
||||
{showActions && (
|
||||
<div className="flex gap-0.5 mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity px-1">
|
||||
<button onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
{copied ? <><Check size={10} />已复制</> : <><Copy size={10} />复制</>}
|
||||
</button>
|
||||
<button onClick={() => onQuote?.(message.content)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<Quote size={10} />引用
|
||||
</button>
|
||||
<button onClick={() => onDelete?.(message.id)}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
|
||||
<Trash2 size={10} />删除
|
||||
</button>
|
||||
<button onClick={() => onRegenerate?.(message.id)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<RefreshCw size={10} />重新生成
|
||||
</button>
|
||||
<button onClick={() => onContinue?.(message.id)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<button onClick={() => onContinue?.()}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<ArrowRight size={10} />继续
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useChat } from '@/hooks/useChat';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { PipelineProgress } from './PipelineProgress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Loader2, StopCircle, X } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -14,18 +13,18 @@ import type { Account, Message } from '@/types';
|
||||
export function ChatView() {
|
||||
const { conversationId } = useParams<{ conversationId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { setConversations, selectedAccountId, setSelectedAccountId } = useAppStore();
|
||||
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation } = useChat(conversationId || null);
|
||||
const { setConversations, selectedAccountId } = useAppStore();
|
||||
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation, removeMessage } = useChat(conversationId || null);
|
||||
const [manifestPath, setManifestPath] = useState<string | null>(null);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [quote, setQuote] = useState<string | null>(null);
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
const creatingRef = useRef(false);
|
||||
|
||||
useEffect(() => { api.listAccounts().then(setAccounts).catch(() => {}); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/pipeline/conversations')
|
||||
.then((r) => r.json()).then(setConversations).catch(() => {});
|
||||
api.listConversations().then(setConversations).catch(() => {});
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,27 +37,13 @@ export function ChatView() {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// After navigating to a new conversation, send the pending message
|
||||
useEffect(() => {
|
||||
if (selectedAccountId && conversationId && connected && messages.length === 0) {
|
||||
const account = accounts.find((a) => a.id === selectedAccountId);
|
||||
if (account) {
|
||||
send(`已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`);
|
||||
}
|
||||
if (conversationId && connected && pendingMessage && messages.length === 0) {
|
||||
send(pendingMessage);
|
||||
setPendingMessage(null);
|
||||
}
|
||||
}, [selectedAccountId, conversationId, connected]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (creatingRef.current) return;
|
||||
creatingRef.current = true;
|
||||
createConversation('新对话', selectedAccountId || undefined);
|
||||
setTimeout(() => {
|
||||
fetch('/api/pipeline/conversations').then((r) => r.json()).then((list) => {
|
||||
setConversations(list);
|
||||
if (list.length > 0) navigate(`/chat/${list[0].id}`);
|
||||
creatingRef.current = false;
|
||||
});
|
||||
}, 500);
|
||||
}, [createConversation, selectedAccountId]);
|
||||
}, [conversationId, connected, pendingMessage]);
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!manifestPath) return;
|
||||
@@ -84,59 +69,77 @@ export function ChatView() {
|
||||
|
||||
const handleContinue = useCallback(() => { send('请继续'); }, [send]);
|
||||
const handleQuote = useCallback((content: string) => { setQuote(content.slice(0, 200)); }, []);
|
||||
const handleDeleteMsg = useCallback((msgId: string) => {
|
||||
removeMessage(msgId);
|
||||
}, [removeMessage]);
|
||||
|
||||
// Delayed conversation creation
|
||||
const handleSendNew = useCallback(async (content: string) => {
|
||||
if (creatingRef.current) return;
|
||||
creatingRef.current = true;
|
||||
setPendingMessage(content);
|
||||
|
||||
try {
|
||||
createConversation(content.slice(0, 30), selectedAccountId || undefined);
|
||||
setTimeout(async () => {
|
||||
const list = await api.listConversations();
|
||||
setConversations(list);
|
||||
if (list.length > 0) {
|
||||
navigate(`/chat/${list[0].id}`);
|
||||
}
|
||||
creatingRef.current = false;
|
||||
}, 600);
|
||||
} catch {
|
||||
creatingRef.current = false;
|
||||
}
|
||||
}, [createConversation, selectedAccountId]);
|
||||
|
||||
const handleSend = useCallback((content: string) => {
|
||||
if (quote) { send(`> ${quote}\n\n${content}`); setQuote(null); }
|
||||
else send(content);
|
||||
}, [send, quote]);
|
||||
if (quote) { content = `> ${quote}\n\n${content}`; setQuote(null); }
|
||||
if (conversationId) {
|
||||
send(content);
|
||||
} else {
|
||||
handleSendNew(content);
|
||||
}
|
||||
}, [send, quote, conversationId]);
|
||||
|
||||
const handleStop = useCallback(() => { stop(); }, [stop]);
|
||||
|
||||
// Empty state - no conversation selected
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 bg-white">
|
||||
<div className="text-4xl mb-2">💬</div>
|
||||
<h2 className="text-lg font-semibold text-zinc-800">开始新对话</h2>
|
||||
<p className="text-sm text-zinc-500">选择左侧对话或创建新的创作会话</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{accounts.length > 0 && (
|
||||
<p className="text-sm text-zinc-500">输入消息开始创作,对话将自动创建</p>
|
||||
<div className="w-full max-w-xl px-4 mt-4">
|
||||
<ChatInput onSend={handleSend} disabled={thinking || creatingRef.current} />
|
||||
</div>
|
||||
{accounts.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className="text-xs text-zinc-400">当前账号:</span>
|
||||
<select
|
||||
value={selectedAccountId || ''}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value || null)}
|
||||
className="h-9 rounded-md border border-zinc-200 bg-white px-3 text-sm text-zinc-700"
|
||||
onChange={(e) => useAppStore.getState().setSelectedAccountId(e.target.value || null)}
|
||||
className="h-7 rounded border border-zinc-200 bg-zinc-50 px-2 text-xs text-zinc-600"
|
||||
>
|
||||
<option value="">不指定账号</option>
|
||||
<option value="">不指定</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNewConversation}
|
||||
disabled={creatingRef.current}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{creatingRef.current ? '创建中...' : '开始对话'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active conversation
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
<div className="px-4 py-2 border-b border-zinc-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-400'}`} />
|
||||
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
||||
</div>
|
||||
<select
|
||||
value={selectedAccountId || ''}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value || null)}
|
||||
className="h-7 rounded border border-zinc-200 bg-zinc-50 px-2 text-xs text-zinc-600"
|
||||
>
|
||||
<option value="">切换账号</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
<div className="flex-1 flex flex-col bg-white h-full">
|
||||
{/* Header bar */}
|
||||
<div className="px-4 py-2 border-b border-zinc-200 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-400'}`} />
|
||||
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{manifestPath && (
|
||||
@@ -152,43 +155,56 @@ export function ChatView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-4 py-4">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
|
||||
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
|
||||
onQuote={handleQuote}
|
||||
/>
|
||||
))}
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{messages.map((msg, i) => (
|
||||
msg.role !== 'tool' && (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLast={i === messages.length - 1}
|
||||
isThinking={thinking}
|
||||
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
|
||||
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
|
||||
onQuote={handleQuote}
|
||||
onDelete={handleDeleteMsg}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
|
||||
{pipeline && (
|
||||
<PipelineProgress
|
||||
phase={pipeline.phase}
|
||||
progress={pipeline.progress}
|
||||
currentItem={pipeline.currentItem}
|
||||
totalItems={pipeline.totalItems}
|
||||
status={pipeline.status}
|
||||
/>
|
||||
)}
|
||||
{pipeline && (
|
||||
<PipelineProgress
|
||||
phase={pipeline.phase}
|
||||
progress={pipeline.progress}
|
||||
currentItem={pipeline.currentItem}
|
||||
totalItems={pipeline.totalItems}
|
||||
status={pipeline.status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{thinking && !pipeline && (
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{toolStatus || '思考中...'}
|
||||
{thinking && !pipeline && (
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{toolStatus || '思考中...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{quote && (
|
||||
<div className="mx-4 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-lg flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span className="flex-1 truncate">引用: {quote}</span>
|
||||
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
|
||||
{/* Quote bar */}
|
||||
{quote && (
|
||||
<div className="mx-4 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-lg flex items-center gap-2 text-xs text-zinc-500 flex-shrink-0">
|
||||
<span className="flex-1 truncate">引用: {quote}</span>
|
||||
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput onSend={handleSend} disabled={thinking} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatInput onSend={handleSend} disabled={thinking} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,68 +2,164 @@ 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 [form, setForm] = useState({
|
||||
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: '',
|
||||
ossEndpoint: '',
|
||||
ossBucket: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Skills config (file-based)
|
||||
const [skillsForm, setSkillsForm] = useState<SkillsConfig>({});
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfigs().then((list) => {
|
||||
const next = { ...form };
|
||||
const next = { ...agentForm };
|
||||
for (const item of list) {
|
||||
try {
|
||||
const v = item.value as Record<string, string>;
|
||||
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;
|
||||
if (v.ANTHROPIC_BASE_URL) next.baseUrl = v.ANTHROPIC_BASE_URL;
|
||||
if (v.ANTHROPIC_AUTH_TOKEN) next.authToken = v.ANTHROPIC_AUTH_TOKEN;
|
||||
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;
|
||||
}
|
||||
if (item.key === 'endpoints') {
|
||||
if (v.ossEndpoint) next.ossEndpoint = v.ossEndpoint;
|
||||
if (v.ossBucket) next.ossBucket = v.ossBucket;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setForm(next);
|
||||
setAgentForm(next);
|
||||
});
|
||||
|
||||
api.getSkillsConfig().then(setSkillsForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
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 {
|
||||
await api.saveConfig('api_keys', {
|
||||
ANTHROPIC_MODEL: form.model,
|
||||
ANTHROPIC_BASE_URL: form.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: form.authToken,
|
||||
});
|
||||
await api.saveConfig('defaults', {
|
||||
imageModel: form.defaultImageModel,
|
||||
videoModel: form.defaultVideoModel,
|
||||
format: form.defaultFormat,
|
||||
});
|
||||
await api.saveConfig('endpoints', {
|
||||
ossEndpoint: form.ossEndpoint,
|
||||
ossBucket: form.ossBucket,
|
||||
});
|
||||
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 {}
|
||||
@@ -71,119 +167,192 @@ export function ConfigForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto p-6 space-y-8 overflow-auto">
|
||||
<h2 className="text-lg font-semibold text-zinc-800">设置</h2>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
placeholder="deepseek-v4-pro[1m]"
|
||||
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={form.baseUrl}
|
||||
onChange={(e) => handleChange('baseUrl', e.target.value)}
|
||||
placeholder="https://api.deepseek.com/anthropic"
|
||||
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={form.authToken}
|
||||
onChange={(e) => handleChange('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-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>
|
||||
</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={form.defaultImageModel}
|
||||
onChange={(e) => handleChange('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={form.defaultVideoModel}
|
||||
onChange={(e) => handleChange('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={form.defaultFormat}
|
||||
onChange={(e) => handleChange('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>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">OSS 存储</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">Endpoint</label>
|
||||
<Input
|
||||
value={form.ossEndpoint}
|
||||
onChange={(e) => handleChange('ossEndpoint', e.target.value)}
|
||||
placeholder="https://oss-cn-hangzhou.aliyuncs.com"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">Bucket</label>
|
||||
<Input
|
||||
value={form.ossBucket}
|
||||
onChange={(e) => handleChange('ossBucket', e.target.value)}
|
||||
placeholder="my-bucket"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} className="w-full">
|
||||
{saved ? '已保存' : saving ? '保存中...' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Plus, MessageCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Pin, Trash2, MessageCircle, Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAppStore } from '@/store';
|
||||
@@ -26,6 +25,24 @@ export function MiddlePanel() {
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleTogglePin = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const conv = conversations.find((c) => c.id === id);
|
||||
if (!conv) return;
|
||||
await api.updateConversation(id, { pinned: !conv.pinned });
|
||||
const list = await api.listConversations();
|
||||
setConversations(list);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('确定删除这个对话?')) return;
|
||||
await api.deleteConversation(id);
|
||||
const list = await api.listConversations();
|
||||
setConversations(list);
|
||||
if (id === conversationId) navigate('/chat');
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
const date = new Date(d);
|
||||
const now = new Date();
|
||||
@@ -37,32 +54,58 @@ export function MiddlePanel() {
|
||||
|
||||
return (
|
||||
<aside className="w-60 flex flex-col border-r border-zinc-200 bg-white">
|
||||
<div className="p-3 flex items-center justify-between border-b border-zinc-100">
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
className="h-8 text-xs bg-zinc-50 border-zinc-200"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8 ml-1 text-zinc-500 hover:text-zinc-700" onClick={() => navigate('/chat')}>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
<div className="p-3 flex items-center gap-2 border-b border-zinc-100">
|
||||
<div className="relative flex-1">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
className="h-8 text-xs bg-zinc-50 border-zinc-200 pl-8"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/chat')}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Plus size={13} />
|
||||
新对话
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{conversations.map((conv) => (
|
||||
<button
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => navigate(`/chat/${conv.id}`)}
|
||||
className={`w-full text-left px-3 py-2.5 border-b border-zinc-50 transition-colors
|
||||
className={`group w-full text-left px-3 py-2.5 border-b border-zinc-50 transition-colors cursor-pointer
|
||||
${conv.id === conversationId
|
||||
? 'bg-indigo-50 border-l-2 border-l-indigo-600'
|
||||
: 'hover:bg-zinc-50 border-l-2 border-l-transparent'}`}
|
||||
? 'bg-indigo-50 border-l-[3px] border-l-indigo-600'
|
||||
: 'hover:bg-zinc-50 border-l-[3px] border-l-transparent'}`}
|
||||
>
|
||||
<div className={`text-sm truncate font-medium ${conv.id === conversationId ? 'text-indigo-700' : 'text-zinc-700'}`}>
|
||||
{conv.title}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{conv.pinned ? <Pin size={11} className="text-indigo-500 flex-shrink-0" /> : null}
|
||||
<div className={`text-sm truncate font-medium flex-1 ${conv.id === conversationId ? 'text-indigo-700' : 'text-zinc-700'}`}>
|
||||
{conv.title}
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-0.5 transition-opacity flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => handleTogglePin(conv.id, e)}
|
||||
className={`p-1 rounded hover:bg-zinc-200 transition-colors ${conv.pinned ? 'text-indigo-500' : 'text-zinc-400'}`}
|
||||
title={conv.pinned ? '取消置顶' : '置顶'}
|
||||
>
|
||||
<Pin size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDelete(conv.id, e)}
|
||||
className="p-1 rounded hover:bg-red-100 text-zinc-400 hover:text-red-500 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-zinc-400 truncate max-w-[150px]">
|
||||
<span className="text-xs text-zinc-400 truncate max-w-[140px]">
|
||||
<MessageCircle size={10} className="inline mr-1" />
|
||||
{conv.title.length > 25 ? conv.title.slice(0, 25) + '...' : conv.title}
|
||||
</span>
|
||||
@@ -70,10 +113,14 @@ export function MiddlePanel() {
|
||||
{formatDate(conv.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{conversations.length === 0 && (
|
||||
<p className="text-xs text-zinc-400 text-center mt-8">暂无对话</p>
|
||||
<div className="flex flex-col items-center justify-center mt-12 text-zinc-400">
|
||||
<MessageCircle size={24} className="mb-2 opacity-40" />
|
||||
<p className="text-xs">暂无对话</p>
|
||||
<p className="text-[10px] mt-1">点击上方「新对话」开始</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
@@ -21,17 +21,19 @@ const editorStyle = {
|
||||
minHeight: '100%',
|
||||
};
|
||||
|
||||
export function PromptEditor() {
|
||||
export function PromptEditor({ accountId: accountIdProp }: { accountId?: string } = {}) {
|
||||
const { accounts } = useAccounts();
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>('');
|
||||
const [selectedType, setSelectedType] = useState<string>('storyboard');
|
||||
const { content, path, loading, load, save, setContent } = usePrompts();
|
||||
|
||||
useEffect(() => {
|
||||
if (accounts.length > 0 && !selectedAccount) {
|
||||
if (accountIdProp) {
|
||||
setSelectedAccount(accountIdProp);
|
||||
} else if (accounts.length > 0 && !selectedAccount) {
|
||||
setSelectedAccount(accounts[0].id);
|
||||
}
|
||||
}, [accounts]);
|
||||
}, [accountIdProp, accounts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAccount) {
|
||||
|
||||
@@ -12,7 +12,11 @@ export function useAccounts() {
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const create = (data: Partial<Account>) => api.createAccount(data).then(refresh);
|
||||
const create = async (data: Partial<Account>) => {
|
||||
const acc = await api.createAccount(data);
|
||||
await refresh();
|
||||
return acc;
|
||||
};
|
||||
const update = (id: string, data: Partial<Account>) => api.updateAccount(id, data).then(refresh);
|
||||
const remove = (id: string) => api.deleteAccount(id).then(refresh);
|
||||
|
||||
|
||||
@@ -16,57 +16,51 @@ export function useChat(conversationId: string | null) {
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [toolStatus, setToolStatus] = useState<string | null>(null);
|
||||
const [pipeline, setPipeline] = useState<PipelineState | null>(null);
|
||||
const pendingRef = useRef(false);
|
||||
const initRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
chatSocket.connect();
|
||||
|
||||
chatSocket.on('connected', () => setConnected(true));
|
||||
|
||||
chatSocket.on('history', (data) => {
|
||||
// Bind all event handlers once
|
||||
const onConnected = () => setConnected(true);
|
||||
const onHistory = (data: Record<string, unknown>) => {
|
||||
setMessages((data.messages as Message[]) || []);
|
||||
});
|
||||
|
||||
chatSocket.on('message', (data) => {
|
||||
};
|
||||
const onMessage = (data: Record<string, unknown>) => {
|
||||
setMessages((prev) => [...prev, data as unknown as Message]);
|
||||
});
|
||||
|
||||
// Streaming
|
||||
chatSocket.on('status', (data) => {
|
||||
};
|
||||
const onStatus = (data: Record<string, unknown>) => {
|
||||
if (data.status === 'thinking') setThinking(true);
|
||||
if (data.status === 'done') { setThinking(false); setToolStatus(null); }
|
||||
});
|
||||
|
||||
chatSocket.on('message_start', (data) => {
|
||||
};
|
||||
const onMessageStart = (data: Record<string, unknown>) => {
|
||||
setThinking(false);
|
||||
setMessages((prev) => [...prev, {
|
||||
id: data.id as string,
|
||||
role: 'assistant',
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
created_at: new Date().toISOString(),
|
||||
conversation_id: '',
|
||||
} as Message]);
|
||||
});
|
||||
|
||||
chatSocket.on('text_delta', (data) => {
|
||||
}]);
|
||||
};
|
||||
const onReasoningDelta = (data: Record<string, unknown>) => {
|
||||
setMessages((prev) => prev.map((m) =>
|
||||
m.id === data.id ? { ...m, reasoningContent: (m.reasoningContent || '') + (data.text as string) } : m
|
||||
));
|
||||
};
|
||||
const onTextDelta = (data: Record<string, unknown>) => {
|
||||
setMessages((prev) => prev.map((m) =>
|
||||
m.id === data.id ? { ...m, content: m.content + (data.text as string) } : m
|
||||
));
|
||||
});
|
||||
|
||||
chatSocket.on('message_end', () => {
|
||||
};
|
||||
const onMessageEnd = () => {
|
||||
setThinking(false);
|
||||
});
|
||||
|
||||
// Tools
|
||||
chatSocket.on('tool_start', (data) => {
|
||||
};
|
||||
const onToolStart = (data: Record<string, unknown>) => {
|
||||
setToolStatus(`执行: ${data.tool}...`);
|
||||
});
|
||||
|
||||
chatSocket.on('tool_result', (data) => {
|
||||
};
|
||||
const onToolResult = (data: Record<string, unknown>) => {
|
||||
setToolStatus(null);
|
||||
setThinking(true);
|
||||
// Save tool result as a tool-type message for inline display
|
||||
setMessages((prev) => [...prev, {
|
||||
id: `tool-${Date.now()}`,
|
||||
role: 'tool' as const,
|
||||
@@ -74,34 +68,60 @@ export function useChat(conversationId: string | null) {
|
||||
created_at: new Date().toISOString(),
|
||||
conversation_id: '',
|
||||
}]);
|
||||
});
|
||||
|
||||
chatSocket.on('tool_error', (data) => {
|
||||
};
|
||||
const onToolError = (data: Record<string, unknown>) => {
|
||||
setToolStatus(`失败: ${data.tool}`);
|
||||
setTimeout(() => setToolStatus(null), 4000);
|
||||
});
|
||||
|
||||
// Pipeline progress
|
||||
chatSocket.on('pipeline_progress', (data) => {
|
||||
};
|
||||
const onPipelineProgress = (data: Record<string, unknown>) => {
|
||||
setPipeline({
|
||||
phase: data.phase as string,
|
||||
progress: data.progress as number,
|
||||
currentItem: data.currentItem as number,
|
||||
totalItems: data.totalItems as number,
|
||||
status: data.status as string,
|
||||
currentItem: data.currentItem as number | undefined,
|
||||
totalItems: data.totalItems as number | undefined,
|
||||
status: data.status as string | undefined,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return () => { chatSocket.disconnect(); };
|
||||
chatSocket.on('connected', onConnected);
|
||||
chatSocket.on('history', onHistory);
|
||||
chatSocket.on('message', onMessage);
|
||||
chatSocket.on('status', onStatus);
|
||||
chatSocket.on('message_start', onMessageStart);
|
||||
chatSocket.on('reasoning_delta', onReasoningDelta);
|
||||
chatSocket.on('text_delta', onTextDelta);
|
||||
chatSocket.on('message_end', onMessageEnd);
|
||||
chatSocket.on('tool_start', onToolStart);
|
||||
chatSocket.on('tool_result', onToolResult);
|
||||
chatSocket.on('tool_error', onToolError);
|
||||
chatSocket.on('pipeline_progress', onPipelineProgress);
|
||||
chatSocket.connect();
|
||||
|
||||
return () => {
|
||||
chatSocket.off('connected', onConnected);
|
||||
chatSocket.off('history', onHistory);
|
||||
chatSocket.off('message', onMessage);
|
||||
chatSocket.off('status', onStatus);
|
||||
chatSocket.off('message_start', onMessageStart);
|
||||
chatSocket.off('reasoning_delta', onReasoningDelta);
|
||||
chatSocket.off('text_delta', onTextDelta);
|
||||
chatSocket.off('message_end', onMessageEnd);
|
||||
chatSocket.off('tool_start', onToolStart);
|
||||
chatSocket.off('tool_result', onToolResult);
|
||||
chatSocket.off('tool_error', onToolError);
|
||||
chatSocket.off('pipeline_progress', onPipelineProgress);
|
||||
chatSocket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load conversation when ID changes
|
||||
useEffect(() => {
|
||||
if (conversationId && connected && !pendingRef.current) {
|
||||
pendingRef.current = true;
|
||||
if (conversationId && connected && !initRef.current) {
|
||||
initRef.current = true;
|
||||
chatSocket.send('init', { conversationId });
|
||||
}
|
||||
if (!conversationId) {
|
||||
pendingRef.current = false;
|
||||
initRef.current = false;
|
||||
setMessages([]);
|
||||
}
|
||||
}, [conversationId, connected]);
|
||||
@@ -118,5 +138,9 @@ export function useChat(conversationId: string | null) {
|
||||
chatSocket.send('create_conversation', { title, accountId });
|
||||
}, []);
|
||||
|
||||
return { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation };
|
||||
const removeMessage = useCallback((msgId: string) => {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== msgId));
|
||||
}, []);
|
||||
|
||||
return { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation, removeMessage };
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export const api = {
|
||||
// Conversations
|
||||
listConversations: () => request<Conversation[]>('/pipeline/conversations'),
|
||||
getMessages: (convId: string) => request<Message[]>(`/pipeline/conversations/${convId}/messages`),
|
||||
updateConversation: (id: string, data: { title?: string; pinned?: boolean }) =>
|
||||
request<void>(`/pipeline/conversations/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteConversation: (id: string) =>
|
||||
request<void>(`/pipeline/conversations/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Assets
|
||||
listAssets: (params?: { accountId?: string; type?: string }) => {
|
||||
@@ -45,4 +49,7 @@ export const api = {
|
||||
getConfigs: () => request<ConfigItem[]>('/configs'),
|
||||
saveConfig: (key: string, value: Record<string, unknown>) =>
|
||||
request<void>(`/configs/${key}`, { method: 'PUT', body: JSON.stringify({ value }) }),
|
||||
getSkillsConfig: () => request<Record<string, unknown>>('/configs/skills/file'),
|
||||
saveSkillsConfig: (data: Record<string, unknown>) =>
|
||||
request<void>('/configs/skills/file', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
};
|
||||
|
||||
@@ -4,8 +4,10 @@ class ChatSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers: Map<string, MessageHandler[]> = new Map();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private intentionallyClosed = false;
|
||||
|
||||
connect() {
|
||||
this.intentionallyClosed = false;
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${location.host}/ws`;
|
||||
this.ws = new WebSocket(url);
|
||||
@@ -13,7 +15,11 @@ class ChatSocket {
|
||||
this.ws.onmessage = (event) => {
|
||||
try { const { type, data } = JSON.parse(event.data); this.emit(type, data); } catch {}
|
||||
};
|
||||
this.ws.onclose = () => { this.reconnectTimer = setTimeout(() => this.connect(), 3000); };
|
||||
this.ws.onclose = () => {
|
||||
if (!this.intentionallyClosed) {
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
on(type: string, handler: MessageHandler) {
|
||||
@@ -23,7 +29,10 @@ class ChatSocket {
|
||||
|
||||
off(type: string, handler: MessageHandler) {
|
||||
const list = this.handlers.get(type);
|
||||
if (list) this.handlers.set(type, list.filter((h) => h !== handler));
|
||||
if (list) {
|
||||
const idx = list.indexOf(handler);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
send(type: string, data: Record<string, unknown> = {}) {
|
||||
@@ -39,8 +48,11 @@ class ChatSocket {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.intentionallyClosed = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,93 @@
|
||||
export interface SubtitleStyle {
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
highlightColor?: string;
|
||||
bold?: boolean;
|
||||
hasShadow?: boolean;
|
||||
shadowColor?: string;
|
||||
shadowAlpha?: number;
|
||||
transformY?: number;
|
||||
alignment?: number;
|
||||
inAnimation?: string;
|
||||
outAnimation?: string;
|
||||
}
|
||||
|
||||
export interface KeywordStyle {
|
||||
textEffect?: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
bold?: boolean;
|
||||
transformY?: number;
|
||||
inAnimation?: string;
|
||||
outAnimation?: string;
|
||||
inAnimDuration?: number;
|
||||
outAnimDuration?: number;
|
||||
}
|
||||
|
||||
export interface TransitionConfig {
|
||||
name: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface TransitionsConfig {
|
||||
strategy?: string;
|
||||
default?: TransitionConfig;
|
||||
byPosition?: Record<string, TransitionConfig>;
|
||||
byDirector?: Record<string, TransitionConfig>;
|
||||
}
|
||||
|
||||
export interface KenBurnsParams {
|
||||
startScale?: number;
|
||||
scaleRate?: number;
|
||||
panXRate?: number;
|
||||
panYRate?: number;
|
||||
}
|
||||
|
||||
export interface KenBurnsConfig {
|
||||
enabled?: boolean;
|
||||
default?: KenBurnsParams;
|
||||
byDirector?: Record<string, KenBurnsParams>;
|
||||
}
|
||||
|
||||
export interface CapCutConfig {
|
||||
effects?: string[];
|
||||
filter?: string;
|
||||
subtitleStyle?: SubtitleStyle;
|
||||
keywordStyle?: KeywordStyle;
|
||||
transitions?: TransitionsConfig;
|
||||
kenBurns?: KenBurnsConfig;
|
||||
defaultBGM?: string;
|
||||
}
|
||||
|
||||
export interface ReferenceItem {
|
||||
file: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pipeline?: string;
|
||||
defaultFormat: string;
|
||||
imageModel: string;
|
||||
videoModel: string;
|
||||
batchSize: number;
|
||||
styles?: Record<string, { references: ReferenceItem[] }>;
|
||||
ttsVoice: string;
|
||||
ttsInstruction: string;
|
||||
storyboardPrompt: string;
|
||||
imageStylePrompt: string;
|
||||
videoStylePrompt: string;
|
||||
references: { file: string; url?: string }[];
|
||||
capcut: Record<string, unknown>;
|
||||
references: ReferenceItem[];
|
||||
capcut: CapCutConfig;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
account_id: string | null;
|
||||
pinned: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -28,6 +97,7 @@ export interface Message {
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
tool_calls?: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -46,6 +116,6 @@ export interface Asset {
|
||||
export interface ConfigItem {
|
||||
id: string;
|
||||
key: string;
|
||||
value: Record<string, unknown>;
|
||||
value: Record<string, unknown> | string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
8
web/client/src/vite-env.d.ts
vendored
Normal file
8
web/client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module 'prismjs/themes/prism.css';
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
115
web/package-lock.json
generated
115
web/package-lock.json
generated
@@ -9,9 +9,12 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.95.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^2.1.1",
|
||||
"openai": "^6.36.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -517,7 +520,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -528,7 +530,6 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -548,7 +549,6 @@
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
@@ -560,7 +560,6 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -573,14 +572,21 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
@@ -590,21 +596,18 @@
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -614,7 +617,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -670,6 +672,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -805,6 +813,23 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -914,6 +939,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -1680,6 +1720,25 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -1761,6 +1820,27 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.36.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.36.0.tgz",
|
||||
"integrity": "sha512-Has2YbIusMq9wQEierFsgf9c783dy1y9arX459LmphNacEkkM5yxi2RIyXP0LmkOroQyW19iTwALHL8Yf26UKA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -2166,6 +2246,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -2333,6 +2421,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2351,7 +2445,6 @@
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.95.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^2.1.1",
|
||||
"openai": "^6.36.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import OpenAI from 'openai';
|
||||
import { tools, ToolDefinition } from './tools';
|
||||
import { getDb } from '../db';
|
||||
import fs from 'fs';
|
||||
@@ -9,34 +10,44 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
||||
|
||||
function getAnthropicClient(): Anthropic {
|
||||
export type Protocol = 'anthropic' | 'openai';
|
||||
|
||||
interface ApiConfig {
|
||||
protocol: Protocol;
|
||||
apiKey: string;
|
||||
baseURL: string | undefined;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function getApiConfig(): ApiConfig {
|
||||
const configRow = getDb().prepare('SELECT value FROM configs WHERE key = ?').get('api_keys') as { value: string } | undefined;
|
||||
|
||||
let apiKey = process.env.ANTHROPIC_API_KEY || '';
|
||||
let baseURL: string | undefined;
|
||||
let model = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
|
||||
let protocol: Protocol = 'anthropic';
|
||||
|
||||
if (configRow) {
|
||||
try {
|
||||
const cfg = JSON.parse(configRow.value);
|
||||
if (cfg.ANTHROPIC_AUTH_TOKEN) apiKey = cfg.ANTHROPIC_AUTH_TOKEN;
|
||||
if (cfg.ANTHROPIC_BASE_URL) baseURL = cfg.ANTHROPIC_BASE_URL;
|
||||
if (cfg.ANTHROPIC_MODEL) model = cfg.ANTHROPIC_MODEL;
|
||||
if (cfg.PROTOCOL === 'openai') protocol = 'openai';
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return new Anthropic({
|
||||
apiKey,
|
||||
baseURL,
|
||||
});
|
||||
return { protocol, apiKey, baseURL, model };
|
||||
}
|
||||
|
||||
function getModel(): string {
|
||||
const configRow = getDb().prepare('SELECT value FROM configs WHERE key = ?').get('api_keys') as { value: string } | undefined;
|
||||
if (configRow) {
|
||||
try {
|
||||
const cfg = JSON.parse(configRow.value);
|
||||
if (cfg.ANTHROPIC_MODEL) return cfg.ANTHROPIC_MODEL;
|
||||
} catch {}
|
||||
}
|
||||
return process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
|
||||
function getAnthropicClient(): Anthropic {
|
||||
const { apiKey, baseURL } = getApiConfig();
|
||||
return new Anthropic({ apiKey, baseURL });
|
||||
}
|
||||
|
||||
function getOpenAIClient(): OpenAI {
|
||||
const { apiKey, baseURL } = getApiConfig();
|
||||
return new OpenAI({ apiKey, baseURL: baseURL || 'https://api.openai.com/v1' });
|
||||
}
|
||||
|
||||
export class VideoAgent {
|
||||
@@ -46,6 +57,22 @@ export class VideoAgent {
|
||||
this.tools = tools;
|
||||
}
|
||||
|
||||
getProtocol(): Protocol {
|
||||
return getApiConfig().protocol;
|
||||
}
|
||||
|
||||
getModel(): string {
|
||||
return getApiConfig().model;
|
||||
}
|
||||
|
||||
getAnthropicClient(): Anthropic {
|
||||
return getAnthropicClient();
|
||||
}
|
||||
|
||||
getOpenAIClient(): OpenAI {
|
||||
return getOpenAIClient();
|
||||
}
|
||||
|
||||
getAnthropicTools(): Anthropic.Tool[] {
|
||||
return this.tools.map((t) => ({
|
||||
name: t.name,
|
||||
@@ -54,6 +81,17 @@ export class VideoAgent {
|
||||
}));
|
||||
}
|
||||
|
||||
getOpenAITools(): OpenAI.ChatCompletionTool[] {
|
||||
return this.tools.map((t) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async executeTool(name: string, params: Record<string, unknown>): Promise<string> {
|
||||
const tool = this.tools.find((t) => t.name === name);
|
||||
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
||||
@@ -61,7 +99,6 @@ export class VideoAgent {
|
||||
}
|
||||
|
||||
getSystemPrompt(): string {
|
||||
// Dynamically list accounts
|
||||
const accountsDir = path.join(PROJECT_ROOT, 'accounts');
|
||||
let accountList = '暂无账号';
|
||||
if (fs.existsSync(accountsDir)) {
|
||||
@@ -107,14 +144,6 @@ ${accountList}
|
||||
- 如果用户只是闲聊,就闲聊。如果用户想做视频,引导完成流程
|
||||
- 不要编造账号或文件路径,使用工具获取真实信息`;
|
||||
}
|
||||
|
||||
getClient(): Anthropic {
|
||||
return getAnthropicClient();
|
||||
}
|
||||
|
||||
getModel(): string {
|
||||
return getModel();
|
||||
}
|
||||
}
|
||||
|
||||
export const videoAgent = new VideoAgent();
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SCHEMA_SQL } from './schema';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DB_PATH = path.resolve(__dirname, '..', '..', 'data', 'meitu-agent.db');
|
||||
|
||||
let db: Database.Database;
|
||||
@@ -22,7 +21,18 @@ export function getDb(): Database.Database {
|
||||
return db;
|
||||
}
|
||||
|
||||
// Ensure a column exists on a table, add it with DEFAULT if missing
|
||||
function ensureColumn(d: Database.Database, table: string, column: string, definition: string) {
|
||||
const cols = d.prepare(`PRAGMA table_info('${table}')`).all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === column)) {
|
||||
d.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function initDb(): void {
|
||||
const d = getDb();
|
||||
d.exec(SCHEMA_SQL);
|
||||
|
||||
// Migrations for existing databases
|
||||
ensureColumn(d, 'conversations', 'pinned', 'INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const SCHEMA_SQL = `
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
account_id TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -1,49 +1,60 @@
|
||||
import { Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import multer from 'multer';
|
||||
|
||||
const ACCOUNTS_DIR = path.resolve(__dirname, '..', '..', '..', 'accounts');
|
||||
|
||||
export const accountsRouter = Router();
|
||||
|
||||
function readAccountJson(id: string): Record<string, unknown> | null {
|
||||
async function readAccountJson(id: string): Promise<Record<string, unknown> | null> {
|
||||
const p = path.join(ACCOUNTS_DIR, id, 'account.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
||||
try {
|
||||
const data = await fs.readFile(p, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAccountJson(id: string, data: Record<string, unknown>): void {
|
||||
async function writeAccountJson(id: string, data: Record<string, unknown>): Promise<void> {
|
||||
const dir = path.join(ACCOUNTS_DIR, id);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'account.json'), JSON.stringify(data, null, 2), 'utf-8');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'account.json'), JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// List
|
||||
accountsRouter.get('/', (_req, res) => {
|
||||
const dirs = fs.readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
|
||||
.map((d) => d.name);
|
||||
accountsRouter.get('/', async (_req, res) => {
|
||||
const dirs = await fs.readdir(ACCOUNTS_DIR, { withFileTypes: true });
|
||||
const accountDirs = dirs.filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'));
|
||||
|
||||
const accounts = dirs
|
||||
.map((id) => readAccountJson(id))
|
||||
.filter(Boolean);
|
||||
const accounts: Record<string, unknown>[] = [];
|
||||
for (const d of accountDirs) {
|
||||
const data = await readAccountJson(d.name);
|
||||
if (data) accounts.push(data);
|
||||
}
|
||||
res.json(accounts);
|
||||
});
|
||||
|
||||
// Get
|
||||
accountsRouter.get('/:id', (req, res) => {
|
||||
const data = readAccountJson(req.params.id);
|
||||
accountsRouter.get('/:id', async (req, res) => {
|
||||
const data = await readAccountJson(req.params.id);
|
||||
if (!data) return res.status(404).json({ error: 'Account not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Create
|
||||
accountsRouter.post('/', (req, res) => {
|
||||
accountsRouter.post('/', async (req, res) => {
|
||||
const { id, ...rest } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'id is required' });
|
||||
|
||||
const dir = path.join(ACCOUNTS_DIR, id);
|
||||
if (fs.existsSync(dir)) return res.status(409).json({ error: 'Account already exists' });
|
||||
try {
|
||||
await fs.access(dir);
|
||||
return res.status(409).json({ error: 'Account already exists' });
|
||||
} catch {
|
||||
// Directory doesn't exist, proceed
|
||||
}
|
||||
|
||||
const data = {
|
||||
id,
|
||||
@@ -62,24 +73,75 @@ accountsRouter.post('/', (req, res) => {
|
||||
capcut: rest.capcut || {},
|
||||
};
|
||||
|
||||
writeAccountJson(id, data);
|
||||
await writeAccountJson(id, data);
|
||||
res.status(201).json(data);
|
||||
});
|
||||
|
||||
// Update
|
||||
accountsRouter.put('/:id', (req, res) => {
|
||||
const existing = readAccountJson(req.params.id);
|
||||
accountsRouter.put('/:id', async (req, res) => {
|
||||
const existing = await readAccountJson(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Account not found' });
|
||||
|
||||
const merged = { ...existing, ...req.body, id: req.params.id };
|
||||
writeAccountJson(req.params.id, merged);
|
||||
await writeAccountJson(req.params.id, merged);
|
||||
res.json(merged);
|
||||
});
|
||||
|
||||
// Delete
|
||||
accountsRouter.delete('/:id', (req, res) => {
|
||||
accountsRouter.delete('/:id', async (req, res) => {
|
||||
const dir = path.join(ACCOUNTS_DIR, req.params.id);
|
||||
if (!fs.existsSync(dir)) return res.status(404).json({ error: 'Account not found' });
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
try {
|
||||
await fs.access(dir);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Account not found' });
|
||||
}
|
||||
await fs.rm(dir, { recursive: true });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// Reference image upload
|
||||
const upload = multer({ dest: path.join(ACCOUNTS_DIR, '_uploads') });
|
||||
|
||||
accountsRouter.post('/:id/references/upload', upload.single('file'), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const account = await readAccountJson(id);
|
||||
if (!account) return res.status(404).json({ error: 'Account not found' });
|
||||
|
||||
const refsDir = path.join(ACCOUNTS_DIR, id, 'references');
|
||||
await fs.mkdir(refsDir, { recursive: true });
|
||||
const destPath = path.join(refsDir, file.originalname);
|
||||
await fs.rename(file.path, destPath);
|
||||
|
||||
const refEntry = { file: `references/${file.originalname}` };
|
||||
const refs = (account.references as unknown[]) || [];
|
||||
refs.push(refEntry);
|
||||
account.references = refs;
|
||||
await writeAccountJson(id, account);
|
||||
|
||||
res.json({ ok: true, reference: refEntry });
|
||||
});
|
||||
|
||||
// Reference image delete
|
||||
accountsRouter.delete('/:id/references/:index', async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const idx = parseInt(req.params.index as string);
|
||||
|
||||
const account = await readAccountJson(id);
|
||||
if (!account) return res.status(404).json({ error: 'Account not found' });
|
||||
|
||||
const refs = (account.references as unknown[]) || [];
|
||||
if (idx < 0 || idx >= refs.length) return res.status(400).json({ error: 'Invalid index' });
|
||||
|
||||
const removed = refs.splice(idx, 1)[0] as { file?: string };
|
||||
if (removed?.file) {
|
||||
const filePath = path.join(ACCOUNTS_DIR, id, removed.file);
|
||||
try { await fs.unlink(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
account.references = refs;
|
||||
await writeAccountJson(id, account);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { getDb } from '../db';
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import fss from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -25,8 +26,23 @@ assetsRouter.get('/', (req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// List manifests with metadata
|
||||
assetsRouter.get('/manifests', async (_req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT manifest_path, account_id,
|
||||
COUNT(*) as asset_count,
|
||||
SUM(CASE WHEN type = 'image' THEN 1 ELSE 0 END) as image_count,
|
||||
SUM(CASE WHEN type = 'video' THEN 1 ELSE 0 END) as video_count,
|
||||
MAX(created_at) as latest_at
|
||||
FROM assets
|
||||
GROUP BY manifest_path
|
||||
ORDER BY latest_at DESC
|
||||
`).all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// Delete asset and its file
|
||||
assetsRouter.delete('/:id', (req, res) => {
|
||||
assetsRouter.delete('/:id', async (req, res) => {
|
||||
const asset = getDb().prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id) as {
|
||||
file_path: string;
|
||||
} | undefined;
|
||||
@@ -34,36 +50,38 @@ assetsRouter.delete('/:id', (req, res) => {
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
|
||||
const filePath = path.join(PROJECT_ROOT, asset.file_path);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
try { await fs.unlink(filePath); } catch { /* ignore */ }
|
||||
|
||||
getDb().prepare('DELETE FROM assets WHERE id = ?').run(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// Scan output directories and index assets
|
||||
assetsRouter.post('/scan', (_req, res) => {
|
||||
assetsRouter.post('/scan', async (_req, res) => {
|
||||
const outputDir = path.join(PROJECT_ROOT, 'output');
|
||||
if (!fs.existsSync(outputDir)) return res.json({ indexed: 0 });
|
||||
try {
|
||||
await fs.access(outputDir);
|
||||
} catch {
|
||||
return res.json({ indexed: 0 });
|
||||
}
|
||||
|
||||
const dirs = fs.readdirSync(outputDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
const dirs = await fs.readdir(outputDir, { withFileTypes: true });
|
||||
const dirEntries = dirs.filter((d) => d.isDirectory());
|
||||
|
||||
let indexed = 0;
|
||||
for (const dir of dirs) {
|
||||
for (const dir of dirEntries) {
|
||||
const manifestPath = path.join(outputDir, dir.name, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
} catch { continue; }
|
||||
|
||||
const accountId = manifest.account?.id || '';
|
||||
|
||||
// Scan images
|
||||
const imagesDir = path.join(outputDir, dir.name, 'images');
|
||||
if (fs.existsSync(imagesDir)) {
|
||||
const files = fs.readdirSync(imagesDir);
|
||||
try {
|
||||
const files = await fs.readdir(imagesDir);
|
||||
for (const file of files) {
|
||||
if (!/\.(jpe?g|png|webp)$/i.test(file)) continue;
|
||||
const id = randomUUID();
|
||||
@@ -79,12 +97,12 @@ assetsRouter.post('/scan', (_req, res) => {
|
||||
).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'image', relPath, shotIndex);
|
||||
indexed++;
|
||||
}
|
||||
}
|
||||
} catch { /* images dir may not exist */ }
|
||||
|
||||
// Scan videos
|
||||
const videosDir = path.join(outputDir, dir.name, 'videos');
|
||||
if (fs.existsSync(videosDir)) {
|
||||
const files = fs.readdirSync(videosDir);
|
||||
try {
|
||||
const files = await fs.readdir(videosDir);
|
||||
for (const file of files) {
|
||||
if (!/\.mp4$/i.test(file)) continue;
|
||||
const id = randomUUID();
|
||||
@@ -100,7 +118,7 @@ assetsRouter.post('/scan', (_req, res) => {
|
||||
).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'video', relPath, shotIndex);
|
||||
indexed++;
|
||||
}
|
||||
}
|
||||
} catch { /* videos dir may not exist */ }
|
||||
}
|
||||
|
||||
res.json({ indexed });
|
||||
@@ -112,6 +130,6 @@ assetsRouter.get('/file', (req, res) => {
|
||||
if (!filePath) return res.status(400).send('Missing path');
|
||||
const fullPath = path.resolve(PROJECT_ROOT, filePath);
|
||||
if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
|
||||
if (!fs.existsSync(fullPath)) return res.status(404).send('Not found');
|
||||
if (!fss.existsSync(fullPath)) return res.status(404).send('Not found');
|
||||
res.sendFile(fullPath);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import OpenAI from 'openai';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDb } from '../db';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const SKILLS_CONFIG_PATH = path.join(PROJECT_ROOT, '.claude', 'skills', 'config.json');
|
||||
|
||||
export const configsRouter = Router();
|
||||
|
||||
configsRouter.get('/', (_req, res) => {
|
||||
@@ -24,3 +34,77 @@ configsRouter.put('/:key', (req, res) => {
|
||||
`).run(randomUUID(), req.params.key, JSON.stringify(value));
|
||||
res.json({ key: req.params.key, ok: true });
|
||||
});
|
||||
|
||||
// Skills config.json (pipeline tools configuration)
|
||||
configsRouter.get('/skills/file', (_req, res) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(SKILLS_CONFIG_PATH, 'utf-8'));
|
||||
res.json(data);
|
||||
} catch {
|
||||
res.status(404).json({ error: 'Skills config not found' });
|
||||
}
|
||||
});
|
||||
|
||||
configsRouter.put('/skills/file', (req, res) => {
|
||||
try {
|
||||
fs.writeFileSync(SKILLS_CONFIG_PATH, JSON.stringify(req.body, null, 2), 'utf-8');
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Test API connection
|
||||
configsRouter.post('/test-connection', async (_req, res) => {
|
||||
const configRow = getDb().prepare('SELECT value FROM configs WHERE key = ?').get('api_keys') as { value: string } | undefined;
|
||||
if (!configRow) return res.json({ ok: false, error: '未配置 API Key' });
|
||||
|
||||
let apiKey = '';
|
||||
let baseURL: string | undefined;
|
||||
let protocol: string = 'anthropic';
|
||||
let model: string = 'claude-sonnet-4-6';
|
||||
|
||||
try {
|
||||
const cfg = JSON.parse(configRow.value);
|
||||
apiKey = cfg.ANTHROPIC_AUTH_TOKEN || '';
|
||||
baseURL = cfg.ANTHROPIC_BASE_URL;
|
||||
protocol = cfg.PROTOCOL || 'anthropic';
|
||||
model = cfg.ANTHROPIC_MODEL || model;
|
||||
} catch {
|
||||
return res.json({ ok: false, error: '配置解析失败' });
|
||||
}
|
||||
|
||||
if (!apiKey) return res.json({ ok: false, error: 'API Key 为空' });
|
||||
|
||||
try {
|
||||
if (protocol === 'openai') {
|
||||
const client = new OpenAI({ apiKey, baseURL: baseURL || 'https://api.openai.com/v1' });
|
||||
const models = await client.models.list();
|
||||
const modelIds: string[] = [];
|
||||
for await (const m of models) { modelIds.push(m.id); if (modelIds.length >= 5) break; }
|
||||
res.json({ ok: true, models: modelIds });
|
||||
} else {
|
||||
const client = new Anthropic({ apiKey, baseURL });
|
||||
// Anthropic SDK doesn't have a models.list() in all versions, try a minimal request
|
||||
try {
|
||||
await client.messages.create({
|
||||
model,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
});
|
||||
res.json({ ok: true, models: [model] });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { status?: number; message?: string };
|
||||
if (err.status === 401) {
|
||||
res.json({ ok: false, error: `认证失败: ${err.message}` });
|
||||
} else if (err.status === 400 || err.status === undefined) {
|
||||
res.json({ ok: true, models: [model] });
|
||||
} else {
|
||||
res.json({ ok: false, error: `错误 (${err.status}): ${err.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
res.json({ ok: false, error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ pipelineRouter.get('/conversations', (req, res) => {
|
||||
sql += ' WHERE title LIKE ?';
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
sql += ' ORDER BY updated_at DESC LIMIT 100';
|
||||
sql += ' ORDER BY pinned DESC, updated_at DESC LIMIT 100';
|
||||
const rows = getDb().prepare(sql).all(...params);
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -36,13 +36,19 @@ pipelineRouter.delete('/conversations/:id', (req, res) => {
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// Rename conversation
|
||||
// Update conversation (rename / toggle pin)
|
||||
pipelineRouter.patch('/conversations/:id', (req, res) => {
|
||||
const { title } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'title required' });
|
||||
getDb().prepare(
|
||||
'UPDATE conversations SET title = ?, updated_at = datetime(\'now\') WHERE id = ?'
|
||||
).run(title, req.params.id);
|
||||
const { title, pinned } = req.body;
|
||||
if (title !== undefined) {
|
||||
getDb().prepare(
|
||||
'UPDATE conversations SET title = ?, updated_at = datetime(\'now\') WHERE id = ?'
|
||||
).run(title, req.params.id);
|
||||
}
|
||||
if (pinned !== undefined) {
|
||||
getDb().prepare(
|
||||
'UPDATE conversations SET pinned = ?, updated_at = datetime(\'now\') WHERE id = ?'
|
||||
).run(pinned ? 1 : 0, req.params.id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
@@ -12,18 +12,21 @@ const PROMPT_FILES: Record<string, string> = {
|
||||
video: 'prompts/视频提示词.md',
|
||||
};
|
||||
|
||||
promptsRouter.get('/:accountId/:type', (req, res) => {
|
||||
promptsRouter.get('/:accountId/:type', async (req, res) => {
|
||||
const { accountId, type } = req.params;
|
||||
const relPath = PROMPT_FILES[type];
|
||||
if (!relPath) return res.status(400).json({ error: 'Unknown type: ' + type });
|
||||
|
||||
const fullPath = path.join(PROJECT_ROOT, 'accounts', accountId, relPath);
|
||||
if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
res.json({ path: relPath, content: fs.readFileSync(fullPath, 'utf-8') });
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
res.json({ path: relPath, content });
|
||||
} catch {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
});
|
||||
|
||||
promptsRouter.put('/:accountId/:type', (req, res) => {
|
||||
promptsRouter.put('/:accountId/:type', async (req, res) => {
|
||||
const { accountId, type } = req.params;
|
||||
const { content } = req.body;
|
||||
const relPath = PROMPT_FILES[type];
|
||||
@@ -31,9 +34,8 @@ promptsRouter.put('/:accountId/:type', (req, res) => {
|
||||
|
||||
const fullPath = path.join(PROJECT_ROOT, 'accounts', accountId, relPath);
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(fullPath, content, 'utf-8');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from 'crypto';
|
||||
import { getDb } from '../db';
|
||||
import { videoAgent } from '../agent';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
type MessageParam = Anthropic.MessageParam;
|
||||
type ContentBlock = Anthropic.ContentBlock;
|
||||
@@ -91,6 +92,295 @@ export function handleChat(ws: WebSocket) {
|
||||
ws.on('close', () => {});
|
||||
}
|
||||
|
||||
// Helper: convert DB messages to OpenAI format
|
||||
function extractToolCalls(blocks: ContentBlock[]): OpenAI.ChatCompletionMessageToolCall[] {
|
||||
return blocks
|
||||
.filter((b) => b.type === 'tool_use')
|
||||
.map((b) => ({
|
||||
id: (b as { id: string }).id,
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: (b as { name: string }).name,
|
||||
arguments: JSON.stringify((b as { input: unknown }).input),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function dbToOpenAI(msg: DbMessage): OpenAI.ChatCompletionMessageParam {
|
||||
if (msg.role === 'user') {
|
||||
return { role: 'user', content: msg.content };
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
const result: Record<string, unknown> = { role: 'assistant', content: msg.content || null };
|
||||
|
||||
if (!msg.tool_calls) return result as unknown as OpenAI.ChatCompletionMessageParam;
|
||||
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(msg.tool_calls); } catch { return result as unknown as OpenAI.ChatCompletionMessageParam; }
|
||||
|
||||
// Legacy format: parsed is ContentBlock[] (array)
|
||||
if (Array.isArray(parsed)) {
|
||||
const toolCalls = extractToolCalls(parsed);
|
||||
if (toolCalls.length > 0) {
|
||||
result.tool_calls = toolCalls;
|
||||
const textBlocks = parsed.filter((b) => b.type === 'text');
|
||||
result.content = textBlocks.map((b) => (b as { text: string }).text).join('') || null;
|
||||
}
|
||||
return result as unknown as OpenAI.ChatCompletionMessageParam;
|
||||
}
|
||||
|
||||
// New format: parsed is { reasoning_content?, content_blocks? }
|
||||
const meta = parsed as { reasoning_content?: string; content_blocks?: ContentBlock[] };
|
||||
if (meta.reasoning_content) {
|
||||
result.reasoning_content = meta.reasoning_content;
|
||||
}
|
||||
if (meta.content_blocks) {
|
||||
const toolCalls = extractToolCalls(meta.content_blocks);
|
||||
if (toolCalls.length > 0) {
|
||||
result.tool_calls = toolCalls;
|
||||
const textBlocks = meta.content_blocks.filter((b) => b.type === 'text');
|
||||
result.content = textBlocks.map((b) => (b as { text: string }).text).join('') || null;
|
||||
}
|
||||
}
|
||||
|
||||
return result as unknown as OpenAI.ChatCompletionMessageParam;
|
||||
}
|
||||
if (msg.role === 'tool') {
|
||||
try {
|
||||
const { tool_use_id, content } = JSON.parse(msg.content);
|
||||
return { role: 'tool', tool_call_id: tool_use_id, content };
|
||||
} catch {
|
||||
return { role: 'tool', tool_call_id: 'unknown', content: msg.content };
|
||||
}
|
||||
}
|
||||
return { role: 'user', content: msg.content };
|
||||
}
|
||||
|
||||
// --- Anthropic protocol streaming ---
|
||||
async function streamAnthropic(
|
||||
ws: WebSocket,
|
||||
convId: string,
|
||||
messages: MessageParam[],
|
||||
): Promise<void> {
|
||||
const client = videoAgent.getAnthropicClient();
|
||||
const model = videoAgent.getModel();
|
||||
const systemPrompt = videoAgent.getSystemPrompt();
|
||||
|
||||
let currentMessages = messages;
|
||||
let maxLoops = 10;
|
||||
|
||||
while (maxLoops-- > 0) {
|
||||
console.log(`[chat:anthropic] Loop ${9 - maxLoops}, messages: ${currentMessages.length}`);
|
||||
const stream = client.messages.stream({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
tools: videoAgent.getAnthropicTools(),
|
||||
messages: currentMessages,
|
||||
});
|
||||
|
||||
const assistantMsgId = randomUUID();
|
||||
ws.send(JSON.stringify({ type: 'message_start', data: { id: assistantMsgId } }));
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'content_block_delta') {
|
||||
if (event.delta.type === 'text_delta') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'text_delta',
|
||||
data: { id: assistantMsgId, text: event.delta.text },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalMsg = await stream.finalMessage();
|
||||
ws.send(JSON.stringify({ type: 'message_end', data: { id: assistantMsgId } }));
|
||||
|
||||
const toolUses = finalMsg.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
|
||||
const textBlocks = finalMsg.content.filter((b): b is Anthropic.TextBlock => b.type === 'text');
|
||||
const finalText = textBlocks.map((b) => b.text).join('');
|
||||
|
||||
if (toolUses.length === 0) {
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', finalText);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save assistant with tool calls
|
||||
const cleanContent = filterContent(finalMsg.content as ContentBlock[]);
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content, tool_calls) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', finalText || '(调用工具)', JSON.stringify(cleanContent));
|
||||
|
||||
currentMessages.push({ role: 'assistant', content: cleanContent });
|
||||
|
||||
// Execute tools
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
for (const tool of toolUses) {
|
||||
ws.send(JSON.stringify({ type: 'tool_start', data: { tool: tool.name, input: tool.input } }));
|
||||
console.log(`[chat:anthropic] Executing tool: ${tool.name}`);
|
||||
|
||||
try {
|
||||
const result = await videoAgent.executeTool(tool.name, tool.input as Record<string, unknown>);
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: tool.id, content: result });
|
||||
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(toolMsgId, convId, 'tool', JSON.stringify({ tool_use_id: tool.id, content: result }));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'tool_result', data: { tool: tool.name, result: result.slice(0, 1000) } }));
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: tool.id, content: `Error: ${errMsg}` });
|
||||
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(toolMsgId, convId, 'tool', JSON.stringify({ tool_use_id: tool.id, content: `Error: ${errMsg}` }));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'tool_error', data: { tool: tool.name, error: errMsg } }));
|
||||
}
|
||||
}
|
||||
|
||||
currentMessages.push({ role: 'user', content: toolResults });
|
||||
}
|
||||
}
|
||||
|
||||
// --- OpenAI protocol streaming ---
|
||||
async function streamOpenAI(
|
||||
ws: WebSocket,
|
||||
convId: string,
|
||||
dbMessages: DbMessage[],
|
||||
): Promise<void> {
|
||||
const client = videoAgent.getOpenAIClient();
|
||||
const model = videoAgent.getModel();
|
||||
const systemPrompt = videoAgent.getSystemPrompt();
|
||||
|
||||
const openaiTools = videoAgent.getOpenAITools();
|
||||
|
||||
let currentDbMessages = [...dbMessages];
|
||||
let maxLoops = 10;
|
||||
|
||||
while (maxLoops-- > 0) {
|
||||
const openaiMessages: OpenAI.ChatCompletionMessageParam[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...currentDbMessages.map(dbToOpenAI),
|
||||
];
|
||||
|
||||
console.log(`[chat:openai] Loop ${9 - maxLoops}, messages: ${openaiMessages.length}`);
|
||||
|
||||
const assistantMsgId = randomUUID();
|
||||
ws.send(JSON.stringify({ type: 'message_start', data: { id: assistantMsgId } }));
|
||||
|
||||
let fullText = '';
|
||||
let reasoningContent = '';
|
||||
let toolCallsAcc: Array<{ id: string; name: string; arguments: string }> = [];
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model,
|
||||
messages: openaiMessages,
|
||||
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (delta?.content) {
|
||||
fullText += delta.content;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'text_delta',
|
||||
data: { id: assistantMsgId, text: delta.content },
|
||||
}));
|
||||
}
|
||||
// DeepSeek thinking mode: capture reasoning_content
|
||||
if ((delta as Record<string, unknown>)?.reasoning_content) {
|
||||
const chunk = (delta as Record<string, unknown>).reasoning_content as string;
|
||||
reasoningContent += chunk;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'reasoning_delta',
|
||||
data: { id: assistantMsgId, text: chunk },
|
||||
}));
|
||||
}
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (tc.index !== undefined) {
|
||||
while (toolCallsAcc.length <= tc.index) {
|
||||
toolCallsAcc.push({ id: '', name: '', arguments: '' });
|
||||
}
|
||||
if (tc.id) toolCallsAcc[tc.index].id = tc.id;
|
||||
if (tc.function?.name) toolCallsAcc[tc.index].name = tc.function.name;
|
||||
if (tc.function?.arguments) toolCallsAcc[tc.index].arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message_end', data: { id: assistantMsgId } }));
|
||||
|
||||
// Store extra metadata (reasoning_content, tool_calls) in tool_calls column as JSON
|
||||
const extraMeta: Record<string, unknown> = {};
|
||||
if (reasoningContent) extraMeta.reasoning_content = reasoningContent;
|
||||
|
||||
// No tool calls — save and done
|
||||
if (toolCallsAcc.length === 0) {
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content, tool_calls) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', fullText, Object.keys(extraMeta).length > 0 ? JSON.stringify(extraMeta) : null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save assistant with tool calls in Anthropic-compatible format for DB
|
||||
const dbToolCalls = toolCallsAcc.map((tc) => ({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input: JSON.parse(tc.arguments || '{}'),
|
||||
}));
|
||||
const cleanContent = fullText
|
||||
? [{ type: 'text', text: fullText }, ...dbToolCalls]
|
||||
: dbToolCalls;
|
||||
extraMeta.content_blocks = cleanContent;
|
||||
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content, tool_calls) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', fullText || '(调用工具)', JSON.stringify(extraMeta));
|
||||
|
||||
// Execute tools and collect results
|
||||
for (const tc of toolCallsAcc) {
|
||||
ws.send(JSON.stringify({ type: 'tool_start', data: { tool: tc.name, input: JSON.parse(tc.arguments || '{}') } }));
|
||||
console.log(`[chat:openai] Executing tool: ${tc.name}`);
|
||||
|
||||
try {
|
||||
const params = JSON.parse(tc.arguments || '{}');
|
||||
const result = await videoAgent.executeTool(tc.name, params);
|
||||
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(toolMsgId, convId, 'tool', JSON.stringify({ tool_use_id: tc.id, content: result }));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'tool_result', data: { tool: tc.name, result: result.slice(0, 1000) } }));
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(toolMsgId, convId, 'tool', JSON.stringify({ tool_use_id: tc.id, content: `Error: ${errMsg}` }));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'tool_error', data: { tool: tc.name, error: errMsg } }));
|
||||
}
|
||||
}
|
||||
|
||||
// Reload all messages for next loop
|
||||
currentDbMessages = getDb().prepare(
|
||||
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at'
|
||||
).all(convId) as DbMessage[];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChatMessage(ws: WebSocket, convId: string, content: string) {
|
||||
const userMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
@@ -112,106 +402,18 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
'SELECT * FROM messages WHERE conversation_id = ? AND id != ? ORDER BY created_at'
|
||||
).all(convId, userMsgId) as DbMessage[];
|
||||
|
||||
const messages: MessageParam[] = history.map(dbToAnthropic);
|
||||
|
||||
const client = videoAgent.getClient();
|
||||
const model = videoAgent.getModel();
|
||||
const systemPrompt = videoAgent.getSystemPrompt();
|
||||
|
||||
ws.send(JSON.stringify({ type: 'status', data: { status: 'thinking' } }));
|
||||
|
||||
try {
|
||||
let currentMessages = messages;
|
||||
let maxLoops = 10;
|
||||
const protocol = videoAgent.getProtocol();
|
||||
|
||||
while (maxLoops-- > 0) {
|
||||
console.log(`[chat] Calling LLM, loop ${9 - maxLoops}, messages: ${currentMessages.length}`);
|
||||
const stream = client.messages.stream({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
tools: videoAgent.getAnthropicTools(),
|
||||
messages: currentMessages,
|
||||
});
|
||||
|
||||
const assistantMsgId = randomUUID();
|
||||
ws.send(JSON.stringify({ type: 'message_start', data: { id: assistantMsgId } }));
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'content_block_delta') {
|
||||
if (event.delta.type === 'text_delta') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'text_delta',
|
||||
data: { id: assistantMsgId, text: event.delta.text },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalMsg = await stream.finalMessage();
|
||||
ws.send(JSON.stringify({ type: 'message_end', data: { id: assistantMsgId } }));
|
||||
|
||||
const toolUses = finalMsg.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
|
||||
const textBlocks = finalMsg.content.filter((b): b is Anthropic.TextBlock => b.type === 'text');
|
||||
const finalText = textBlocks.map((b) => b.text).join('');
|
||||
|
||||
// No tool calls — save and done
|
||||
if (toolUses.length === 0) {
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', finalText);
|
||||
console.log(`[chat] Done, response: ${finalText.slice(0, 80)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save assistant message with filtered content (no thinking blocks)
|
||||
const cleanContent = filterContent(finalMsg.content as ContentBlock[]);
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content, tool_calls) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', finalText || '(调用工具)', JSON.stringify(cleanContent));
|
||||
|
||||
currentMessages.push({ role: 'assistant', content: cleanContent });
|
||||
|
||||
// Execute tools
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
|
||||
for (const tool of toolUses) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tool_start',
|
||||
data: { tool: tool.name, input: tool.input },
|
||||
}));
|
||||
console.log(`[chat] Executing tool: ${tool.name}`);
|
||||
|
||||
try {
|
||||
const result = await videoAgent.executeTool(tool.name, tool.input as Record<string, unknown>);
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: tool.id, content: result });
|
||||
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(toolMsgId, convId, 'tool', JSON.stringify({ tool_use_id: tool.id, content: result }));
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tool_result',
|
||||
data: { tool: tool.name, result: result.slice(0, 1000) },
|
||||
}));
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: tool.id, content: `Error: ${errMsg}` });
|
||||
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(toolMsgId, convId, 'tool', JSON.stringify({ tool_use_id: tool.id, content: `Error: ${errMsg}` }));
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tool_error',
|
||||
data: { tool: tool.name, error: errMsg },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
currentMessages.push({ role: 'user', content: toolResults });
|
||||
if (protocol === 'openai') {
|
||||
// OpenAI protocol
|
||||
await streamOpenAI(ws, convId, history);
|
||||
} else {
|
||||
// Anthropic protocol (default)
|
||||
const messages: MessageParam[] = history.map(dbToAnthropic);
|
||||
await streamAnthropic(ws, convId, messages);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
|
||||
Reference in New Issue
Block a user