feat(web): integrate Claude LLM streaming with markdown rendering

- Add Anthropic SDK with DeepSeek-compatible API config
- Streaming tool-use loop in WebSocket chat handler
- GitHub-style markdown rendering with markdown-it
- Tool status indicators and thinking states in chat UI
- Fix Tailwind content path and CSS border utility

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 03:22:15 +08:00
parent d6b18fb7dc
commit 001dbde9f4
15 changed files with 759 additions and 95 deletions

View File

@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function ChatInput({ onSend }: { onSend: (content: string) => void }) {
export function ChatInput({ onSend, disabled }: { onSend: (content: string) => void; disabled?: boolean }) {
const [input, setInput] = useState('');
const ref = useRef<HTMLTextAreaElement>(null);
@@ -29,10 +29,11 @@ export function ChatInput({ onSend }: { onSend: (content: string) => void }) {
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder="输入指令..."
placeholder={disabled ? '等待回复中...' : '输入指令...'}
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-600"
disabled={disabled}
/>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={handleSend}>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={handleSend} disabled={disabled}>
<Send size={14} />
</Button>
</div>

View File

@@ -1,18 +1,31 @@
import { cn } from '@/lib/utils';
import { renderMarkdown } from '@/lib/markdown';
import type { Message } from '@/types';
export function ChatMessage({ message }: { message: Message }) {
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-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'
'max-w-[80%] rounded-lg px-4 py-2.5',
isUser
? 'bg-zinc-800 text-zinc-100'
: 'bg-zinc-900 text-zinc-300 border border-zinc-800'
)}
>
{message.content}
{isEmpty ? (
<span className="text-zinc-600 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>
</div>
);

View File

@@ -3,13 +3,14 @@ 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 } from 'lucide-react';
import { RefreshCw, Loader2 } from 'lucide-react';
export function ChatView() {
const { activeConversationId, conversations, setConversations, selectedAccountId } = useAppStore();
const { messages, connected, send, createConversation } = useChat(activeConversationId);
const { activeConversationId, setConversations, selectedAccountId } = useAppStore();
const { messages, connected, thinking, toolStatus, send, createConversation } = useChat(activeConversationId);
const [manifestPath, setManifestPath] = useState<string | null>(null);
useEffect(() => {
@@ -19,7 +20,6 @@ export function ChatView() {
.catch(() => {});
}, [messages]);
// Check for associated manifest in messages
useEffect(() => {
const toolMsgs = messages.filter((m) => m.role === 'tool');
if (toolMsgs.length > 0) {
@@ -80,12 +80,21 @@ export function ChatView() {
</Button>
)}
</div>
<ScrollArea className="flex-1 px-4 py-4">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
{thinking && (
<div className="flex items-center gap-2 text-zinc-500 text-sm py-2">
<Loader2 size={14} className="animate-spin" />
{toolStatus || '思考中...'}
</div>
)}
</ScrollArea>
<ChatInput onSend={send} />
<ChatInput onSend={send} disabled={thinking} />
</div>
);
}

View File

@@ -2,22 +2,75 @@ 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;
}
export function useChat(conversationId: string | null) {
const [messages, setMessages] = useState<Message[]>([]);
const [connected, setConnected] = useState(false);
const [thinking, setThinking] = useState(false);
const [toolStatus, setToolStatus] = useState<string | null>(null);
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]);
});
// Streaming handlers
chatSocket.on('status', (data) => {
if (data.status === 'thinking') setThinking(true);
});
chatSocket.on('message_start', (data) => {
setThinking(false);
// Add placeholder for streaming
setMessages((prev) => [...prev, {
id: data.id as string,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
conversation_id: '',
} as Message]);
});
chatSocket.on('text_delta', (data) => {
setMessages((prev) => prev.map((m) =>
m.id === data.id ? { ...m, content: m.content + (data.text as string) } : m
));
});
chatSocket.on('message_end', () => {
setThinking(false);
});
chatSocket.on('tool_start', (data) => {
setToolStatus(`正在执行: ${data.tool}...`);
});
chatSocket.on('tool_result', () => {
setToolStatus(null);
setThinking(true);
});
chatSocket.on('tool_error', (data) => {
setToolStatus(`工具执行失败: ${data.tool}`);
setTimeout(() => setToolStatus(null), 3000);
});
return () => {
chatSocket.disconnect();
};
@@ -42,5 +95,5 @@ export function useChat(conversationId: string | null) {
chatSocket.send('create_conversation', { title, accountId });
}, []);
return { messages, connected, send, createConversation };
return { messages, connected, thinking, toolStatus, send, createConversation };
}

View File

@@ -4,9 +4,86 @@
@layer base {
* {
@apply border-border;
@apply border-zinc-800;
}
body {
@apply bg-zinc-950 text-zinc-50;
}
}
/* GitHub-style Markdown */
@layer components {
.markdown-body {
color-scheme: dark;
}
.markdown-body h1 { font-size: 1.5em; font-weight: 600; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid #30363d; }
.markdown-body h2 { font-size: 1.25em; font-weight: 600; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid #30363d; }
.markdown-body h3 { font-size: 1.1em; font-weight: 600; margin: 1em 0 0.25em; }
.markdown-body h4 { font-size: 1em; font-weight: 600; margin: 1em 0 0.25em; }
.markdown-body p { margin: 0 0 0.75em; }
.markdown-body p:last-child { margin-bottom: 0; }
.markdown-body ul, .markdown-body ol { padding-left: 1.5em; margin: 0 0 0.75em; }
.markdown-body li { margin: 0.15em 0; }
.markdown-body li > p { margin: 0; }
.markdown-body ul { list-style-type: disc; }
.markdown-body ol { list-style-type: decimal; }
.markdown-body ul ul { list-style-type: circle; }
.markdown-body ul ul ul { list-style-type: square; }
.markdown-body blockquote {
margin: 0 0 0.75em;
padding: 0 1em;
color: #8b949e;
border-left: 3px solid #30363d;
}
.markdown-body code {
background: rgba(110,118,129,0.2);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.875em;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.markdown-body pre {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
margin: 0 0 0.75em;
overflow-x: auto;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.8125em;
line-height: 1.5;
color: #e6edf3;
}
.markdown-body hr {
border: 0;
height: 1px;
background: #30363d;
margin: 1em 0;
}
.markdown-body a {
color: #58a6ff;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body strong { font-weight: 600; }
.markdown-body table {
border-collapse: collapse;
margin: 0 0 0.75em;
width: 100%;
}
.markdown-body th, .markdown-body td {
border: 1px solid #30363d;
padding: 6px 13px;
text-align: left;
}
.markdown-body th {
font-weight: 600;
background: rgba(110,118,129,0.1);
}
.markdown-body img { max-width: 100%; border-radius: 6px; }
}

View File

@@ -0,0 +1,12 @@
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
});
export function renderMarkdown(text: string): string {
return md.render(text);
}

View File

@@ -1,7 +1,7 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./client/src/**/*.{ts,tsx}'],
content: ['./src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {