feat(web): add chat UI with WebSocket streaming and conversation persistence

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 02:41:01 +08:00
parent 10685ea866
commit 6e3f5d9415
6 changed files with 270 additions and 1 deletions

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

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

View File

@@ -1,3 +1,56 @@
import { useEffect } 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';
export function ChatView() {
return <div className="flex-1 flex items-center justify-center text-zinc-500"></div>;
const { activeConversationId, conversations, setConversations } = useAppStore();
const { messages, connected, send, createConversation } = useChat(activeConversationId);
useEffect(() => {
fetch('/api/pipeline/conversations')
.then((r) => r.json())
.then(setConversations)
.catch(() => {});
}, [messages]);
const handleNewConversation = () => {
createConversation('新对话');
setTimeout(() => {
fetch('/api/pipeline/conversations')
.then((r) => r.json())
.then(setConversations);
}, 300);
};
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 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>
<ScrollArea className="flex-1 px-4 py-4">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
</ScrollArea>
<ChatInput onSend={send} />
</div>
);
}

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

View 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();

View File

@@ -0,0 +1,55 @@
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 rows = getDb().prepare('SELECT * FROM conversations ORDER BY updated_at DESC').all();
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();
});
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 });
}
});