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() {
|
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