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:
@@ -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",
|
||||
|
||||
29
web/client/package-lock.json
generated
29
web/client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
@@ -2,21 +2,10 @@ import { WebSocket } from 'ws';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getDb } from '../db';
|
||||
import { videoAgent } from '../agent';
|
||||
import type { MessageParam, ToolUseBlock, TextBlock } from '@anthropic-ai/sdk/resources/messages.mjs';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
interface ChatMsg {
|
||||
type: string;
|
||||
conversationId?: string;
|
||||
content?: string;
|
||||
title?: string;
|
||||
accountId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
conversation_id?: string;
|
||||
role?: string;
|
||||
tool_calls?: string;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
}
|
||||
type MessageParam = Anthropic.MessageParam;
|
||||
type ContentBlock = Anthropic.ContentBlock;
|
||||
|
||||
interface DbMessage {
|
||||
id: string;
|
||||
@@ -27,6 +16,10 @@ interface DbMessage {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function filterContent(blocks: ContentBlock[]): ContentBlock[] {
|
||||
return blocks.filter((b) => b.type === 'text' || b.type === 'tool_use');
|
||||
}
|
||||
|
||||
function dbToAnthropic(msg: DbMessage): MessageParam {
|
||||
if (msg.role === 'user') {
|
||||
return { role: 'user', content: msg.content };
|
||||
@@ -34,8 +27,8 @@ function dbToAnthropic(msg: DbMessage): MessageParam {
|
||||
if (msg.role === 'assistant') {
|
||||
if (msg.tool_calls) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg.tool_calls);
|
||||
return { role: 'assistant', content: parsed };
|
||||
const parsed = JSON.parse(msg.tool_calls) as ContentBlock[];
|
||||
return { role: 'assistant', content: filterContent(parsed) };
|
||||
} catch {
|
||||
return { role: 'assistant', content: msg.content };
|
||||
}
|
||||
@@ -47,7 +40,7 @@ function dbToAnthropic(msg: DbMessage): MessageParam {
|
||||
const { tool_use_id, content } = JSON.parse(msg.content);
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id, content }],
|
||||
content: [{ type: 'tool_result' as const, tool_use_id, content }],
|
||||
};
|
||||
} catch {
|
||||
return { role: 'user', content: msg.content };
|
||||
@@ -61,9 +54,8 @@ export function handleChat(ws: WebSocket) {
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
const msg: ChatMsg = JSON.parse(raw.toString());
|
||||
const msg = JSON.parse(raw.toString());
|
||||
|
||||
// --- Init: load conversation history ---
|
||||
if (msg.type === 'init') {
|
||||
conversationId = msg.conversationId || randomUUID();
|
||||
const history = getDb().prepare(
|
||||
@@ -73,7 +65,6 @@ export function handleChat(ws: WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Create conversation ---
|
||||
if (msg.type === 'create_conversation') {
|
||||
const { title, accountId } = msg;
|
||||
conversationId = randomUUID();
|
||||
@@ -84,9 +75,12 @@ export function handleChat(ws: WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Chat with LLM ---
|
||||
if (msg.type === 'chat') {
|
||||
await handleChatMessage(ws, conversationId!, msg.content!);
|
||||
if (!conversationId) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: '没有活跃对话,请先创建或选择一个对话' } }));
|
||||
return;
|
||||
}
|
||||
await handleChatMessage(ws, conversationId, msg.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebSocket error:', e);
|
||||
@@ -98,14 +92,12 @@ export function handleChat(ws: WebSocket) {
|
||||
}
|
||||
|
||||
async function handleChatMessage(ws: WebSocket, convId: string, content: string) {
|
||||
// 1. Save user message
|
||||
const userMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(userMsgId, convId, 'user', content);
|
||||
ws.send(JSON.stringify({ type: 'message', data: { id: userMsgId, role: 'user', content } }));
|
||||
|
||||
// Update conversation title if first message
|
||||
const msgCount = getDb().prepare(
|
||||
'SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?'
|
||||
).get(convId) as { count: number };
|
||||
@@ -114,18 +106,14 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
getDb().prepare('UPDATE conversations SET title = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
||||
.run(title, convId);
|
||||
}
|
||||
|
||||
// Update conversation timestamp
|
||||
getDb().prepare('UPDATE conversations SET updated_at = datetime(\'now\') WHERE id = ?').run(convId);
|
||||
|
||||
// 2. Build message history for Anthropic
|
||||
const history = getDb().prepare(
|
||||
'SELECT * FROM messages WHERE conversation_id = ? AND id != ? ORDER BY created_at'
|
||||
).all(convId, userMsgId) as DbMessage[];
|
||||
|
||||
const messages: MessageParam[] = history.map(dbToAnthropic);
|
||||
|
||||
// 3. Call LLM with tool loop
|
||||
const client = videoAgent.getClient();
|
||||
const model = videoAgent.getModel();
|
||||
const systemPrompt = videoAgent.getSystemPrompt();
|
||||
@@ -137,6 +125,7 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
let maxLoops = 10;
|
||||
|
||||
while (maxLoops-- > 0) {
|
||||
console.log(`[chat] Calling LLM, loop ${9 - maxLoops}, messages: ${currentMessages.length}`);
|
||||
const stream = client.messages.stream({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
@@ -145,87 +134,58 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
messages: currentMessages,
|
||||
});
|
||||
|
||||
let assistantContent = '';
|
||||
let toolUseBlocks: { id: string; name: string; input: Record<string, unknown> }[] = [];
|
||||
const assistantMsgId = randomUUID();
|
||||
|
||||
// Stream text
|
||||
ws.send(JSON.stringify({ type: 'message_start', data: { id: assistantMsgId } }));
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'content_block_delta') {
|
||||
if (event.delta.type === 'text_delta') {
|
||||
assistantContent += event.delta.text;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'text_delta',
|
||||
data: { id: assistantMsgId, text: event.delta.text },
|
||||
}));
|
||||
}
|
||||
if (event.delta.type === 'input_json_delta') {
|
||||
// Accumulating tool input — handled by SDK internally
|
||||
}
|
||||
}
|
||||
if (event.type === 'content_block_start') {
|
||||
if (event.content_block.type === 'tool_use') {
|
||||
toolUseBlocks.push({
|
||||
id: event.content_block.id,
|
||||
name: event.content_block.name,
|
||||
input: (event.content_block.input || {}) as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalMsg = await stream.finalMessage();
|
||||
ws.send(JSON.stringify({ type: 'message_end', data: { id: assistantMsgId } }));
|
||||
|
||||
// Extract tool uses from final message
|
||||
const toolUses: { id: string; name: string; input: Record<string, unknown> }[] = [];
|
||||
const textBlocks: string[] = [];
|
||||
const toolUses = finalMsg.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
|
||||
const textBlocks = finalMsg.content.filter((b): b is Anthropic.TextBlock => b.type === 'text');
|
||||
const finalText = textBlocks.map((b) => b.text).join('');
|
||||
|
||||
for (const block of finalMsg.content) {
|
||||
if (block.type === 'text') {
|
||||
textBlocks.push(block.text);
|
||||
}
|
||||
if (block.type === 'tool_use') {
|
||||
toolUses.push({ id: block.id, name: block.name, input: block.input as Record<string, unknown> });
|
||||
}
|
||||
}
|
||||
|
||||
// No tool calls — save assistant message and done
|
||||
// No tool calls — save and done
|
||||
if (toolUses.length === 0) {
|
||||
const finalText = textBlocks.join('');
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', finalText);
|
||||
console.log(`[chat] Done, response: ${finalText.slice(0, 80)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Has tool calls — save assistant message with tool_calls, execute tools, add results
|
||||
// Save assistant message with filtered content (no thinking blocks)
|
||||
const cleanContent = filterContent(finalMsg.content as ContentBlock[]);
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content, tool_calls) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(assistantMsgId, convId, 'assistant', textBlocks.join('') || '(调用工具)', JSON.stringify(finalMsg.content));
|
||||
).run(assistantMsgId, convId, 'assistant', finalText || '(调用工具)', JSON.stringify(cleanContent));
|
||||
|
||||
// Build assistant content blocks for Anthropic
|
||||
const assistantBlocks: (TextBlock | ToolUseBlock)[] = finalMsg.content
|
||||
.filter((b): b is TextBlock | ToolUseBlock => b.type === 'text' || b.type === 'tool_use');
|
||||
currentMessages.push({ role: 'assistant', content: cleanContent });
|
||||
|
||||
currentMessages.push({ role: 'assistant', content: assistantBlocks });
|
||||
|
||||
// Execute tools and send results
|
||||
const toolResults: { type: 'tool_result'; tool_use_id: string; content: string }[] = [];
|
||||
// Execute tools
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
|
||||
for (const tool of toolUses) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tool_start',
|
||||
data: { tool: tool.name, input: tool.input },
|
||||
}));
|
||||
console.log(`[chat] Executing tool: ${tool.name}`);
|
||||
|
||||
try {
|
||||
const result = await videoAgent.executeTool(tool.name, tool.input);
|
||||
const result = await videoAgent.executeTool(tool.name, tool.input as Record<string, unknown>);
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: tool.id, content: result });
|
||||
|
||||
// Save tool result to DB
|
||||
const toolMsgId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
@@ -251,17 +211,11 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool results to conversation
|
||||
currentMessages.push({
|
||||
role: 'user',
|
||||
content: toolResults,
|
||||
});
|
||||
|
||||
// Continue loop — LLM will process tool results and possibly call more tools or give final response
|
||||
currentMessages.push({ role: 'user', content: toolResults });
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
console.error('LLM error:', errMsg);
|
||||
console.error('[chat] LLM error:', errMsg);
|
||||
const errId = randomUUID();
|
||||
getDb().prepare(
|
||||
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
|
||||
|
||||
Reference in New Issue
Block a user