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