feat(web): add markdown syntax highlighting, slash commands, stop button, quote/reply

- PromptEditor with Prism.js syntax highlighting
- Slash commands (/run, /status, /images, /list, /help) in chat input
- Stop button to cancel ongoing generation
- Quote/reply and regenerate/continue actions in chat
- MiddlePanel with conversation timestamps and preview
- Pipeline progress in chat view
- Fix all remaining dark theme classes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 04:09:00 +08:00
parent f43a640e64
commit e850613972
12 changed files with 375 additions and 213 deletions

View File

@@ -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",

View File

@@ -12,14 +12,17 @@
"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",
@@ -1246,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",
@@ -2336,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",
@@ -2439,6 +2458,16 @@
"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",

View File

@@ -12,14 +12,17 @@
"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",

View File

@@ -1,11 +1,34 @@
import { useState, useRef } from 'react';
import { Send } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Send, Terminal, Image, Play, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
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);
useEffect(() => {
if (input.startsWith('/')) {
setShowCmds(true);
setCmdIdx(0);
} else {
setShowCmds(false);
}
}, [input]);
const matchingCmds = input.startsWith('/')
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
: [];
const handleSend = () => {
if (!input.trim()) return;
onSend(input.trim());
@@ -14,6 +37,18 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCmds && matchingCmds.length > 0) {
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
const cmd = matchingCmds[cmdIdx % matchingCmds.length].cmd;
setInput(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();
@@ -21,21 +56,43 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
};
return (
<div className="p-4 border-t border-zinc-200 bg-white">
<div className="flex items-end gap-2 bg-zinc-50 rounded-xl border border-zinc-200 px-4 py-2.5 focus-within:border-indigo-300 transition-colors">
<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"
disabled={disabled}
/>
<Button size="icon" variant="ghost" className="h-8 w-8 text-zinc-400 hover:text-indigo-600" onClick={handleSend} disabled={disabled}>
<Send size={16} />
</Button>
<div className="relative">
{showCmds && matchingCmds.length > 0 && (
<div className="mx-4 mb-0.5 bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden">
{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 px-3 py-1.5 text-sm transition-colors ${
i === (cmdIdx % matchingCmds.length) ? 'bg-indigo-50 text-indigo-700' : 'text-zinc-600 hover:bg-zinc-50'
}`}
>
<Icon size={14} />
<span className="font-mono font-medium">{c.cmd}</span>
<span className="text-zinc-400">{c.desc}</span>
</button>
);
})}
</div>
)}
<div className="p-4 border-t border-zinc-200 bg-white">
<div className="flex items-end gap-2 bg-zinc-50 rounded-xl border border-zinc-200 px-4 py-2.5 focus-within:border-indigo-300 transition-colors">
<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"
disabled={disabled}
/>
<Button size="icon" variant="ghost" className="h-8 w-8 text-zinc-400 hover:text-indigo-600" onClick={handleSend} disabled={disabled}>
<Send size={16} />
</Button>
</div>
</div>
</div>
);

View File

@@ -1,30 +1,76 @@
import { cn } from '@/lib/utils';
import { renderMarkdown } from '@/lib/markdown';
import { RefreshCw, ArrowRight, Quote } 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-xl px-4 py-2.5',
isUser
? 'bg-indigo-600 text-white'
: 'bg-white text-zinc-700 border border-zinc-200 shadow-sm'
)}
>
{isEmpty ? (
<span className="text-zinc-400 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) }}
/>
<div className={cn('mb-4 flex group', isUser ? 'justify-end' : 'justify-start')}>
<div className="max-w-[80%]">
<div
className={cn(
'rounded-xl px-4 py-2.5',
isUser
? 'bg-indigo-600 text-white'
: 'bg-white text-zinc-700 border border-zinc-200 shadow-sm'
)}
>
{isEmpty ? (
<span className="text-zinc-400 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) }}
/>
)}
</div>
{/* Action buttons - only for assistant messages */}
{!isUser && !isEmpty && (
<div className="flex gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onQuote && (
<button
onClick={() => onQuote(message.content)}
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors"
title="引用"
>
<Quote size={10} />
</button>
)}
{onRegenerate && (
<button
onClick={() => onRegenerate(message.id)}
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors"
title="重新生成"
>
<RefreshCw size={10} />
</button>
)}
{onContinue && (
<button
onClick={() => onContinue(message.id)}
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors"
title="继续"
>
<ArrowRight size={10} />
</button>
)}
</div>
)}
</div>
</div>

View File

@@ -4,30 +4,28 @@ import { useAppStore } from '@/store';
import { useChat } from '@/hooks/useChat';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { PipelineProgress } from './PipelineProgress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { RefreshCw, Loader2 } from 'lucide-react';
import { RefreshCw, Loader2, StopCircle, X } from 'lucide-react';
import { api } from '@/lib/api';
import type { Account } from '@/types';
import type { Account, Message } from '@/types';
export function ChatView() {
const { conversationId } = useParams<{ conversationId?: string }>();
const navigate = useNavigate();
const { setConversations, selectedAccountId, setSelectedAccountId } = useAppStore();
const { messages, connected, thinking, toolStatus, send, createConversation } = useChat(conversationId || null);
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(() => { 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(() => {
@@ -40,13 +38,11 @@ export function ChatView() {
}
}, [messages]);
// Inject account context when account is selected
useEffect(() => {
if (selectedAccountId && conversationId && connected && messages.length === 0) {
const account = accounts.find((a) => a.id === selectedAccountId);
if (account) {
const ctx = `已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`;
send(ctx);
send(`已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`);
}
}
}, [selectedAccountId, conversationId, connected]);
@@ -54,34 +50,48 @@ export function ChatView() {
const handleNewConversation = useCallback(() => {
if (creatingRef.current) return;
creatingRef.current = true;
createConversation('新对话', selectedAccountId || undefined);
setTimeout(() => {
fetch('/api/pipeline/conversations')
.then((r) => r.json())
.then((list) => {
setConversations(list);
if (list.length > 0) {
navigate(`/chat/${list[0].id}`);
}
creatingRef.current = false;
});
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, setConversations, navigate]);
}, [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 }),
});
};
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">
@@ -96,9 +106,7 @@ export function ChatView() {
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>
))}
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
)}
<button
@@ -123,31 +131,49 @@ export function ChatView() {
</div>
<select
value={selectedAccountId || ''}
onChange={(e) => {
setSelectedAccountId(e.target.value || null);
}}
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>
))}
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
{manifestPath && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
<RefreshCw size={12} className="mr-1" />
</Button>
)}
<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>
</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 && (
{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 || '思考中...'}
@@ -155,7 +181,14 @@ export function ChatView() {
)}
</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>
);
}

View File

@@ -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-200 bg-zinc-50 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-indigo-50 text-indigo-700 font-medium' : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50'
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-white text-zinc-700 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>
);

View File

@@ -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 };
}

View File

@@ -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));
}

Binary file not shown.

Binary file not shown.

View File

@@ -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 (?, ?, ?, ?)'