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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
12
web/client/src/lib/markdown.ts
Normal file
12
web/client/src/lib/markdown.ts
Normal 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);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./client/src/**/*.{ts,tsx}'],
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user