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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
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();
|
||||
55
web/server/routes/pipeline.ts
Normal file
55
web/server/routes/pipeline.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user