Compare commits
20 Commits
fa8bfde952
...
d6b18fb7dc
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b18fb7dc | |||
| 1bad63ff6d | |||
| 177c90a661 | |||
| af6ea966fd | |||
| 685e383621 | |||
| 6e3f5d9415 | |||
| 10685ea866 | |||
| 375540f528 | |||
| 19a1a4e6d2 | |||
| e35778ebea | |||
| 66f0f82fde | |||
| c9fe380d0d | |||
| 93e67bb8dc | |||
| 63e619bb92 | |||
| c27bd54bb3 | |||
| 8b11383bc4 | |||
| 58ceb0af00 | |||
| 0c8283d6e9 | |||
| d63ecd8d29 | |||
| 5263fcd571 |
24
web/client/src/App.tsx
Normal file
24
web/client/src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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 />
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
web/client/src/components/accounts/AccountForm.tsx
Normal file
135
web/client/src/components/accounts/AccountForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { Account } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
account?: Account;
|
||||||
|
onSave: (data: Partial<Account>) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: account?.id || '',
|
||||||
|
name: account?.name || '',
|
||||||
|
description: account?.description || '',
|
||||||
|
defaultFormat: account?.defaultFormat || '9:16',
|
||||||
|
imageModel: account?.imageModel || 'gemini',
|
||||||
|
videoModel: account?.videoModel || 'veo3-fast',
|
||||||
|
ttsVoice: account?.ttsVoice || '',
|
||||||
|
ttsInstruction: account?.ttsInstruction || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (key: string, value: string) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{account ? `编辑账户: ${account.name}` : '创建新账户'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">账户 ID</label>
|
||||||
|
<Input
|
||||||
|
value={form.id}
|
||||||
|
onChange={(e) => handleChange('id', e.target.value)}
|
||||||
|
disabled={!!account}
|
||||||
|
placeholder="my-account"
|
||||||
|
className="mt-1 bg-zinc-900 border-zinc-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">名称</label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
placeholder="账户名称"
|
||||||
|
className="mt-1 bg-zinc-900 border-zinc-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">描述</label>
|
||||||
|
<Input
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
placeholder="简短描述..."
|
||||||
|
className="mt-1 bg-zinc-900 border-zinc-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs 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-800 bg-zinc-900 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="9:16">9:16 竖屏</option>
|
||||||
|
<option value="16:9">16:9 横屏</option>
|
||||||
|
<option value="1:1">1:1 方形</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">生图模型</label>
|
||||||
|
<select
|
||||||
|
value={form.imageModel}
|
||||||
|
onChange={(e) => handleChange('imageModel', e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
<option value="mj">Midjourney</option>
|
||||||
|
<option value="gpt">GPT Image</option>
|
||||||
|
<option value="kling">Kling</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">视频模型</label>
|
||||||
|
<select
|
||||||
|
value={form.videoModel}
|
||||||
|
onChange={(e) => handleChange('videoModel', e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">TTS 语音</label>
|
||||||
|
<Input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">TTS 指令</label>
|
||||||
|
<textarea
|
||||||
|
value={form.ttsInstruction}
|
||||||
|
onChange={(e) => handleChange('ttsInstruction', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="用沉稳有力的男性声音朗读..."
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button onClick={() => onSave(form)}>保存</Button>
|
||||||
|
{onDelete && (
|
||||||
|
<Button variant="destructive" onClick={onDelete}>删除</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={onCancel}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
web/client/src/components/accounts/AccountList.tsx
Normal file
85
web/client/src/components/accounts/AccountList.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
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 { accounts, create, update, remove } = useAccounts();
|
||||||
|
const [editing, setEditing] = useState<Account | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<ScrollArea className="w-60 border-r border-zinc-800 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)}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{a.name}</div>
|
||||||
|
<div className="text-xs text-zinc-600">{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">
|
||||||
|
<button
|
||||||
|
onClick={() => setSubTab('info')}
|
||||||
|
className={`px-3 py-2 text-sm ${subTab === 'info' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
||||||
|
>
|
||||||
|
账户设置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSubTab('prompts')}
|
||||||
|
className={`px-3 py-2 text-sm ${subTab === 'prompts' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
||||||
|
>
|
||||||
|
提示词模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{subTab === 'info' ? (
|
||||||
|
(editing || creating) ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<AccountForm
|
||||||
|
account={creating ? undefined : editing!}
|
||||||
|
onSave={(data) => {
|
||||||
|
if (creating) {
|
||||||
|
create(data).then(() => setCreating(false));
|
||||||
|
} else {
|
||||||
|
update(editing!.id, data).then(() => setEditing(null));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={editing ? () => {
|
||||||
|
if (confirm(`确定删除账户「${editing.name}」?`)) {
|
||||||
|
remove(editing.id).then(() => setEditing(null));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onCancel={() => { setEditing(null); setCreating(false); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-zinc-600 text-sm">
|
||||||
|
选择一个账户或创建新账户
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<PromptEditor />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
web/client/src/components/assets/AssetGallery.tsx
Normal file
97
web/client/src/components/assets/AssetGallery.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Trash2, RefreshCw } from 'lucide-react';
|
||||||
|
import { useAssets } from '@/hooks/useAssets';
|
||||||
|
import { AssetPreview } from './AssetPreview';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { Asset } from '@/types';
|
||||||
|
|
||||||
|
export function AssetGallery() {
|
||||||
|
const [accountFilter, setAccountFilter] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||||
|
const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const { assets, loading, remove } = useAssets({
|
||||||
|
accountId: accountFilter || undefined,
|
||||||
|
type: typeFilter || undefined,
|
||||||
|
});
|
||||||
|
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/accounts').then((r) => r.json()).then(setAccounts).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
await fetch('/api/assets/scan', { method: 'POST' });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<option value="">全部账号</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="image">图片</option>
|
||||||
|
<option value="video">视频</option>
|
||||||
|
</select>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}>
|
||||||
|
<RefreshCw size={12} className="mr-1" />
|
||||||
|
扫描
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-zinc-500 text-sm text-center mt-8">加载中...</p>
|
||||||
|
) : assets.length === 0 ? (
|
||||||
|
<p className="text-zinc-600 text-sm text-center mt-8">暂无资产,点击"扫描"导入</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{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"
|
||||||
|
onClick={() => setPreviewAsset(asset)}
|
||||||
|
>
|
||||||
|
{asset.type === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); remove(asset.id); }}
|
||||||
|
className="absolute top-1 right-1 p-1 rounded bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} className="text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewAsset && <AssetPreview asset={previewAsset} onClose={() => setPreviewAsset(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
web/client/src/components/assets/AssetPreview.tsx
Normal file
35
web/client/src/components/assets/AssetPreview.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
import type { Asset } from '@/types';
|
||||||
|
|
||||||
|
export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-zinc-800 hover:bg-zinc-700 text-white"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{asset.type === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||||
|
alt=""
|
||||||
|
className="max-w-full max-h-[90vh] object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="max-w-full max-h-[90vh] rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
web/client/src/components/chat/ChatInput.tsx
Normal file
41
web/client/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function ChatInput({ onSend }: { onSend: (content: string) => void }) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
onSend(input.trim());
|
||||||
|
setInput('');
|
||||||
|
ref.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="输入指令..."
|
||||||
|
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={handleSend}>
|
||||||
|
<Send size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
web/client/src/components/chat/ChatMessage.tsx
Normal file
19
web/client/src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Message } from '@/types';
|
||||||
|
|
||||||
|
export function ChatMessage({ message }: { message: Message }) {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
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 text-sm leading-relaxed',
|
||||||
|
isUser ? 'bg-zinc-800 text-zinc-100' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
web/client/src/components/chat/ChatView.tsx
Normal file
91
web/client/src/components/chat/ChatView.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAppStore } from '@/store';
|
||||||
|
import { useChat } from '@/hooks/useChat';
|
||||||
|
import { ChatMessage } from './ChatMessage';
|
||||||
|
import { ChatInput } from './ChatInput';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ChatView() {
|
||||||
|
const { activeConversationId, conversations, setConversations, selectedAccountId } = useAppStore();
|
||||||
|
const { messages, connected, send, createConversation } = useChat(activeConversationId);
|
||||||
|
const [manifestPath, setManifestPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/pipeline/conversations')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setConversations)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Check for associated manifest in messages
|
||||||
|
useEffect(() => {
|
||||||
|
const toolMsgs = messages.filter((m) => m.role === 'tool');
|
||||||
|
if (toolMsgs.length > 0) {
|
||||||
|
try {
|
||||||
|
const lastTool = JSON.parse(toolMsgs[toolMsgs.length - 1].content);
|
||||||
|
if (lastTool.manifest) setManifestPath(lastTool.manifest);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleNewConversation = () => {
|
||||||
|
createConversation('新对话', selectedAccountId || undefined);
|
||||||
|
setTimeout(() => {
|
||||||
|
fetch('/api/pipeline/conversations')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setConversations);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeConversationId) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{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} />
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
<ChatInput onSend={send} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
web/client/src/components/chat/PipelineProgress.tsx
Normal file
41
web/client/src/components/chat/PipelineProgress.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
phase: string;
|
||||||
|
progress: number;
|
||||||
|
currentItem?: number;
|
||||||
|
totalItems?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineProgress({ phase, progress, currentItem, totalItems, status }: Props) {
|
||||||
|
const phaseLabel: Record<string, string> = {
|
||||||
|
images: '生成图片',
|
||||||
|
upload: '上传素材',
|
||||||
|
videos: '生成视频',
|
||||||
|
tts: '配音',
|
||||||
|
assemble: '成片组装',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 my-2">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-xs text-zinc-400">
|
||||||
|
{phaseLabel[phase] || phase}
|
||||||
|
{currentItem && totalItems ? ` (${currentItem}/${totalItems})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-zinc-500">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-500',
|
||||||
|
progress < 100 ? 'bg-blue-500' : 'bg-green-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{status && <p className="text-xs text-zinc-500 mt-1">{status}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/client/src/components/config/ConfigForm.tsx
Normal file
56
web/client/src/components/config/ConfigForm.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export function ConfigForm() {
|
||||||
|
const [configs, setConfigs] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = 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 handleSave = async (key: string, raw: string) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.saveConfig(key, JSON.parse(raw));
|
||||||
|
} catch { alert('Invalid JSON'); }
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
web/client/src/components/layout/AppLayout.tsx
Normal file
17
web/client/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex bg-zinc-950 text-zinc-50 overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
{activeView === 'chat' && <MiddlePanel />}
|
||||||
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/client/src/components/layout/MiddlePanel.tsx
Normal file
56
web/client/src/components/layout/MiddlePanel.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useAppStore } from '@/store';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export function MiddlePanel() {
|
||||||
|
const { conversations, activeConversationId, setActiveConversationId, setConversations } = useAppStore();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listConversations().then(setConversations).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearch(value);
|
||||||
|
const qs = value ? `?search=${encodeURIComponent(value)}` : '';
|
||||||
|
fetch(`/api/pipeline/conversations${qs}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setConversations)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-60 flex flex-col border-r border-zinc-800">
|
||||||
|
<div className="p-3 flex items-center justify-between">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索对话..."
|
||||||
|
className="h-8 text-xs bg-zinc-900 border-zinc-800"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8 ml-1">
|
||||||
|
<Plus size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1 px-2">
|
||||||
|
{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'}`}
|
||||||
|
>
|
||||||
|
{conv.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{conversations.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-600 text-center mt-8">暂无对话</p>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
web/client/src/components/layout/Sidebar.tsx
Normal file
35
web/client/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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: '设置' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
web/client/src/components/prompts/PromptEditor.tsx
Normal file
84
web/client/src/components/prompts/PromptEditor.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAccounts } from '@/hooks/useAccounts';
|
||||||
|
import { usePrompts } from '@/hooks/usePrompts';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const PROMPT_TYPES = [
|
||||||
|
{ type: 'storyboard', label: '分镜' },
|
||||||
|
{ type: 'image', label: '图片提示词' },
|
||||||
|
{ type: 'video', label: '视频提示词' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function PromptEditor() {
|
||||||
|
const { accounts } = useAccounts();
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<string>('');
|
||||||
|
const [selectedType, setSelectedType] = useState<string>('storyboard');
|
||||||
|
const { content, path, loading, load, save, setContent } = usePrompts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accounts.length > 0 && !selectedAccount) {
|
||||||
|
setSelectedAccount(accounts[0].id);
|
||||||
|
}
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAccount) {
|
||||||
|
load(selectedAccount, selectedType);
|
||||||
|
}
|
||||||
|
}, [selectedAccount, selectedType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="w-48 border-r border-zinc-800 p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs 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"
|
||||||
|
>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => save(selectedAccount, selectedType, content)}
|
||||||
|
disabled={!selectedAccount || 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
web/client/src/components/ui/button.tsx
Normal file
39
web/client/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'default' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => (
|
||||||
|
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
21
web/client/src/components/ui/input.tsx
Normal file
21
web/client/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
16
web/client/src/components/ui/scroll-area.tsx
Normal file
16
web/client/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('overflow-auto', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = 'ScrollArea';
|
||||||
|
|
||||||
|
export { ScrollArea };
|
||||||
20
web/client/src/hooks/useAccounts.ts
Normal file
20
web/client/src/hooks/useAccounts.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Account } from '@/types';
|
||||||
|
|
||||||
|
export function useAccounts() {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
api.listAccounts().then(setAccounts).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const create = (data: Partial<Account>) => api.createAccount(data).then(refresh);
|
||||||
|
const update = (id: string, data: Partial<Account>) => api.updateAccount(id, data).then(refresh);
|
||||||
|
const remove = (id: string) => api.deleteAccount(id).then(refresh);
|
||||||
|
|
||||||
|
return { accounts, loading, refresh, create, update, remove };
|
||||||
|
}
|
||||||
19
web/client/src/hooks/useAssets.ts
Normal file
19
web/client/src/hooks/useAssets.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Asset } from '@/types';
|
||||||
|
|
||||||
|
export function useAssets(params?: { accountId?: string; type?: string }) {
|
||||||
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.listAssets(params).then(setAssets).finally(() => setLoading(false));
|
||||||
|
}, [params?.accountId, params?.type]);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const remove = (id: string) => api.deleteAsset(id).then(refresh);
|
||||||
|
|
||||||
|
return { assets, loading, refresh, remove };
|
||||||
|
}
|
||||||
46
web/client/src/hooks/useChat.ts
Normal file
46
web/client/src/hooks/useChat.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { chatSocket } from '@/lib/websocket';
|
||||||
|
import type { Message } from '@/types';
|
||||||
|
|
||||||
|
export function useChat(conversationId: string | null) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const pendingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatSocket.connect();
|
||||||
|
|
||||||
|
chatSocket.on('connected', () => setConnected(true));
|
||||||
|
chatSocket.on('history', (data) => {
|
||||||
|
setMessages((data.messages as Message[]) || []);
|
||||||
|
});
|
||||||
|
chatSocket.on('message', (data) => {
|
||||||
|
setMessages((prev) => [...prev, data as unknown as Message]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chatSocket.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversationId && connected && !pendingRef.current) {
|
||||||
|
pendingRef.current = true;
|
||||||
|
chatSocket.send('init', { conversationId });
|
||||||
|
}
|
||||||
|
if (!conversationId) {
|
||||||
|
pendingRef.current = false;
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}, [conversationId, connected]);
|
||||||
|
|
||||||
|
const send = useCallback((content: string) => {
|
||||||
|
chatSocket.send('chat', { content });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createConversation = useCallback((title: string, accountId?: string) => {
|
||||||
|
chatSocket.send('create_conversation', { title, accountId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { messages, connected, send, createConversation };
|
||||||
|
}
|
||||||
25
web/client/src/hooks/usePrompts.ts
Normal file
25
web/client/src/hooks/usePrompts.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export function usePrompts() {
|
||||||
|
const [content, setContent] = useState<string>('');
|
||||||
|
const [path, setPath] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (accountId: string, type: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.getPrompt(accountId, type);
|
||||||
|
setContent(result.content);
|
||||||
|
setPath(result.path);
|
||||||
|
} catch { setContent(''); setPath(''); }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = useCallback(async (accountId: string, type: string, newContent: string) => {
|
||||||
|
await api.savePrompt(accountId, type, newContent);
|
||||||
|
setContent(newContent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { content, path, loading, load, save, setContent };
|
||||||
|
}
|
||||||
48
web/client/src/lib/api.ts
Normal file
48
web/client/src/lib/api.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Account, Asset, Conversation, Message, ConfigItem } from '@/types';
|
||||||
|
|
||||||
|
const BASE = '/api';
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Accounts
|
||||||
|
listAccounts: () => request<Account[]>('/accounts'),
|
||||||
|
getAccount: (id: string) => request<Account>(`/accounts/${id}`),
|
||||||
|
createAccount: (data: Partial<Account>) =>
|
||||||
|
request<Account>('/accounts', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateAccount: (id: string, data: Partial<Account>) =>
|
||||||
|
request<Account>(`/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteAccount: (id: string) =>
|
||||||
|
request<void>(`/accounts/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Prompts
|
||||||
|
getPrompt: (accountId: string, type: string) =>
|
||||||
|
request<{ path: string; content: string }>(`/prompts/${accountId}/${type}`),
|
||||||
|
savePrompt: (accountId: string, type: string, content: string) =>
|
||||||
|
request<void>(`/prompts/${accountId}/${type}`, { method: 'PUT', body: JSON.stringify({ content }) }),
|
||||||
|
|
||||||
|
// Conversations
|
||||||
|
listConversations: () => request<Conversation[]>('/pipeline/conversations'),
|
||||||
|
getMessages: (convId: string) => request<Message[]>(`/pipeline/conversations/${convId}/messages`),
|
||||||
|
|
||||||
|
// Assets
|
||||||
|
listAssets: (params?: { accountId?: string; type?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.accountId) qs.set('accountId', params.accountId);
|
||||||
|
if (params?.type) qs.set('type', params.type);
|
||||||
|
return request<Asset[]>(`/assets?${qs}`);
|
||||||
|
},
|
||||||
|
deleteAsset: (id: string) => request<void>(`/assets/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Configs
|
||||||
|
getConfigs: () => request<ConfigItem[]>('/configs'),
|
||||||
|
saveConfig: (key: string, value: Record<string, unknown>) =>
|
||||||
|
request<void>(`/configs/${key}`, { method: 'PUT', body: JSON.stringify({ value }) }),
|
||||||
|
};
|
||||||
55
web/client/src/lib/websocket.ts
Normal file
55
web/client/src/lib/websocket.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
type MessageHandler = (data: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
class ChatSocket {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private handlers: Map<string, MessageHandler[]> = new Map();
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
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.onmessage = (event) => {
|
||||||
|
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) {
|
||||||
|
if (!this.handlers.has(type)) this.handlers.set(type, []);
|
||||||
|
this.handlers.get(type)!.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(type: string, handler: MessageHandler) {
|
||||||
|
const list = this.handlers.get(type);
|
||||||
|
if (list) this.handlers.set(type, list.filter((h) => h !== handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
send(type: string, data: Record<string, unknown> = {}) {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify({ type, ...data }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(type: string, data: Record<string, unknown>) {
|
||||||
|
(this.handlers.get(type) || []).forEach((h) => h(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatSocket = new ChatSocket();
|
||||||
24
web/client/src/store/index.ts
Normal file
24
web/client/src/store/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { NavView, 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 }),
|
||||||
|
}));
|
||||||
53
web/client/src/types/index.ts
Normal file
53
web/client/src/types/index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export type NavView = 'chat' | 'accounts' | 'assets' | 'config';
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
defaultFormat: string;
|
||||||
|
imageModel: string;
|
||||||
|
videoModel: string;
|
||||||
|
batchSize: number;
|
||||||
|
ttsVoice: string;
|
||||||
|
ttsInstruction: string;
|
||||||
|
storyboardPrompt: string;
|
||||||
|
imageStylePrompt: string;
|
||||||
|
videoStylePrompt: string;
|
||||||
|
references: { file: string; url?: string }[];
|
||||||
|
capcut: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
account_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||||
|
content: string;
|
||||||
|
tool_calls?: unknown;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
account_id: string | null;
|
||||||
|
manifest_path: string | null;
|
||||||
|
type: 'image' | 'video';
|
||||||
|
file_path: string;
|
||||||
|
url: string | null;
|
||||||
|
shot_index: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigItem {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
value: Record<string, unknown>;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
16
web/components.json
Normal file
16
web/components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "client/tailwind.config.ts",
|
||||||
|
"css": "client/src/index.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/package-lock.json
generated
13
web/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
@@ -1903,6 +1904,18 @@
|
|||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/class-variance-authority": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://polar.sh/cva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
|||||||
@@ -10,32 +10,33 @@
|
|||||||
"db:init": "tsx server/db/schema.ts"
|
"db:init": "tsx server/db/schema.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
|
||||||
"ws": "^8.18.0",
|
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
"zod": "^3.23.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"zustand": "^5.0.0",
|
"express": "^4.21.0",
|
||||||
|
"lucide-react": "^0.460.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"lucide-react": "^0.460.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"clsx": "^2.1.0",
|
"ws": "^8.18.0",
|
||||||
"tailwind-merge": "^2.6.0"
|
"zod": "^3.23.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/ws": "^8.5.0",
|
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
"@types/cors": "^2.8.0",
|
"@types/cors": "^2.8.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"typescript": "^5.6.0",
|
"@types/ws": "^8.5.0",
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"vite": "^5.4.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"postcss": "^8.4.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"concurrently": "^9.1.0"
|
"concurrently": "^9.1.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
web/server/agent/index.ts
Normal file
39
web/server/agent/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { tools, ToolDefinition } from './tools';
|
||||||
|
|
||||||
|
export class VideoAgent {
|
||||||
|
private tools: ToolDefinition[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.tools = tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
getToolDefinitions() {
|
||||||
|
return this.tools.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
parameters: t.parameters,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeTool(name: string, params: Record<string, unknown>): Promise<string> {
|
||||||
|
const tool = this.tools.find((t) => t.name === name);
|
||||||
|
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
||||||
|
return tool.execute(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSystemPrompt(accountContext?: string): string {
|
||||||
|
return `你是美图 Agent,帮助用户进行短视频创作。
|
||||||
|
|
||||||
|
可用账号:${accountContext || '暂无'}
|
||||||
|
|
||||||
|
你可以:
|
||||||
|
1. 帮用户创建新账号
|
||||||
|
2. 查看和管理已有账号
|
||||||
|
3. 执行视频创作 pipeline(分镜→生图→生视频→TTS→成片)
|
||||||
|
4. 管理提示词模板
|
||||||
|
|
||||||
|
用户想创作视频时,一步步引导他们完成流程。`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoAgent = new VideoAgent();
|
||||||
101
web/server/agent/tools.ts
Normal file
101
web/server/agent/tools.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { spawn, execSync } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
||||||
|
const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js');
|
||||||
|
|
||||||
|
export interface ToolDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
execute: (params: Record<string, unknown>) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: ToolDefinition[] = [
|
||||||
|
{
|
||||||
|
name: 'list_accounts',
|
||||||
|
description: '列出所有可用账号',
|
||||||
|
parameters: { type: 'object', properties: {}, required: [] },
|
||||||
|
execute: async () => {
|
||||||
|
const accountsDir = path.join(PROJECT_ROOT, 'accounts');
|
||||||
|
const dirs = fs.readdirSync(accountsDir, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
|
||||||
|
.map((d) => {
|
||||||
|
const configPath = path.join(accountsDir, d.name, 'account.json');
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
return `${d.name} - ${cfg.description || '无描述'} (${cfg.imageModel}/${cfg.videoModel})`;
|
||||||
|
}
|
||||||
|
return d.name;
|
||||||
|
});
|
||||||
|
return dirs.join('\n');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'run_pipeline_phase',
|
||||||
|
description: '执行 pipeline 阶段 (images/upload/videos/tts/assemble)',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
manifest: { type: 'string', description: 'manifest.json 绝对路径' },
|
||||||
|
phase: { type: 'string', description: '阶段名: images, upload, videos, tts, assemble' },
|
||||||
|
},
|
||||||
|
required: ['manifest', 'phase'],
|
||||||
|
},
|
||||||
|
execute: async (params) => {
|
||||||
|
const { manifest, phase } = params as { manifest: string; phase: string };
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn('node', [PIPELINE_SCRIPT, 'run', '--manifest', manifest, '--phase', phase], {
|
||||||
|
cwd: PROJECT_ROOT,
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
proc.stdout.on('data', (d: Buffer) => { output += d.toString(); });
|
||||||
|
proc.stderr.on('data', (d: Buffer) => { output += d.toString(); });
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
code === 0 ? resolve(output) : reject(new Error(`Pipeline exit code ${code}: ${output}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pipeline_status',
|
||||||
|
description: '查看 pipeline 进度',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
manifest: { type: 'string', description: 'manifest.json 绝对路径' },
|
||||||
|
},
|
||||||
|
required: ['manifest'],
|
||||||
|
},
|
||||||
|
execute: async (params) => {
|
||||||
|
const { manifest } = params as { manifest: string };
|
||||||
|
const result = execSync(`node "${PIPELINE_SCRIPT}" status --manifest "${manifest}"`, {
|
||||||
|
cwd: PROJECT_ROOT, encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'create_account',
|
||||||
|
description: '创建新账号',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: '账号 ID' },
|
||||||
|
name: { type: 'string', description: '账号名称' },
|
||||||
|
desc: { type: 'string', description: '账号描述' },
|
||||||
|
},
|
||||||
|
required: ['id', 'name'],
|
||||||
|
},
|
||||||
|
execute: async (params) => {
|
||||||
|
const { id, name, desc } = params as { id: string; name: string; desc?: string };
|
||||||
|
const result = execSync(
|
||||||
|
`node "${PIPELINE_SCRIPT}" create-account --id "${id}" --name "${name}" --desc "${desc || ''}" --video-model veo3-fast`,
|
||||||
|
{ cwd: PROJECT_ROOT, encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
28
web/server/db/index.ts
Normal file
28
web/server/db/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { SCHEMA_SQL } from './schema';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const DB_PATH = path.resolve(__dirname, '..', '..', 'data', 'meitu-agent.db');
|
||||||
|
|
||||||
|
let db: Database.Database;
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (!db) {
|
||||||
|
const dir = path.dirname(DB_PATH);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initDb(): void {
|
||||||
|
const d = getDb();
|
||||||
|
d.exec(SCHEMA_SQL);
|
||||||
|
}
|
||||||
49
web/server/db/schema.ts
Normal file
49
web/server/db/schema.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export const SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
account_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
tool_calls TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT,
|
||||||
|
manifest_path TEXT,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('image', 'video')),
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
shot_index INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
manifest_path TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','done','failed')),
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS configs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_account ON assets(account_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_manifest ON pipeline_runs(manifest_path);
|
||||||
|
`;
|
||||||
32
web/server/index.ts
Normal file
32
web/server/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { initDb } from './db';
|
||||||
|
import { accountsRouter } from './routes/accounts';
|
||||||
|
import { promptsRouter } from './routes/prompts';
|
||||||
|
import { pipelineRouter } from './routes/pipeline';
|
||||||
|
import { assetsRouter } from './routes/assets';
|
||||||
|
import { configsRouter } from './routes/configs';
|
||||||
|
import { handleChat } from './ws/chat';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
|
app.use('/api/accounts', accountsRouter);
|
||||||
|
app.use('/api/prompts', promptsRouter);
|
||||||
|
app.use('/api/pipeline', pipelineRouter);
|
||||||
|
app.use('/api/assets', assetsRouter);
|
||||||
|
app.use('/api/configs', configsRouter);
|
||||||
|
|
||||||
|
wss.on('connection', handleChat);
|
||||||
|
|
||||||
|
const PORT = 3001;
|
||||||
|
initDb();
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
85
web/server/routes/accounts.ts
Normal file
85
web/server/routes/accounts.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const ACCOUNTS_DIR = path.resolve(__dirname, '..', '..', '..', 'accounts');
|
||||||
|
|
||||||
|
export const accountsRouter = Router();
|
||||||
|
|
||||||
|
function readAccountJson(id: string): Record<string, unknown> | null {
|
||||||
|
const p = path.join(ACCOUNTS_DIR, id, 'account.json');
|
||||||
|
if (!fs.existsSync(p)) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAccountJson(id: string, data: Record<string, unknown>): void {
|
||||||
|
const dir = path.join(ACCOUNTS_DIR, id);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'account.json'), JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// List
|
||||||
|
accountsRouter.get('/', (_req, res) => {
|
||||||
|
const dirs = fs.readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
|
||||||
|
.map((d) => d.name);
|
||||||
|
|
||||||
|
const accounts = dirs
|
||||||
|
.map((id) => readAccountJson(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
res.json(accounts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get
|
||||||
|
accountsRouter.get('/:id', (req, res) => {
|
||||||
|
const data = readAccountJson(req.params.id);
|
||||||
|
if (!data) return res.status(404).json({ error: 'Account not found' });
|
||||||
|
res.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create
|
||||||
|
accountsRouter.post('/', (req, res) => {
|
||||||
|
const { id, ...rest } = req.body;
|
||||||
|
if (!id) return res.status(400).json({ error: 'id is required' });
|
||||||
|
|
||||||
|
const dir = path.join(ACCOUNTS_DIR, id);
|
||||||
|
if (fs.existsSync(dir)) return res.status(409).json({ error: 'Account already exists' });
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
id,
|
||||||
|
name: rest.name || id,
|
||||||
|
description: rest.description || '',
|
||||||
|
defaultFormat: rest.defaultFormat || '9:16',
|
||||||
|
imageModel: rest.imageModel || 'gemini',
|
||||||
|
videoModel: rest.videoModel || 'veo3-fast',
|
||||||
|
batchSize: rest.batchSize || 30,
|
||||||
|
ttsVoice: rest.ttsVoice || '',
|
||||||
|
ttsInstruction: rest.ttsInstruction || '',
|
||||||
|
storyboardPrompt: rest.storyboardPrompt || 'prompts/分镜.md',
|
||||||
|
imageStylePrompt: rest.imageStylePrompt || 'prompts/图片提示词.md',
|
||||||
|
videoStylePrompt: rest.videoStylePrompt || 'prompts/视频提示词.md',
|
||||||
|
references: rest.references || [],
|
||||||
|
capcut: rest.capcut || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
writeAccountJson(id, data);
|
||||||
|
res.status(201).json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update
|
||||||
|
accountsRouter.put('/:id', (req, res) => {
|
||||||
|
const existing = readAccountJson(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Account not found' });
|
||||||
|
|
||||||
|
const merged = { ...existing, ...req.body, id: req.params.id };
|
||||||
|
writeAccountJson(req.params.id, merged);
|
||||||
|
res.json(merged);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
accountsRouter.delete('/:id', (req, res) => {
|
||||||
|
const dir = path.join(ACCOUNTS_DIR, req.params.id);
|
||||||
|
if (!fs.existsSync(dir)) return res.status(404).json({ error: 'Account not found' });
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
117
web/server/routes/assets.ts
Normal file
117
web/server/routes/assets.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
|
||||||
|
export const assetsRouter = Router();
|
||||||
|
|
||||||
|
// List assets with optional filters
|
||||||
|
assetsRouter.get('/', (req, res) => {
|
||||||
|
const { accountId, type } = req.query;
|
||||||
|
let sql = 'SELECT * FROM assets WHERE 1=1';
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (accountId) { sql += ' AND account_id = ?'; params.push(accountId); }
|
||||||
|
if (type) { sql += ' AND type = ?'; params.push(type); }
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC LIMIT 200';
|
||||||
|
const rows = getDb().prepare(sql).all(...params);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete asset and its file
|
||||||
|
assetsRouter.delete('/:id', (req, res) => {
|
||||||
|
const asset = getDb().prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id) as {
|
||||||
|
file_path: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
|
||||||
|
const filePath = path.join(PROJECT_ROOT, asset.file_path);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
|
||||||
|
getDb().prepare('DELETE FROM assets WHERE id = ?').run(req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scan output directories and index assets
|
||||||
|
assetsRouter.post('/scan', (_req, res) => {
|
||||||
|
const outputDir = path.join(PROJECT_ROOT, 'output');
|
||||||
|
if (!fs.existsSync(outputDir)) return res.json({ indexed: 0 });
|
||||||
|
|
||||||
|
const dirs = fs.readdirSync(outputDir, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory());
|
||||||
|
|
||||||
|
let indexed = 0;
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const manifestPath = path.join(outputDir, dir.name, 'manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) continue;
|
||||||
|
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||||
|
} catch { continue; }
|
||||||
|
|
||||||
|
const accountId = manifest.account?.id || '';
|
||||||
|
|
||||||
|
// Scan images
|
||||||
|
const imagesDir = path.join(outputDir, dir.name, 'images');
|
||||||
|
if (fs.existsSync(imagesDir)) {
|
||||||
|
const files = fs.readdirSync(imagesDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (!/\.(jpe?g|png|webp)$/i.test(file)) continue;
|
||||||
|
const id = randomUUID();
|
||||||
|
const relPath = path.relative(PROJECT_ROOT, path.join(imagesDir, file));
|
||||||
|
const match = file.match(/scene_(\d+)/);
|
||||||
|
const shotIndex = match ? parseInt(match[1]) : null;
|
||||||
|
|
||||||
|
const exists = getDb().prepare('SELECT id FROM assets WHERE file_path = ?').get(relPath);
|
||||||
|
if (exists) continue;
|
||||||
|
|
||||||
|
getDb().prepare(
|
||||||
|
'INSERT INTO assets (id, account_id, manifest_path, type, file_path, shot_index) VALUES (?,?,?,?,?,?)'
|
||||||
|
).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'image', relPath, shotIndex);
|
||||||
|
indexed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan videos
|
||||||
|
const videosDir = path.join(outputDir, dir.name, 'videos');
|
||||||
|
if (fs.existsSync(videosDir)) {
|
||||||
|
const files = fs.readdirSync(videosDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (!/\.mp4$/i.test(file)) continue;
|
||||||
|
const id = randomUUID();
|
||||||
|
const relPath = path.relative(PROJECT_ROOT, path.join(videosDir, file));
|
||||||
|
const match = file.match(/scene_(\d+)/);
|
||||||
|
const shotIndex = match ? parseInt(match[1]) : null;
|
||||||
|
|
||||||
|
const exists = getDb().prepare('SELECT id FROM assets WHERE file_path = ?').get(relPath);
|
||||||
|
if (exists) continue;
|
||||||
|
|
||||||
|
getDb().prepare(
|
||||||
|
'INSERT INTO assets (id, account_id, manifest_path, type, file_path, shot_index) VALUES (?,?,?,?,?,?)'
|
||||||
|
).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'video', relPath, shotIndex);
|
||||||
|
indexed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ indexed });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve asset files
|
||||||
|
assetsRouter.get('/file', (req, res) => {
|
||||||
|
const filePath = req.query.path as string;
|
||||||
|
if (!filePath) return res.status(400).send('Missing path');
|
||||||
|
const fullPath = path.resolve(PROJECT_ROOT, filePath);
|
||||||
|
if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
|
||||||
|
if (!fs.existsSync(fullPath)) return res.status(404).send('Not found');
|
||||||
|
res.sendFile(fullPath);
|
||||||
|
});
|
||||||
26
web/server/routes/configs.ts
Normal file
26
web/server/routes/configs.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
|
||||||
|
export const configsRouter = Router();
|
||||||
|
|
||||||
|
configsRouter.get('/', (_req, res) => {
|
||||||
|
const rows = getDb().prepare('SELECT * FROM configs ORDER BY key').all();
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
configsRouter.get('/:key', (req, res) => {
|
||||||
|
const row = getDb().prepare('SELECT * FROM configs WHERE key = ?').get(req.params.key);
|
||||||
|
if (!row) return res.status(404).json({ error: 'Config not found' });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
configsRouter.put('/:key', (req, res) => {
|
||||||
|
const { value } = req.body;
|
||||||
|
getDb().prepare(`
|
||||||
|
INSERT INTO configs (id, key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
`).run(randomUUID(), req.params.key, JSON.stringify(value));
|
||||||
|
res.json({ key: req.params.key, ok: true });
|
||||||
|
});
|
||||||
73
web/server/routes/pipeline.ts
Normal file
73
web/server/routes/pipeline.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js');
|
||||||
|
|
||||||
|
export const pipelineRouter = Router();
|
||||||
|
|
||||||
|
pipelineRouter.get('/conversations', (req, res) => {
|
||||||
|
const { search } = req.query;
|
||||||
|
let sql = 'SELECT * FROM conversations';
|
||||||
|
const params: string[] = [];
|
||||||
|
if (search) {
|
||||||
|
sql += ' WHERE title LIKE ?';
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY updated_at DESC LIMIT 100';
|
||||||
|
const rows = getDb().prepare(sql).all(...params);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
pipelineRouter.get('/conversations/:id/messages', (req, res) => {
|
||||||
|
const rows = getDb().prepare(
|
||||||
|
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at'
|
||||||
|
).all(req.params.id);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
pipelineRouter.delete('/conversations/:id', (req, res) => {
|
||||||
|
getDb().prepare('DELETE FROM conversations WHERE id = ?').run(req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rename conversation
|
||||||
|
pipelineRouter.patch('/conversations/:id', (req, res) => {
|
||||||
|
const { title } = req.body;
|
||||||
|
if (!title) return res.status(400).json({ error: 'title required' });
|
||||||
|
getDb().prepare(
|
||||||
|
'UPDATE conversations SET title = ?, updated_at = datetime(\'now\') WHERE id = ?'
|
||||||
|
).run(title, req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
pipelineRouter.get('/status', (req, res) => {
|
||||||
|
const { manifest } = req.query;
|
||||||
|
if (!manifest) return res.status(400).json({ error: 'manifest path required' });
|
||||||
|
try {
|
||||||
|
const result = execSync(`node "${PIPELINE_SCRIPT}" status --manifest "${manifest}"`, {
|
||||||
|
cwd: PROJECT_ROOT, encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
res.json({ output: result });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pipelineRouter.post('/resume', (req, res) => {
|
||||||
|
const { manifest } = req.body;
|
||||||
|
if (!manifest) return res.status(400).json({ error: 'manifest path required' });
|
||||||
|
try {
|
||||||
|
const result = execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifest}" --resume`, {
|
||||||
|
cwd: PROJECT_ROOT, encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
res.json({ output: result });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
45
web/server/routes/prompts.ts
Normal file
45
web/server/routes/prompts.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
|
||||||
|
export const promptsRouter = Router();
|
||||||
|
|
||||||
|
const PROMPT_FILES: Record<string, string> = {
|
||||||
|
storyboard: 'prompts/分镜.md',
|
||||||
|
image: 'prompts/图片提示词.md',
|
||||||
|
video: 'prompts/视频提示词.md',
|
||||||
|
};
|
||||||
|
|
||||||
|
promptsRouter.get('/:accountId/:type', (req, res) => {
|
||||||
|
const { accountId, type } = req.params;
|
||||||
|
const relPath = PROMPT_FILES[type];
|
||||||
|
if (!relPath) return res.status(400).json({ error: 'Unknown type: ' + type });
|
||||||
|
|
||||||
|
const fullPath = path.join(PROJECT_ROOT, 'accounts', accountId, relPath);
|
||||||
|
if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
|
res.json({ path: relPath, content: fs.readFileSync(fullPath, 'utf-8') });
|
||||||
|
});
|
||||||
|
|
||||||
|
promptsRouter.put('/:accountId/:type', (req, res) => {
|
||||||
|
const { accountId, type } = req.params;
|
||||||
|
const { content } = req.body;
|
||||||
|
const relPath = PROMPT_FILES[type];
|
||||||
|
if (!relPath) return res.status(400).json({ error: 'Unknown type: ' + type });
|
||||||
|
|
||||||
|
const fullPath = path.join(PROJECT_ROOT, 'accounts', accountId, relPath);
|
||||||
|
const dir = path.dirname(fullPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(fullPath, content, 'utf-8');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
promptsRouter.get('/:accountId', (req, res) => {
|
||||||
|
res.json(Object.keys(PROMPT_FILES).map((type) => ({
|
||||||
|
type,
|
||||||
|
path: PROMPT_FILES[type],
|
||||||
|
})));
|
||||||
|
});
|
||||||
61
web/server/ws/chat.ts
Normal file
61
web/server/ws/chat.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
|
||||||
|
export function handleChat(ws: WebSocket) {
|
||||||
|
let conversationId: string | null = null;
|
||||||
|
|
||||||
|
ws.on('message', async (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
|
||||||
|
if (msg.type === 'init') {
|
||||||
|
conversationId = msg.conversationId || randomUUID();
|
||||||
|
const history = getDb().prepare(
|
||||||
|
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at'
|
||||||
|
).all(conversationId);
|
||||||
|
ws.send(JSON.stringify({ type: 'history', data: { conversationId, messages: history } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'chat') {
|
||||||
|
const { content } = msg;
|
||||||
|
const msgId = randomUUID();
|
||||||
|
|
||||||
|
getDb().prepare(
|
||||||
|
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(msgId, conversationId, 'user', content);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'message', data: { id: msgId, role: 'user', content } }));
|
||||||
|
|
||||||
|
// Assistant echo (placeholder until LLM integration in Task 3.3)
|
||||||
|
const assistantId = randomUUID();
|
||||||
|
const assistantContent = `收到你的消息:「${content}」。Agent 引擎正在启动中...`;
|
||||||
|
|
||||||
|
getDb().prepare(
|
||||||
|
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(assistantId, conversationId, 'assistant', assistantContent);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
data: { id: assistantId, role: 'assistant', content: assistantContent },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'create_conversation') {
|
||||||
|
const { title, accountId } = msg;
|
||||||
|
conversationId = randomUUID();
|
||||||
|
getDb().prepare(
|
||||||
|
'INSERT INTO conversations (id, title, account_id) VALUES (?, ?, ?)'
|
||||||
|
).run(conversationId, title || '新对话', accountId || null);
|
||||||
|
ws.send(JSON.stringify({ type: 'conversation_created', data: { id: conversationId, title } }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', data: { message: (e as Error).message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
// cleanup if needed
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user