Compare commits
5 Commits
2859fa3f2c
...
01963aac96
| Author | SHA1 | Date | |
|---|---|---|---|
| 01963aac96 | |||
| 5104bbc18a | |||
| e850613972 | |||
| f43a640e64 | |||
| 348cc0c5b9 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"jianyingDraftPath": "/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
|
||||
"capcutMateDir": "/Users/lc/capcut-mate",
|
||||
"jianyingDraftPath": "C:/Users/45070/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft",
|
||||
"capcutMateDir": "C:/Users/45070/capcut-mate",
|
||||
"capcutMateApiBase": "http://capcut.muyetools.cn/openapi/capcut-mate/v1",
|
||||
"imgbbApiKey": "deprecated",
|
||||
"geminiApiBaseUrl": "https://yunwu.ai",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>美图 Agent</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-50 antialiased">
|
||||
<body class="bg-white text-zinc-900 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
118
web/client/package-lock.json
generated
118
web/client/package-lock.json
generated
@@ -12,15 +12,20 @@
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.0",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
@@ -1212,6 +1217,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
@@ -1237,6 +1249,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1265,6 +1284,29 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router": {
|
||||
"version": "5.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
|
||||
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router-dom": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1537,6 +1579,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2291,6 +2346,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
@@ -2356,6 +2420,54 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||
"integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz",
|
||||
"integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-simple-code-editor": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
|
||||
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2500,6 +2612,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -8,24 +8,29 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zustand": "^5.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"clsx": "^2.1.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"markdown-it": "^14.1.0"
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.0",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/markdown-it": "^14.1.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"vite": "^5.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"typescript": "^5.6.0"
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { ChatView } from '@/components/chat/ChatView';
|
||||
import { AccountList } from '@/components/accounts/AccountList';
|
||||
import { AssetGallery } from '@/components/assets/AssetGallery';
|
||||
import { ConfigForm } from '@/components/config/ConfigForm';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
function MainContent() {
|
||||
const view = useAppStore((s) => s.activeView);
|
||||
switch (view) {
|
||||
case 'chat': return <ChatView />;
|
||||
case 'accounts': return <AccountList />;
|
||||
case 'assets': return <AssetGallery />;
|
||||
case 'config': return <ConfigForm />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<MainContent />
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/chat" element={<ChatView />} />
|
||||
<Route path="/chat/:conversationId" element={<ChatView />} />
|
||||
<Route path="/accounts" element={<AccountList />} />
|
||||
<Route path="/accounts/:accountId" element={<AccountList />} />
|
||||
<Route path="/assets" element={<AssetGallery />} />
|
||||
<Route path="/settings" element={<ConfigForm />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
onChange={(e) => handleChange('id', e.target.value)}
|
||||
disabled={!!account}
|
||||
placeholder="my-account"
|
||||
className="mt-1 bg-zinc-900 border-zinc-800"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
value={form.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="账户名称"
|
||||
className="mt-1 bg-zinc-900 border-zinc-800"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
value={form.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
placeholder="简短描述..."
|
||||
className="mt-1 bg-zinc-900 border-zinc-800"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
<select
|
||||
value={form.defaultFormat}
|
||||
onChange={(e) => handleChange('defaultFormat', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm"
|
||||
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>
|
||||
@@ -79,7 +79,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
<select
|
||||
value={form.imageModel}
|
||||
onChange={(e) => handleChange('imageModel', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm"
|
||||
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>
|
||||
@@ -92,7 +92,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
<select
|
||||
value={form.videoModel}
|
||||
onChange={(e) => handleChange('videoModel', e.target.value)}
|
||||
className="mt-1 w-full h-10 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm"
|
||||
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 Fast Frames</option>
|
||||
@@ -108,7 +108,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
value={form.ttsVoice}
|
||||
onChange={(e) => handleChange('ttsVoice', e.target.value)}
|
||||
placeholder="cosyvoice-xxx"
|
||||
className="mt-1 bg-zinc-900 border-zinc-800 font-mono text-xs"
|
||||
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||
onChange={(e) => handleChange('ttsInstruction', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="用沉稳有力的男性声音朗读..."
|
||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm resize-y"
|
||||
className="mt-1 w-full rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from '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 type { Account } from '@/types';
|
||||
|
||||
export function AccountList() {
|
||||
const { accountId } = useParams<{ accountId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { accounts, create, update, remove } = useAccounts();
|
||||
const [editing, setEditing] = useState<Account | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
|
||||
|
||||
const editing = accountId ? accounts.find((a) => a.id === accountId) || null : null;
|
||||
|
||||
// Reset creating when navigating to an account
|
||||
useEffect(() => { setCreating(false); }, [accountId]);
|
||||
|
||||
const handleSelectAccount = (id: string) => {
|
||||
navigate(`/accounts/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<ScrollArea className="w-60 border-r border-zinc-800 p-3">
|
||||
<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-medium text-zinc-300">账户列表</h2>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setCreating(true)}>
|
||||
<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={() => { setEditing(a); setSubTab('info'); }}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-zinc-800/50 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
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 text-zinc-600">{a.imageModel} · {a.defaultFormat}</div>
|
||||
<div className="text-xs opacity-60">{a.imageModel} · {a.defaultFormat}</div>
|
||||
</button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex gap-0 border-b border-zinc-800 px-6">
|
||||
<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-3 py-2 text-sm ${subTab === 'info' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
||||
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-3 py-2 text-sm ${subTab === 'prompts' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
||||
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>
|
||||
@@ -57,21 +72,21 @@ export function AccountList() {
|
||||
account={creating ? undefined : editing!}
|
||||
onSave={(data) => {
|
||||
if (creating) {
|
||||
create(data).then(() => setCreating(false));
|
||||
create(data).then(() => { setCreating(false); if (data.id) navigate(`/accounts/${data.id}`); });
|
||||
} else {
|
||||
update(editing!.id, data).then(() => setEditing(null));
|
||||
update(editing!.id, data);
|
||||
}
|
||||
}}
|
||||
onDelete={editing ? () => {
|
||||
if (confirm(`确定删除账户「${editing.name}」?`)) {
|
||||
remove(editing.id).then(() => setEditing(null));
|
||||
remove(editing.id).then(() => navigate('/accounts'));
|
||||
}
|
||||
} : undefined}
|
||||
onCancel={() => { setEditing(null); setCreating(false); }}
|
||||
onCancel={() => { setCreating(false); }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-zinc-600 text-sm">
|
||||
<div className="flex items-center justify-center h-full text-zinc-400 text-sm">
|
||||
选择一个账户或创建新账户
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,11 +26,11 @@ export function AssetGallery() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-200 bg-white">
|
||||
<select
|
||||
value={accountFilter}
|
||||
onChange={(e) => setAccountFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-xs"
|
||||
className="h-8 rounded-md border border-zinc-200 bg-zinc-50 px-2 text-xs"
|
||||
>
|
||||
<option value="">全部账号</option>
|
||||
{accounts.map((a) => (
|
||||
@@ -40,12 +40,13 @@ export function AssetGallery() {
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-xs"
|
||||
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>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}>
|
||||
<RefreshCw size={12} className="mr-1" />
|
||||
扫描
|
||||
@@ -54,15 +55,18 @@ export function AssetGallery() {
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-zinc-500 text-sm text-center mt-8">加载中...</p>
|
||||
<p className="text-zinc-400 text-sm text-center mt-8">加载中...</p>
|
||||
) : assets.length === 0 ? (
|
||||
<p className="text-zinc-600 text-sm text-center mt-8">暂无资产,点击"扫描"导入</p>
|
||||
<div className="flex flex-col items-center justify-center mt-16 text-zinc-400">
|
||||
<p className="text-sm">暂无资产</p>
|
||||
<p className="text-xs mt-1">点击"扫描"从 output 目录导入</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="group relative aspect-[9/16] bg-zinc-900 rounded-lg overflow-hidden cursor-pointer border border-zinc-800 hover:border-zinc-600 transition-colors"
|
||||
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' ? (
|
||||
@@ -81,10 +85,20 @@ export function AssetGallery() {
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); remove(asset.id); }}
|
||||
className="absolute top-1 right-1 p-1 rounded bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
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-400" />
|
||||
<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>
|
||||
|
||||
@@ -1,41 +1,109 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Send, Terminal, Image, Play, FileText, ArrowUp } from 'lucide-react';
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
{ cmd: '/run', desc: '执行 pipeline 阶段', icon: Play },
|
||||
{ cmd: '/status', desc: '查看管线进度', icon: Terminal },
|
||||
{ cmd: '/images', desc: '生成图片', icon: Image },
|
||||
{ cmd: '/list', desc: '列出可用账号', icon: FileText },
|
||||
{ cmd: '/help', desc: '显示帮助', icon: Terminal },
|
||||
];
|
||||
|
||||
export function ChatInput({ onSend, disabled }: { onSend: (content: string) => void; disabled?: boolean }) {
|
||||
const [input, setInput] = useState('');
|
||||
const [showCmds, setShowCmds] = useState(false);
|
||||
const [cmdIdx, setCmdIdx] = useState(0);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const matchingCmds = input.startsWith('/')
|
||||
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
|
||||
: [];
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim()) return;
|
||||
if (!input.trim() || disabled) return;
|
||||
onSend(input.trim());
|
||||
setInput('');
|
||||
setShowCmds(false);
|
||||
ref.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showCmds && matchingCmds.length > 0) {
|
||||
if (e.key === 'Tab') { e.preventDefault(); setInput(matchingCmds[cmdIdx % matchingCmds.length].cmd + ' '); setShowCmds(false); return; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setCmdIdx((i) => (i + 1) % matchingCmds.length); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setCmdIdx((i) => (i - 1 + matchingCmds.length) % matchingCmds.length); return; }
|
||||
}
|
||||
if (e.key === 'Escape') { setShowCmds(false); return; }
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = input.trim().length > 0 && !disabled;
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t border-zinc-800">
|
||||
<div className="flex items-end gap-2 bg-zinc-900 rounded-lg border border-zinc-800 px-3 py-2">
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
placeholder={disabled ? '等待回复中...' : '输入指令...'}
|
||||
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-600"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={handleSend} disabled={disabled}>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
<div className="relative">
|
||||
{/* Slash command menu */}
|
||||
{showCmds && matchingCmds.length > 0 && (
|
||||
<div className="mx-6 mb-0.5 bg-white border border-zinc-200 rounded-xl shadow-lg overflow-hidden animate-in slide-in-from-bottom-2">
|
||||
{matchingCmds.map((c, i) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<button
|
||||
key={c.cmd}
|
||||
onClick={() => { setInput(c.cmd + ' '); setShowCmds(false); ref.current?.focus(); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 text-sm transition-colors ${
|
||||
i === (cmdIdx % matchingCmds.length) ? 'bg-indigo-50 text-indigo-700' : 'text-zinc-600 hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} className="text-zinc-400" />
|
||||
<span className="font-mono font-semibold text-xs">{c.cmd}</span>
|
||||
<span className="text-zinc-400 text-xs">{c.desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="px-4 pb-4 pt-2 bg-gradient-to-t from-white via-white to-transparent">
|
||||
<div className={`
|
||||
flex items-end gap-2 bg-zinc-50 rounded-2xl border transition-all duration-200 px-4 py-3
|
||||
${disabled ? 'border-zinc-200 opacity-60' : 'border-zinc-200 focus-within:border-indigo-300 focus-within:shadow-md focus-within:shadow-indigo-50 focus-within:bg-white'}
|
||||
`}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
placeholder={disabled ? '等待回复中...' : '输入消息,或按 / 查看快捷命令...'}
|
||||
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-400 text-zinc-700 min-h-[24px] max-h-[120px]"
|
||||
disabled={disabled}
|
||||
style={{ height: 'auto' }}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
className={`
|
||||
w-8 h-8 rounded-xl flex items-center justify-center transition-all flex-shrink-0
|
||||
${canSend
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm'
|
||||
: 'bg-zinc-200 text-zinc-400 cursor-not-allowed'}
|
||||
`}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 text-center mt-1.5">
|
||||
美图 Agent 可能会产生错误内容,请核实重要信息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,72 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { renderMarkdown } from '@/lib/markdown';
|
||||
import { RefreshCw, ArrowRight, Quote, Bot, User } from 'lucide-react';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
export function ChatMessage({ message }: { message: Message }) {
|
||||
interface Props {
|
||||
message: Message;
|
||||
onRegenerate?: (msgId: string) => void;
|
||||
onContinue?: (msgId: string) => void;
|
||||
onQuote?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, onRegenerate, onContinue, onQuote }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
const isEmpty = !message.content;
|
||||
|
||||
return (
|
||||
<div className={cn('mb-4 flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg px-4 py-2.5',
|
||||
<div className={cn('flex gap-3 mb-6', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
||||
{/* Avatar */}
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isUser ? 'bg-indigo-600 text-white' : 'bg-zinc-100 text-zinc-500'
|
||||
)}>
|
||||
{isUser ? <User size={15} /> : <Bot size={15} />}
|
||||
</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-zinc-800 text-zinc-100'
|
||||
: 'bg-zinc-900 text-zinc-300 border border-zinc-800'
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<span className="text-zinc-600 italic text-xs">...</span>
|
||||
) : isUser ? (
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div
|
||||
className="markdown-body text-sm leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
? '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" />
|
||||
</div>
|
||||
) : isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="markdown-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
|
||||
)}
|
||||
</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">
|
||||
<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">
|
||||
<Quote 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">
|
||||
<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">
|
||||
<ArrowRight size={10} />继续
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '@/store';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
@@ -6,18 +7,25 @@ import { ChatInput } from './ChatInput';
|
||||
import { PipelineProgress } from './PipelineProgress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { RefreshCw, Loader2, StopCircle, X } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Account, Message } from '@/types';
|
||||
|
||||
export function ChatView() {
|
||||
const { activeConversationId, setConversations, selectedAccountId } = useAppStore();
|
||||
const { messages, connected, thinking, toolStatus, send, createConversation } = useChat(activeConversationId);
|
||||
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 [manifestPath, setManifestPath] = useState<string | null>(null);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [quote, setQuote] = 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(() => {});
|
||||
.then((r) => r.json()).then(setConversations).catch(() => {});
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,71 +38,157 @@ export function ChatView() {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleNewConversation = () => {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}, [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(setConversations);
|
||||
}, 300);
|
||||
};
|
||||
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]);
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!manifestPath) return;
|
||||
try {
|
||||
await fetch('/api/pipeline/resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ manifest: manifestPath }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Resume failed:', e);
|
||||
}
|
||||
await fetch('/api/pipeline/resume', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ manifest: manifestPath }),
|
||||
});
|
||||
};
|
||||
|
||||
if (!activeConversationId) {
|
||||
const findPrecedingUser = useCallback((msgId: string): Message | null => {
|
||||
const idx = messages.findIndex((m) => m.id === msgId);
|
||||
if (idx <= 0) return null;
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') return messages[i];
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
const handleRegenerate = useCallback((msgId: string) => {
|
||||
const prev = findPrecedingUser(msgId);
|
||||
if (prev) send(prev.content);
|
||||
}, [findPrecedingUser, send]);
|
||||
|
||||
const handleContinue = useCallback(() => { send('请继续'); }, [send]);
|
||||
const handleQuote = useCallback((content: string) => { setQuote(content.slice(0, 200)); }, []);
|
||||
|
||||
const handleSend = useCallback((content: string) => {
|
||||
if (quote) { send(`> ${quote}\n\n${content}`); setQuote(null); }
|
||||
else send(content);
|
||||
}, [send, quote]);
|
||||
|
||||
const handleStop = useCallback(() => { stop(); }, [stop]);
|
||||
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-zinc-500">
|
||||
<p>选择对话或开始新对话</p>
|
||||
<button
|
||||
onClick={handleNewConversation}
|
||||
className="px-4 py-2 rounded-md bg-zinc-800 text-sm hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
开始新对话
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<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 && (
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="px-4 py-2 border-b border-zinc-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-zinc-500">{connected ? '已连接' : '连接中...'}</span>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
{manifestPath && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
|
||||
<RefreshCw size={12} className="mr-1" />断点续跑
|
||||
</Button>
|
||||
)}
|
||||
{thinking && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50" onClick={handleStop}>
|
||||
<StopCircle size={12} className="mr-1" />停止
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{manifestPath && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
|
||||
<RefreshCw size={12} className="mr-1" />
|
||||
断点续跑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-4 py-4">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
|
||||
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
|
||||
onQuote={handleQuote}
|
||||
/>
|
||||
))}
|
||||
|
||||
{thinking && (
|
||||
<div className="flex items-center gap-2 text-zinc-500 text-sm py-2">
|
||||
{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 || '思考中...'}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<ChatInput onSend={send} disabled={thinking} />
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatInput onSend={handleSend} disabled={thinking} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,189 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function ConfigForm() {
|
||||
const [configs, setConfigs] = useState<Record<string, string>>({});
|
||||
const [form, setForm] = useState({
|
||||
model: '',
|
||||
baseUrl: '',
|
||||
authToken: '',
|
||||
defaultImageModel: '',
|
||||
defaultVideoModel: '',
|
||||
defaultFormat: '',
|
||||
ossEndpoint: '',
|
||||
ossBucket: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfigs().then((list) => {
|
||||
const map: Record<string, string> = {};
|
||||
list.forEach((c) => { map[c.key] = JSON.stringify(c.value, null, 2); });
|
||||
setConfigs(map);
|
||||
const next = { ...form };
|
||||
for (const item of list) {
|
||||
try {
|
||||
const v = item.value as Record<string, string>;
|
||||
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 (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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = async (key: string, raw: string) => {
|
||||
const handleChange = (key: string, value: string) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveConfig(key, JSON.parse(raw));
|
||||
} catch { alert('Invalid JSON'); }
|
||||
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,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch {}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const configKeys = [
|
||||
{ key: 'api_keys', label: 'API 密钥' },
|
||||
{ key: 'defaults', label: '默认参数' },
|
||||
{ key: 'endpoints', label: '服务端点' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-lg p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold">设置</h2>
|
||||
{configKeys.map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-zinc-500">{label}</label>
|
||||
<textarea
|
||||
value={configs[key] || '{}'}
|
||||
onChange={(e) => setConfigs((c) => ({ ...c, [key]: e.target.value }))}
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-mono resize-y"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1"
|
||||
disabled={saving}
|
||||
onClick={() => handleSave(key, configs[key])}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<div className="max-w-xl mx-auto p-6 space-y-8 overflow-auto">
|
||||
<h2 className="text-lg font-semibold text-zinc-800">设置</h2>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { MiddlePanel } from './MiddlePanel';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const activeView = useAppStore((s) => s.activeView);
|
||||
const { pathname } = useLocation();
|
||||
const isChat = pathname.startsWith('/chat');
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-zinc-950 text-zinc-50 overflow-hidden">
|
||||
<div className="h-screen flex bg-white text-zinc-900 overflow-hidden">
|
||||
<Sidebar />
|
||||
{activeView === 'chat' && <MiddlePanel />}
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
{isChat && <MiddlePanel />}
|
||||
<main className="flex-1 flex flex-col min-w-0 bg-zinc-50">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -7,8 +8,10 @@ import { useAppStore } from '@/store';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function MiddlePanel() {
|
||||
const { conversations, activeConversationId, setActiveConversationId, setConversations } = useAppStore();
|
||||
const { conversations, setConversations } = useAppStore();
|
||||
const [search, setSearch] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { conversationId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
api.listConversations().then(setConversations).catch(() => {});
|
||||
@@ -23,32 +26,54 @@ export function MiddlePanel() {
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
const date = new Date(d);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 86400000) return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-60 flex flex-col border-r border-zinc-800">
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<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-900 border-zinc-800"
|
||||
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">
|
||||
<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>
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<ScrollArea className="flex-1">
|
||||
{conversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => setActiveConversationId(conv.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm truncate mb-0.5 transition-colors
|
||||
${conv.id === activeConversationId ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'}`}
|
||||
onClick={() => navigate(`/chat/${conv.id}`)}
|
||||
className={`w-full text-left px-3 py-2.5 border-b border-zinc-50 transition-colors
|
||||
${conv.id === conversationId
|
||||
? 'bg-indigo-50 border-l-2 border-l-indigo-600'
|
||||
: 'hover:bg-zinc-50 border-l-2 border-l-transparent'}`}
|
||||
>
|
||||
{conv.title}
|
||||
<div className={`text-sm truncate font-medium ${conv.id === conversationId ? 'text-indigo-700' : 'text-zinc-700'}`}>
|
||||
{conv.title}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-zinc-400 truncate max-w-[150px]">
|
||||
<MessageCircle size={10} className="inline mr-1" />
|
||||
{conv.title.length > 25 ? conv.title.slice(0, 25) + '...' : conv.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-300 flex-shrink-0 ml-2">
|
||||
{formatDate(conv.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{conversations.length === 0 && (
|
||||
<p className="text-xs text-zinc-600 text-center mt-8">暂无对话</p>
|
||||
<p className="text-xs text-zinc-400 text-center mt-8">暂无对话</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { MessageCircle, FolderOpen, Image, Settings } from 'lucide-react';
|
||||
import { useAppStore } from '@/store';
|
||||
import type { NavView } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navItems: { id: NavView; icon: typeof MessageCircle; label: string }[] = [
|
||||
{ id: 'chat', icon: MessageCircle, label: '对话' },
|
||||
{ id: 'accounts', icon: FolderOpen, label: '账户' },
|
||||
{ id: 'assets', icon: Image, label: '资产' },
|
||||
{ id: 'config', icon: Settings, label: '设置' },
|
||||
const navItems = [
|
||||
{ to: '/chat', icon: MessageCircle, label: '对话' },
|
||||
{ to: '/accounts', icon: FolderOpen, label: '账户' },
|
||||
{ to: '/assets', icon: Image, label: '资产' },
|
||||
{ to: '/settings', icon: Settings, label: '设置' },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { activeView, setActiveView } = useAppStore();
|
||||
|
||||
return (
|
||||
<aside className="w-14 flex flex-col items-center py-4 gap-2 border-r border-zinc-800">
|
||||
{navItems.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveView(id)}
|
||||
className={cn(
|
||||
'w-10 h-10 flex items-center justify-center rounded-lg transition-colors',
|
||||
activeView === id
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50'
|
||||
)}
|
||||
title={label}
|
||||
<aside className="w-16 flex flex-col items-center py-4 gap-1 border-r border-zinc-200 bg-zinc-50">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'w-12 h-12 flex flex-col items-center justify-center gap-0.5 rounded-lg transition-colors text-[10px] font-medium',
|
||||
isActive
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/themes/prism.css';
|
||||
import { useAccounts } from '@/hooks/useAccounts';
|
||||
import { usePrompts } from '@/hooks/usePrompts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -9,6 +14,13 @@ const PROMPT_TYPES = [
|
||||
{ type: 'video', label: '视频提示词' },
|
||||
] as const;
|
||||
|
||||
const editorStyle = {
|
||||
fontFamily: '"ui-monospace", "SFMono-Regular", "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
minHeight: '100%',
|
||||
};
|
||||
|
||||
export function PromptEditor() {
|
||||
const { accounts } = useAccounts();
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>('');
|
||||
@@ -27,15 +39,23 @@ export function PromptEditor() {
|
||||
}
|
||||
}, [selectedAccount, selectedType]);
|
||||
|
||||
const highlightCode = useCallback((code: string) => {
|
||||
try {
|
||||
return highlight(code, languages.markdown, 'markdown');
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="w-48 border-r border-zinc-800 p-3 space-y-3">
|
||||
<div className="w-48 border-r border-zinc-200 p-3 space-y-3 bg-zinc-50">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500">账户</label>
|
||||
<label className="text-xs font-medium text-zinc-500">账户</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="mt-1 w-full h-9 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-sm"
|
||||
className="mt-1 w-full h-9 rounded-md border border-zinc-200 bg-white px-2 text-sm text-zinc-700"
|
||||
>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
@@ -43,14 +63,14 @@ export function PromptEditor() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-500">模板</label>
|
||||
<label className="text-xs font-medium text-zinc-500">模板</label>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{PROMPT_TYPES.map(({ type, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||
selectedType === type ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:text-zinc-300'
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-sm transition-colors ${
|
||||
selectedType === type ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-zinc-500 hover:text-zinc-700 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -60,24 +80,29 @@ export function PromptEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
||||
<span className="text-xs text-zinc-500 font-mono">{path}</span>
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-200">
|
||||
<span className="text-xs text-zinc-400 font-mono">{path || '选择账户和模板'}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => save(selectedAccount, selectedType, content)}
|
||||
disabled={!selectedAccount || loading}
|
||||
className="text-xs"
|
||||
>
|
||||
保存
|
||||
{loading ? '加载中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="flex-1 w-full bg-zinc-950 text-zinc-200 font-mono text-sm p-4 resize-none outline-none"
|
||||
placeholder="加载中..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Editor
|
||||
value={content}
|
||||
onValueChange={setContent}
|
||||
highlight={highlightCode}
|
||||
padding={16}
|
||||
style={editorStyle}
|
||||
className="min-h-full bg-white"
|
||||
textareaClassName="outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { chatSocket } from '@/lib/websocket';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
interface StreamingMessage {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
created_at: string;
|
||||
streaming: boolean;
|
||||
interface PipelineState {
|
||||
phase: string;
|
||||
progress: number;
|
||||
currentItem?: number;
|
||||
totalItems?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function useChat(conversationId: string | null) {
|
||||
@@ -15,6 +15,7 @@ export function useChat(conversationId: string | null) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [toolStatus, setToolStatus] = useState<string | null>(null);
|
||||
const [pipeline, setPipeline] = useState<PipelineState | null>(null);
|
||||
const pendingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,14 +31,14 @@ export function useChat(conversationId: string | null) {
|
||||
setMessages((prev) => [...prev, data as unknown as Message]);
|
||||
});
|
||||
|
||||
// Streaming handlers
|
||||
// Streaming
|
||||
chatSocket.on('status', (data) => {
|
||||
if (data.status === 'thinking') setThinking(true);
|
||||
if (data.status === 'done') { setThinking(false); setToolStatus(null); }
|
||||
});
|
||||
|
||||
chatSocket.on('message_start', (data) => {
|
||||
setThinking(false);
|
||||
// Add placeholder for streaming
|
||||
setMessages((prev) => [...prev, {
|
||||
id: data.id as string,
|
||||
role: 'assistant',
|
||||
@@ -57,23 +58,41 @@ export function useChat(conversationId: string | null) {
|
||||
setThinking(false);
|
||||
});
|
||||
|
||||
// Tools
|
||||
chatSocket.on('tool_start', (data) => {
|
||||
setToolStatus(`正在执行: ${data.tool}...`);
|
||||
setToolStatus(`执行: ${data.tool}...`);
|
||||
});
|
||||
|
||||
chatSocket.on('tool_result', () => {
|
||||
chatSocket.on('tool_result', (data) => {
|
||||
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,
|
||||
content: JSON.stringify({ tool: data.tool, result: data.result }),
|
||||
created_at: new Date().toISOString(),
|
||||
conversation_id: '',
|
||||
}]);
|
||||
});
|
||||
|
||||
chatSocket.on('tool_error', (data) => {
|
||||
setToolStatus(`工具执行失败: ${data.tool}`);
|
||||
setTimeout(() => setToolStatus(null), 3000);
|
||||
setToolStatus(`失败: ${data.tool}`);
|
||||
setTimeout(() => setToolStatus(null), 4000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
chatSocket.disconnect();
|
||||
};
|
||||
// Pipeline progress
|
||||
chatSocket.on('pipeline_progress', (data) => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
return () => { chatSocket.disconnect(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -91,9 +110,13 @@ export function useChat(conversationId: string | null) {
|
||||
chatSocket.send('chat', { content });
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
chatSocket.stop();
|
||||
}, []);
|
||||
|
||||
const createConversation = useCallback((title: string, accountId?: string) => {
|
||||
chatSocket.send('create_conversation', { title, accountId });
|
||||
}, []);
|
||||
|
||||
return { messages, connected, thinking, toolStatus, send, createConversation };
|
||||
return { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation };
|
||||
}
|
||||
|
||||
@@ -4,20 +4,21 @@
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-zinc-800;
|
||||
@apply border-zinc-200;
|
||||
}
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-50;
|
||||
@apply bg-white text-zinc-900;
|
||||
}
|
||||
}
|
||||
|
||||
/* GitHub-style Markdown */
|
||||
/* GitHub-style Markdown - light theme */
|
||||
@layer components {
|
||||
.markdown-body {
|
||||
color-scheme: dark;
|
||||
color-scheme: light;
|
||||
color: #1f2328;
|
||||
}
|
||||
.markdown-body h1 { font-size: 1.5em; font-weight: 600; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid #30363d; }
|
||||
.markdown-body h2 { font-size: 1.25em; font-weight: 600; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid #30363d; }
|
||||
.markdown-body h1 { font-size: 1.5em; font-weight: 600; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid #d0d7de; }
|
||||
.markdown-body h2 { font-size: 1.25em; font-weight: 600; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid #d0d7de; }
|
||||
.markdown-body h3 { font-size: 1.1em; font-weight: 600; margin: 1em 0 0.25em; }
|
||||
.markdown-body h4 { font-size: 1em; font-weight: 600; margin: 1em 0 0.25em; }
|
||||
.markdown-body p { margin: 0 0 0.75em; }
|
||||
@@ -27,24 +28,22 @@
|
||||
.markdown-body li > p { margin: 0; }
|
||||
.markdown-body ul { list-style-type: disc; }
|
||||
.markdown-body ol { list-style-type: decimal; }
|
||||
.markdown-body ul ul { list-style-type: circle; }
|
||||
.markdown-body ul ul ul { list-style-type: square; }
|
||||
.markdown-body blockquote {
|
||||
margin: 0 0 0.75em;
|
||||
padding: 0 1em;
|
||||
color: #8b949e;
|
||||
border-left: 3px solid #30363d;
|
||||
color: #656d76;
|
||||
border-left: 3px solid #d0d7de;
|
||||
}
|
||||
.markdown-body code {
|
||||
background: rgba(110,118,129,0.2);
|
||||
background: rgba(175,184,193,0.2);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875em;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 0 0 0.75em;
|
||||
@@ -55,35 +54,19 @@
|
||||
padding: 0;
|
||||
font-size: 0.8125em;
|
||||
line-height: 1.5;
|
||||
color: #e6edf3;
|
||||
color: #1f2328;
|
||||
}
|
||||
.markdown-body hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: #30363d;
|
||||
background: #d0d7de;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.markdown-body a {
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.markdown-body a { color: #0969da; text-decoration: none; }
|
||||
.markdown-body a:hover { text-decoration: underline; }
|
||||
.markdown-body strong { font-weight: 600; }
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.75em;
|
||||
width: 100%;
|
||||
}
|
||||
.markdown-body th, .markdown-body td {
|
||||
border: 1px solid #30363d;
|
||||
padding: 6px 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-body th {
|
||||
font-weight: 600;
|
||||
background: rgba(110,118,129,0.1);
|
||||
}
|
||||
.markdown-body table { border-collapse: collapse; margin: 0 0 0.75em; width: 100%; }
|
||||
.markdown-body th, .markdown-body td { border: 1px solid #d0d7de; padding: 6px 13px; text-align: left; }
|
||||
.markdown-body th { font-weight: 600; background: #f6f8fa; }
|
||||
.markdown-body img { max-width: 100%; border-radius: 6px; }
|
||||
}
|
||||
|
||||
@@ -9,21 +9,11 @@ class ChatSocket {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${location.host}/ws`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.emit('connected', {});
|
||||
};
|
||||
|
||||
this.ws.onopen = () => { this.emit('connected', {}); };
|
||||
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);
|
||||
try { const { type, data } = JSON.parse(event.data); this.emit(type, data); } catch {}
|
||||
};
|
||||
this.ws.onclose = () => { this.reconnectTimer = setTimeout(() => this.connect(), 3000); };
|
||||
}
|
||||
|
||||
on(type: string, handler: MessageHandler) {
|
||||
@@ -42,6 +32,8 @@ class ChatSocket {
|
||||
}
|
||||
}
|
||||
|
||||
stop() { this.send('stop'); }
|
||||
|
||||
private emit(type: string, data: Record<string, unknown>) {
|
||||
(this.handlers.get(type) || []).forEach((h) => h(data));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import type { NavView, Conversation } from '@/types';
|
||||
import type { Conversation } from '@/types';
|
||||
|
||||
interface AppState {
|
||||
activeView: NavView;
|
||||
setActiveView: (view: NavView) => void;
|
||||
selectedAccountId: string | null;
|
||||
setSelectedAccountId: (id: string | null) => void;
|
||||
activeConversationId: string | null;
|
||||
setActiveConversationId: (id: string | null) => void;
|
||||
conversations: Conversation[];
|
||||
setConversations: (list: Conversation[]) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
activeView: 'chat',
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
selectedAccountId: null,
|
||||
setSelectedAccountId: (id) => set({ selectedAccountId: id }),
|
||||
activeConversationId: null,
|
||||
setActiveConversationId: (id) => set({ activeConversationId: id }),
|
||||
conversations: [],
|
||||
setConversations: (list) => set({ conversations: list }),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export type NavView = 'chat' | 'accounts' | 'assets' | 'config';
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -20,29 +20,29 @@ export default defineConfig({
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(240 3.7% 15.9%)',
|
||||
input: 'hsl(240 3.7% 15.9%)',
|
||||
ring: 'hsl(240 4.9% 83.9%)',
|
||||
background: 'hsl(240 10% 3.9%)',
|
||||
foreground: 'hsl(0 0% 98%)',
|
||||
border: 'hsl(240 5.9% 90%)',
|
||||
input: 'hsl(240 5.9% 90%)',
|
||||
ring: 'hsl(240 5% 64.9%)',
|
||||
background: 'hsl(0 0% 100%)',
|
||||
foreground: 'hsl(240 10% 3.9%)',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(0 0% 98%)',
|
||||
foreground: 'hsl(240 5.9% 10%)',
|
||||
DEFAULT: 'hsl(240 5.9% 10%)',
|
||||
foreground: 'hsl(0 0% 98%)',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(240 3.7% 15.9%)',
|
||||
foreground: 'hsl(0 0% 98%)',
|
||||
DEFAULT: 'hsl(240 4.8% 95.9%)',
|
||||
foreground: 'hsl(240 5.9% 10%)',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(240 3.7% 15.9%)',
|
||||
foreground: 'hsl(240 5% 64.9%)',
|
||||
DEFAULT: 'hsl(240 4.8% 95.9%)',
|
||||
foreground: 'hsl(240 3.8% 46.1%)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(240 3.7% 15.9%)',
|
||||
foreground: 'hsl(0 0% 98%)',
|
||||
DEFAULT: 'hsl(240 4.8% 95.9%)',
|
||||
foreground: 'hsl(240 5.9% 10%)',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(0 62.8% 30.6%)',
|
||||
DEFAULT: 'hsl(0 84.2% 60.2%)',
|
||||
foreground: 'hsl(0 0% 98%)',
|
||||
},
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,12 +1,15 @@
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js');
|
||||
const ACCOUNTS_DIR = path.join(PROJECT_ROOT, 'accounts');
|
||||
const OUTPUT_DIR = path.join(PROJECT_ROOT, 'output');
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
@@ -19,7 +22,55 @@ export interface ToolDefinition {
|
||||
execute: (params: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function loadJSON(filePath: string): unknown {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
/** Write items JSON to a temp file and invoke pipeline init, returning the manifest path */
|
||||
function runInit(params: {
|
||||
account: string;
|
||||
mode: string;
|
||||
items: unknown[];
|
||||
imageModel?: string;
|
||||
videoModel?: string;
|
||||
format?: string;
|
||||
}): string {
|
||||
// Write items to a temp file to avoid shell escaping issues with JSON
|
||||
const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`);
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(params.items), 'utf-8');
|
||||
try {
|
||||
const args = [
|
||||
`"${PIPELINE_SCRIPT}"`, 'init',
|
||||
`--account "${params.account}"`,
|
||||
`--mode ${params.mode}`,
|
||||
`--items-file "${tmpFile}"`,
|
||||
params.imageModel ? `--image-model ${params.imageModel}` : '',
|
||||
params.videoModel ? `--video-model ${params.videoModel}` : '',
|
||||
params.format ? `--format ${params.format}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' });
|
||||
// Parse manifest path from init output: "Manifest 已创建: <path>"
|
||||
const match = output.match(/Manifest 已创建: (.+)/);
|
||||
if (!match) throw new Error(`Failed to parse manifest path from init output: ${output}`);
|
||||
return match[1].trim();
|
||||
} finally {
|
||||
// Clean up temp file (pipeline init also deletes it, but be safe)
|
||||
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Registry
|
||||
// ============================================================================
|
||||
|
||||
export const tools: ToolDefinition[] = [
|
||||
// ---------------------------------------------------------------------------
|
||||
// Existing tools (kept exactly as before)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
name: 'list_accounts',
|
||||
description: '列出所有可用账号,返回每个账号的名称、描述、生图模型和视频模型',
|
||||
@@ -29,13 +80,13 @@ export const tools: ToolDefinition[] = [
|
||||
required: [],
|
||||
},
|
||||
execute: async () => {
|
||||
const accountsDir = path.join(PROJECT_ROOT, 'accounts');
|
||||
const dirs = fs.readdirSync(accountsDir, { withFileTypes: true })
|
||||
if (!fs.existsSync(ACCOUNTS_DIR)) return '暂无账号';
|
||||
const dirs = fs.readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
|
||||
.map((d) => {
|
||||
const configPath = path.join(accountsDir, d.name, 'account.json');
|
||||
const configPath = path.join(ACCOUNTS_DIR, d.name, 'account.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const cfg = loadJSON(configPath) as Record<string, string>;
|
||||
return `${d.name} - ${cfg.description || '无描述'} (生图:${cfg.imageModel} 视频:${cfg.videoModel} 画幅:${cfg.defaultFormat})`;
|
||||
}
|
||||
return d.name;
|
||||
@@ -130,10 +181,258 @@ export const tools: ToolDefinition[] = [
|
||||
},
|
||||
execute: async (params) => {
|
||||
const { accountId } = params as { accountId: string };
|
||||
const configPath = path.join(PROJECT_ROOT, 'accounts', accountId, 'account.json');
|
||||
const configPath = path.join(ACCOUNTS_DIR, accountId, 'account.json');
|
||||
if (!fs.existsSync(configPath)) return `账号「${accountId}」不存在`;
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const cfg = loadJSON(configPath);
|
||||
return JSON.stringify(cfg, null, 2);
|
||||
},
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New tools: image/video generation, manifest management
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
name: 'generate_images',
|
||||
description: '文生图:根据文本提示词生成图片。内部创建临时 manifest 并调用 pipeline images 阶段,支持批量生成。生成结果写入 output 目录,返回图片文件路径列表。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accountId: { type: 'string', description: '账号ID,用于继承模型、参考图等配置' },
|
||||
prompt: { type: 'string', description: '图片提示词(imagePrompt),描述要生成的图片内容' },
|
||||
count: { type: 'number', description: '生成图片数量,默认 1' },
|
||||
imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' },
|
||||
},
|
||||
required: ['accountId', 'prompt'],
|
||||
},
|
||||
execute: async (params) => {
|
||||
const { accountId, prompt, count, imageModel } = params as Record<string, unknown>;
|
||||
const num = Math.max(1, Number(count) || 1);
|
||||
// Build items: each item needs shotDesc + imagePrompt for the images phase
|
||||
const items = Array.from({ length: num }, (_, i) => ({
|
||||
id: i + 1,
|
||||
shotDesc: prompt as string,
|
||||
script: '',
|
||||
imagePrompt: prompt as string,
|
||||
keyword: 'generated',
|
||||
}));
|
||||
// Init manifest (items passed as array, helper writes temp file)
|
||||
const manifestPath = runInit({
|
||||
account: accountId as string,
|
||||
mode: 'single',
|
||||
items,
|
||||
imageModel: imageModel as string | undefined,
|
||||
});
|
||||
// Run images phase
|
||||
execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase images`, {
|
||||
cwd: PROJECT_ROOT, encoding: 'utf-8',
|
||||
});
|
||||
// Read back results
|
||||
const manifest = loadJSON(manifestPath) as { items?: Array<{ id: number; file?: string; candidates?: string[]; status?: string }> };
|
||||
const results = (manifest.items || []).map((item) => ({
|
||||
id: item.id,
|
||||
file: item.file || null,
|
||||
candidates: item.candidates || [],
|
||||
status: item.status,
|
||||
}));
|
||||
return JSON.stringify({ manifestPath, images: results }, null, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'generate_videos',
|
||||
description: '图生视频:根据已有图片和提示词生成 AI 视频。内部创建临时 manifest,先上传图片再执行 videos 阶段。返回视频文件路径。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accountId: { type: 'string', description: '账号ID,用于继承视频模型等配置' },
|
||||
imagePath: { type: 'string', description: '图片文件路径(本地绝对路径或相对 PROJECT_ROOT 的路径)' },
|
||||
prompt: { type: 'string', description: '视频提示词(videoPrompt),描述图片如何动起来' },
|
||||
videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' },
|
||||
imagePrompt: { type: 'string', description: '图片提示词(可选),用于 manifest 记录' },
|
||||
},
|
||||
required: ['accountId', 'imagePath', 'prompt'],
|
||||
},
|
||||
execute: async (params) => {
|
||||
const { accountId, imagePath, prompt, videoModel, imagePrompt } = params as Record<string, string>;
|
||||
const resolvedImagePath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(PROJECT_ROOT, imagePath);
|
||||
if (!fs.existsSync(resolvedImagePath)) {
|
||||
return `错误: 图片文件不存在: ${resolvedImagePath}`;
|
||||
}
|
||||
// Determine relative path from output dir
|
||||
const baseName = path.basename(resolvedImagePath);
|
||||
const item = {
|
||||
id: 1,
|
||||
shotDesc: imagePrompt || prompt,
|
||||
script: '',
|
||||
imagePrompt: imagePrompt || prompt,
|
||||
videoPrompt: prompt,
|
||||
keyword: 'generated',
|
||||
file: `images/${baseName}`,
|
||||
};
|
||||
// Init manifest (items passed as array, helper writes temp file)
|
||||
const manifestPath = runInit({
|
||||
account: accountId,
|
||||
mode: 'single',
|
||||
items: [item],
|
||||
videoModel: videoModel,
|
||||
});
|
||||
// Copy image into manifest's images dir
|
||||
const manifestDir = path.dirname(manifestPath);
|
||||
const imagesDir = path.join(manifestDir, 'images');
|
||||
if (!fs.existsSync(imagesDir)) fs.mkdirSync(imagesDir, { recursive: true });
|
||||
const targetPath = path.join(imagesDir, baseName);
|
||||
if (resolvedImagePath !== targetPath) {
|
||||
fs.copyFileSync(resolvedImagePath, targetPath);
|
||||
}
|
||||
// Run upload + videos phases
|
||||
execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase upload,videos`, {
|
||||
cwd: PROJECT_ROOT, encoding: 'utf-8',
|
||||
});
|
||||
// Read back results
|
||||
const manifest = loadJSON(manifestPath) as {
|
||||
items?: Array<{ id: number; video?: string; videoUrl?: string; videoDuration?: number; status?: string }>;
|
||||
};
|
||||
const results = (manifest.items || []).map((it) => ({
|
||||
id: it.id,
|
||||
video: it.video || null,
|
||||
videoUrl: it.videoUrl || null,
|
||||
videoDuration: it.videoDuration || null,
|
||||
status: it.status,
|
||||
}));
|
||||
return JSON.stringify({ manifestPath, videos: results }, null, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_account_prompts',
|
||||
description: '获取指定账号的三个提示词模板内容:分镜提示词、图片提示词、视频提示词。这些模板定义了账号的视频风格和创意方向。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accountId: { type: 'string', description: '账号ID' },
|
||||
},
|
||||
required: ['accountId'],
|
||||
},
|
||||
execute: async (params) => {
|
||||
const { accountId } = params as { accountId: string };
|
||||
const accountDir = path.join(ACCOUNTS_DIR, accountId);
|
||||
if (!fs.existsSync(accountDir)) return `账号「${accountId}」不存在`;
|
||||
const promptsDir = path.join(accountDir, 'prompts');
|
||||
const promptFiles = ['分镜.md', '图片提示词.md', '视频提示词.md'];
|
||||
const result: Record<string, string> = {};
|
||||
for (const file of promptFiles) {
|
||||
const filePath = path.join(promptsDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
result[file] = fs.readFileSync(filePath, 'utf-8');
|
||||
} else {
|
||||
result[file] = `(文件不存在: ${filePath})`;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result, null, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_outputs',
|
||||
description: '列出 output 目录下所有项目及其 manifest 状态,方便查看历史生成记录和进行中的任务。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
execute: async () => {
|
||||
if (!fs.existsSync(OUTPUT_DIR)) return 'output 目录不存在';
|
||||
const entries = fs.readdirSync(OUTPUT_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => {
|
||||
const manifestPath = path.join(OUTPUT_DIR, d.name, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) return { name: d.name, status: 'no manifest' };
|
||||
try {
|
||||
const manifest = loadJSON(manifestPath) as {
|
||||
account?: string;
|
||||
imageModel?: string;
|
||||
videoModel?: string;
|
||||
mode?: string;
|
||||
pipeline?: { phases?: Record<string, string> };
|
||||
items?: Array<{ status?: string }>;
|
||||
};
|
||||
const phases = manifest.pipeline?.phases || {};
|
||||
const itemCount = manifest.items?.length || 0;
|
||||
const doneCount = manifest.items?.filter((i) => i.status === 'done').length || 0;
|
||||
return {
|
||||
name: d.name,
|
||||
account: manifest.account || '?',
|
||||
mode: manifest.mode || '?',
|
||||
items: `${doneCount}/${itemCount} done`,
|
||||
phases: Object.entries(phases).map(([k, v]) => `${k}:${v}`).join(', ') || 'pending',
|
||||
};
|
||||
} catch {
|
||||
return { name: d.name, status: 'manifest parse error' };
|
||||
}
|
||||
});
|
||||
return JSON.stringify(entries, null, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_manifest',
|
||||
description: '读取指定 manifest.json 的完整内容,返回 JSON 字符串。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
manifestPath: { type: 'string', description: 'manifest.json 的绝对路径或相对路径' },
|
||||
},
|
||||
required: ['manifestPath'],
|
||||
},
|
||||
execute: async (params) => {
|
||||
const { manifestPath } = params as { manifestPath: string };
|
||||
const resolved = path.isAbsolute(manifestPath)
|
||||
? manifestPath
|
||||
: path.resolve(PROJECT_ROOT, manifestPath);
|
||||
if (!fs.existsSync(resolved)) return `manifest 不存在: ${resolved}`;
|
||||
const manifest = loadJSON(resolved);
|
||||
return JSON.stringify(manifest, null, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_manifest',
|
||||
description: '初始化新的 manifest.json,调用 pipeline.js init 创建输出目录和项目骨架。这是视频创作 pipeline 的起点。',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accountId: { type: 'string', description: '账号ID,决定模型、参考图等配置来源' },
|
||||
mode: { type: 'string', description: '创作模式: single (单图模式) 或 framePair (首尾帧模式)' },
|
||||
items: { type: 'string', description: '分镜数据 JSON 数组字符串。每个元素需包含 shotDesc (分镜描述) 和 script (旁白文案),可选 imagePrompt、videoPrompt、directorRef、keyword、duration' },
|
||||
imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' },
|
||||
videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' },
|
||||
format: { type: 'string', description: '画幅(可选,默认继承账号配置): 9:16, 16:9, 1:1' },
|
||||
},
|
||||
required: ['accountId', 'mode', 'items'],
|
||||
},
|
||||
execute: async (params) => {
|
||||
const { accountId, mode, items, imageModel, videoModel, format } = params as Record<string, string>;
|
||||
// Validate items JSON
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(items); } catch { return '错误: items 不是合法的 JSON 字符串'; }
|
||||
if (!Array.isArray(parsed) || (parsed as unknown[]).length === 0) return '错误: items 必须是非空 JSON 数组';
|
||||
// Write items to temp file to avoid shell escaping issues
|
||||
const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`);
|
||||
fs.writeFileSync(tmpFile, items, 'utf-8');
|
||||
try {
|
||||
const args = [
|
||||
`"${PIPELINE_SCRIPT}"`, 'init',
|
||||
`--account "${accountId}"`,
|
||||
`--mode ${mode}`,
|
||||
`--items-file "${tmpFile}"`,
|
||||
imageModel ? `--image-model ${imageModel}` : '',
|
||||
videoModel ? `--video-model ${videoModel}` : '',
|
||||
format ? `--format ${format}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' });
|
||||
const match = output.match(/Manifest 已创建: (.+)/);
|
||||
const manifestPath = match ? match[1].trim() : '(unable to parse path)';
|
||||
return `Manifest 已创建: ${manifestPath}\n\n${output}`;
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,21 +2,10 @@ import { WebSocket } from 'ws';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getDb } from '../db';
|
||||
import { videoAgent } from '../agent';
|
||||
import type { MessageParam, ToolUseBlock, TextBlock } from '@anthropic-ai/sdk/resources/messages.mjs';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
interface ChatMsg {
|
||||
type: string;
|
||||
conversationId?: string;
|
||||
content?: string;
|
||||
title?: string;
|
||||
accountId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
conversation_id?: string;
|
||||
role?: string;
|
||||
tool_calls?: string;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
}
|
||||
type MessageParam = Anthropic.MessageParam;
|
||||
type ContentBlock = Anthropic.ContentBlock;
|
||||
|
||||
interface DbMessage {
|
||||
id: string;
|
||||
@@ -27,6 +16,10 @@ interface DbMessage {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function filterContent(blocks: ContentBlock[]): ContentBlock[] {
|
||||
return blocks.filter((b) => b.type === 'text' || b.type === 'tool_use');
|
||||
}
|
||||
|
||||
function dbToAnthropic(msg: DbMessage): MessageParam {
|
||||
if (msg.role === 'user') {
|
||||
return { role: 'user', content: msg.content };
|
||||
@@ -34,8 +27,8 @@ function dbToAnthropic(msg: DbMessage): MessageParam {
|
||||
if (msg.role === 'assistant') {
|
||||
if (msg.tool_calls) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg.tool_calls);
|
||||
return { role: 'assistant', content: parsed };
|
||||
const parsed = JSON.parse(msg.tool_calls) as ContentBlock[];
|
||||
return { role: 'assistant', content: filterContent(parsed) };
|
||||
} catch {
|
||||
return { role: 'assistant', content: msg.content };
|
||||
}
|
||||
@@ -47,7 +40,7 @@ function dbToAnthropic(msg: DbMessage): MessageParam {
|
||||
const { tool_use_id, content } = JSON.parse(msg.content);
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id, content }],
|
||||
content: [{ type: 'tool_result' as const, tool_use_id, content }],
|
||||
};
|
||||
} catch {
|
||||
return { role: 'user', content: msg.content };
|
||||
@@ -61,9 +54,8 @@ export function handleChat(ws: WebSocket) {
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
const msg: ChatMsg = JSON.parse(raw.toString());
|
||||
const msg = JSON.parse(raw.toString());
|
||||
|
||||
// --- Init: load conversation history ---
|
||||
if (msg.type === 'init') {
|
||||
conversationId = msg.conversationId || randomUUID();
|
||||
const history = getDb().prepare(
|
||||
@@ -73,7 +65,6 @@ export function handleChat(ws: WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Create conversation ---
|
||||
if (msg.type === 'create_conversation') {
|
||||
const { title, accountId } = msg;
|
||||
conversationId = randomUUID();
|
||||
@@ -84,9 +75,12 @@ export function handleChat(ws: WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Chat with LLM ---
|
||||
if (msg.type === 'chat') {
|
||||
await handleChatMessage(ws, conversationId!, msg.content!);
|
||||
if (!conversationId) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: '没有活跃对话,请先创建或选择一个对话' } }));
|
||||
return;
|
||||
}
|
||||
await handleChatMessage(ws, conversationId, msg.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebSocket error:', e);
|
||||
@@ -98,14 +92,12 @@ export function handleChat(ws: WebSocket) {
|
||||
}
|
||||
|
||||
async function handleChatMessage(ws: WebSocket, convId: string, content: string) {
|
||||
// 1. Save user message
|
||||
const userMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(userMsgId, convId, 'user', content);
|
||||
ws.send(JSON.stringify({ type: 'message', data: { id: userMsgId, role: 'user', content } }));
|
||||
|
||||
// Update conversation title if first message
|
||||
const msgCount = getDb().prepare(
|
||||
'SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?'
|
||||
).get(convId) as { count: number };
|
||||
@@ -114,18 +106,14 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
getDb().prepare('UPDATE conversations SET title = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
||||
.run(title, convId);
|
||||
}
|
||||
|
||||
// Update conversation timestamp
|
||||
getDb().prepare('UPDATE conversations SET updated_at = datetime(\'now\') WHERE id = ?').run(convId);
|
||||
|
||||
// 2. Build message history for Anthropic
|
||||
const history = getDb().prepare(
|
||||
'SELECT * FROM messages WHERE conversation_id = ? AND id != ? ORDER BY created_at'
|
||||
).all(convId, userMsgId) as DbMessage[];
|
||||
|
||||
const messages: MessageParam[] = history.map(dbToAnthropic);
|
||||
|
||||
// 3. Call LLM with tool loop
|
||||
const client = videoAgent.getClient();
|
||||
const model = videoAgent.getModel();
|
||||
const systemPrompt = videoAgent.getSystemPrompt();
|
||||
@@ -137,6 +125,7 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
let maxLoops = 10;
|
||||
|
||||
while (maxLoops-- > 0) {
|
||||
console.log(`[chat] Calling LLM, loop ${9 - maxLoops}, messages: ${currentMessages.length}`);
|
||||
const stream = client.messages.stream({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
@@ -145,87 +134,58 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
messages: currentMessages,
|
||||
});
|
||||
|
||||
let assistantContent = '';
|
||||
let toolUseBlocks: { id: string; name: string; input: Record<string, unknown> }[] = [];
|
||||
const assistantMsgId = randomUUID();
|
||||
|
||||
// Stream text
|
||||
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') {
|
||||
assistantContent += event.delta.text;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'text_delta',
|
||||
data: { id: assistantMsgId, text: event.delta.text },
|
||||
}));
|
||||
}
|
||||
if (event.delta.type === 'input_json_delta') {
|
||||
// Accumulating tool input — handled by SDK internally
|
||||
}
|
||||
}
|
||||
if (event.type === 'content_block_start') {
|
||||
if (event.content_block.type === 'tool_use') {
|
||||
toolUseBlocks.push({
|
||||
id: event.content_block.id,
|
||||
name: event.content_block.name,
|
||||
input: (event.content_block.input || {}) as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalMsg = await stream.finalMessage();
|
||||
ws.send(JSON.stringify({ type: 'message_end', data: { id: assistantMsgId } }));
|
||||
|
||||
// Extract tool uses from final message
|
||||
const toolUses: { id: string; name: string; input: Record<string, unknown> }[] = [];
|
||||
const textBlocks: string[] = [];
|
||||
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('');
|
||||
|
||||
for (const block of finalMsg.content) {
|
||||
if (block.type === 'text') {
|
||||
textBlocks.push(block.text);
|
||||
}
|
||||
if (block.type === 'tool_use') {
|
||||
toolUses.push({ id: block.id, name: block.name, input: block.input as Record<string, unknown> });
|
||||
}
|
||||
}
|
||||
|
||||
// No tool calls — save assistant message and done
|
||||
// No tool calls — save and done
|
||||
if (toolUses.length === 0) {
|
||||
const finalText = textBlocks.join('');
|
||||
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;
|
||||
}
|
||||
|
||||
// Has tool calls — save assistant message with tool_calls, execute tools, add results
|
||||
// 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', textBlocks.join('') || '(调用工具)', JSON.stringify(finalMsg.content));
|
||||
).run(assistantMsgId, convId, 'assistant', finalText || '(调用工具)', JSON.stringify(cleanContent));
|
||||
|
||||
// Build assistant content blocks for Anthropic
|
||||
const assistantBlocks: (TextBlock | ToolUseBlock)[] = finalMsg.content
|
||||
.filter((b): b is TextBlock | ToolUseBlock => b.type === 'text' || b.type === 'tool_use');
|
||||
currentMessages.push({ role: 'assistant', content: cleanContent });
|
||||
|
||||
currentMessages.push({ role: 'assistant', content: assistantBlocks });
|
||||
|
||||
// Execute tools and send results
|
||||
const toolResults: { type: 'tool_result'; tool_use_id: string; content: string }[] = [];
|
||||
// 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);
|
||||
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 });
|
||||
|
||||
// Save tool result to DB
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
@@ -251,17 +211,11 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool results to conversation
|
||||
currentMessages.push({
|
||||
role: 'user',
|
||||
content: toolResults,
|
||||
});
|
||||
|
||||
// Continue loop — LLM will process tool results and possibly call more tools or give final response
|
||||
currentMessages.push({ role: 'user', content: toolResults });
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
console.error('LLM error:', errMsg);
|
||||
console.error('[chat] LLM error:', errMsg);
|
||||
const errId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
|
||||
Reference in New Issue
Block a user