feat: 添加 Web 前端及服务端 SSE 流式支持,扩展多模型兼容

后端:
  - server: 实现完整的 HTTP 会话管理(CRUD)+ SSE 事件流推送,
    支持双通道架构(POST 发消息 + GET SSE 接收流式响应)
  - runtime: ContentBlock 新增 Thinking / RedactedThinking 变体,
    支持思考过程和已编辑思考的序列化/反序列化
  - api: 注册 GLM 系列模型(glm-4/5 等)到模型注册表,
    扩展 XAI/OpenAI 兼容提供商的请求构建逻辑

  前端:
  - 基于 Ant Design X 构建完整聊天界面:Bubble.List 消息列表、
    Sender 输入框、Conversations 会话管理、Think 思考过程折叠、
    ThoughtChain 工具调用链展示
  - XMarkdown 集成:代码高亮、Mermaid 图表、LaTeX 公式、
    自定义脚注、流式渲染(incomplete 占位符)
  - SSE Hook 对接服务端事件流,手动管理 AssistantBuffer 累积 delta
  - 深色/浅色主题切换,会话侧边栏(新建/切换/删除)
This commit is contained in:
fengmengqi
2026-04-10 16:29:27 +08:00
parent 0782159ecd
commit 4a04faf926
56 changed files with 8611 additions and 172 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.local
+50
View File
@@ -0,0 +1,50 @@
# Claw Code Frontend
Claw Code 的 Web 前端界面,使用 [Ant Design X](https://x.ant.design/) 构建。
## 技术栈
- React 19 + TypeScript
- Ant Design X 2.5Bubble / Sender / Conversations / Think / ThoughtChain / XMarkdown
- Vite 6
## 开发
```bash
npm install
npm run dev
```
前端通过 Vite 代理连接后端,默认代理到 `http://localhost:3000`
先启动后端:
```bash
# 项目根目录
cargo run --bin claw-server
```
## 构建
```bash
npm run build
```
产出目录:`dist/`
## 项目结构
```
src/
main.tsx # 入口
App.tsx # XProvider 主题 + 布局 + 状态管理
api.ts # REST API 客户端
types.ts # 类型定义
hooks/
useSSE.ts # SSE 事件流
components/
ChatView.tsx # 聊天区(Bubble.List + Sender + XMarkdown
SessionSidebar.tsx # 会话侧边栏(Conversations
ToolChain.tsx # 工具调用链(ThoughtChain
WelcomeScreen.tsx # 欢迎页
```
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claw Code</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4910
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "claw-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@ant-design/x": "^2.5.0",
"@ant-design/x-markdown": "^2.5.0",
"@ant-design/x-sdk": "^2.5.0",
"@antv/infographic": "^0.2.16",
"antd": "^6.1.1",
"marked-emoji": "^2.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.8.3",
"vite": "^6.3.2"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🐾</text></svg>

After

Width:  |  Height:  |  Size: 110 B

+343
View File
@@ -0,0 +1,343 @@
import React, { useState, useCallback, useRef } from 'react';
import { XProvider } from '@ant-design/x';
import zhCN_X from '@ant-design/x/locale/zh_CN';
import { theme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import SessionSidebar from './components/SessionSidebar';
import ChatView from './components/ChatView';
import type { ChatDisplayMessage } from './components/ChatView';
import type { ContentBlock, ConversationMessage, SessionEvent, TokenUsage } from './types';
import { useSSE } from './hooks/useSSE';
import * as api from './api';
// 将服务端消息格式(tool 消息独立)合并为前端展示格式
// 服务端: user → assistant(text+tool_use) → tool(tool_result) → tool(tool_result) → assistant(text+tool_use) → ...
// 前端: user → assistant(text+tool_use+tool_result) → assistant(text+tool_use+tool_result) → ...
function mergeMessages(raw: ConversationMessage[]): ChatDisplayMessage[] {
const result: ChatDisplayMessage[] = [];
let assistantIdx = -1; // 上一个 assistant 消息在 result 中的索引
for (let i = 0; i < raw.length; i++) {
const m = raw[i];
if (m.role === 'assistant') {
result.push({
key: `msg-${i}`,
role: 'assistant',
blocks: [...m.blocks],
streaming: false,
});
assistantIdx = result.length - 1;
} else if (m.role === 'user') {
result.push({
key: `msg-${i}`,
role: 'user',
blocks: [...m.blocks],
streaming: false,
});
assistantIdx = -1;
} else if (m.role === 'tool') {
// 将 tool_result blocks 合并到上一个 assistant 消息
if (assistantIdx >= 0) {
result[assistantIdx].blocks = [
...result[assistantIdx].blocks,
...m.blocks,
];
}
}
}
return result;
}
// 助手消息的累积缓冲区
interface AssistantBuffer {
text: string;
thinking: string;
toolCalls: Map<string, { id: string; name: string; input: string; output?: string; isError?: boolean }>;
}
function blocksFromBuffer(buffer: AssistantBuffer, _streaming: boolean): ContentBlock[] {
const blocks: ContentBlock[] = [];
if (buffer.thinking) {
blocks.push({ type: 'thinking', thinking: buffer.thinking });
}
if (buffer.text) {
blocks.push({ type: 'text', text: buffer.text });
}
for (const tool of buffer.toolCalls.values()) {
blocks.push({ type: 'tool_use', id: tool.id, name: tool.name, input: tool.input });
if (tool.output !== undefined) {
blocks.push({ type: 'tool_result', tool_use_id: tool.id, tool_name: tool.name, output: tool.output, is_error: tool.isError ?? false });
}
}
return blocks;
}
const App: React.FC = () => {
const [isDark, setIsDark] = useState(() => {
const saved = localStorage.getItem('claw-theme');
if (saved) return saved === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatDisplayMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [_usage, setUsage] = useState<TokenUsage | null>(null);
// 助手消息缓冲区
const bufferRef = useRef<AssistantBuffer | null>(null);
const msgCounterRef = useRef(0);
const toggleTheme = useCallback(() => {
setIsDark((prev) => {
const next = !prev;
localStorage.setItem('claw-theme', next ? 'dark' : 'light');
return next;
});
}, []);
// 处理 SSE 事件
const handleEvent = useCallback((event: SessionEvent) => {
switch (event.type) {
case 'snapshot': {
// 初始化完整消息状态(合并 tool 消息到 assistant
setMessages(mergeMessages(event.messages));
setIsStreaming(false);
bufferRef.current = null;
break;
}
case 'message_delta': {
// 累积文本 delta
if (!bufferRef.current) return;
bufferRef.current.text += event.text;
setMessages((prev) =>
updateLastAssistant(prev, bufferRef.current!)
);
break;
}
case 'thinking_delta': {
if (!bufferRef.current) return;
bufferRef.current.thinking += event.thinking;
setMessages((prev) =>
updateLastAssistant(prev, bufferRef.current!)
);
break;
}
case 'tool_use_start': {
if (!bufferRef.current) return;
bufferRef.current.toolCalls.set(event.tool_use_id, {
id: event.tool_use_id,
name: event.tool_name,
input: event.input,
});
setMessages((prev) =>
updateLastAssistant(prev, bufferRef.current!)
);
break;
}
case 'tool_result': {
if (!bufferRef.current) return;
const existing = bufferRef.current.toolCalls.get(event.tool_use_id);
if (existing) {
existing.output = event.output;
existing.isError = event.is_error;
} else {
bufferRef.current.toolCalls.set(event.tool_use_id, {
id: event.tool_use_id,
name: event.tool_name,
input: '',
output: event.output,
isError: event.is_error,
});
}
setMessages((prev) =>
updateLastAssistant(prev, bufferRef.current!)
);
break;
}
case 'usage': {
setUsage(event.usage);
break;
}
case 'turn_complete': {
setIsStreaming(false);
setUsage(event.usage);
// 标记最后一条助手消息为非流式
setMessages((prev) => {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1];
if (last.role !== 'assistant') return prev;
return [
...prev.slice(0, -1),
{ ...last, streaming: false },
];
});
bufferRef.current = null;
break;
}
case 'message': {
// 忽略完整 message 事件,因为 delta 已经处理了流式组装
break;
}
}
}, []);
// SSE 连接
useSSE(activeSessionId, handleEvent);
// 新建会话
const handleNewSession = useCallback(async () => {
try {
const res = await api.createSession();
setActiveSessionId(res.session_id);
setMessages([]);
setUsage(null);
setIsStreaming(false);
bufferRef.current = null;
} catch (err) {
console.error('创建会话失败:', err);
}
}, []);
// 切换会话
const handleSessionChange = useCallback(async (id: string) => {
try {
const details = await api.getSession(id);
setActiveSessionId(id);
setMessages(mergeMessages(details.messages));
setUsage(null);
setIsStreaming(false);
bufferRef.current = null;
} catch (err) {
console.error('加载会话失败:', err);
}
}, []);
// 删除会话
const handleDeleteSession = useCallback(async (id: string) => {
try {
await api.deleteSession(id);
if (activeSessionId === id) {
setActiveSessionId(null);
setMessages([]);
setUsage(null);
}
} catch (err) {
console.error('删除会话失败:', err);
}
}, [activeSessionId]);
// 发送消息
const handleSend = useCallback(async (message: string) => {
if (!activeSessionId || isStreaming) return;
// 添加用户消息
const userKey = `user-${++msgCounterRef.current}`;
const assistantKey = `assistant-${msgCounterRef.current}`;
// 初始化助手消息缓冲区
bufferRef.current = {
text: '',
thinking: '',
toolCalls: new Map(),
};
const userMsg: ChatDisplayMessage = {
key: userKey,
role: 'user',
blocks: [{ type: 'text', text: message }],
};
const assistantMsg: ChatDisplayMessage = {
key: assistantKey,
role: 'assistant',
blocks: [],
streaming: true,
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setIsStreaming(true);
try {
await api.sendMessage(activeSessionId, message);
} catch (err) {
console.error('发送消息失败:', err);
setIsStreaming(false);
}
}, [activeSessionId, isStreaming]);
// 取消(中止)
const handleCancel = useCallback(() => {
setIsStreaming(false);
setMessages((prev) => {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1];
if (last.role !== 'assistant') return prev;
return [
...prev.slice(0, -1),
{ ...last, streaming: false },
];
});
bufferRef.current = null;
}, []);
return (
<XProvider
locale={{ ...zhCN_X, ...zhCN }}
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: { colorPrimary: '#1677ff' },
}}
>
<div style={{
width: '100%',
height: '100vh',
display: 'flex',
overflow: 'hidden',
background: isDark ? '#141414' : '#fff',
}}>
<SessionSidebar
activeSessionId={activeSessionId}
onSessionChange={handleSessionChange}
onNewSession={handleNewSession}
onDeleteSession={handleDeleteSession}
isDark={isDark}
onToggleTheme={toggleTheme}
/>
<ChatView
messages={messages}
isStreaming={isStreaming}
hasActiveSession={activeSessionId !== null}
onSend={handleSend}
onCancel={handleCancel}
/>
</div>
</XProvider>
);
};
// 更新最后一条助手消息
function updateLastAssistant(
prev: ChatDisplayMessage[],
buffer: AssistantBuffer,
): ChatDisplayMessage[] {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1];
if (last.role !== 'assistant') return prev;
return [
...prev.slice(0, -1),
{
...last,
blocks: blocksFromBuffer(buffer, true),
},
];
}
export default App;
+57
View File
@@ -0,0 +1,57 @@
import type {
CreateSessionResponse,
ListSessionsResponse,
SessionDetailsResponse,
UsageResponse,
CompactResponse,
} from './types';
const BASE = '/sessions';
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...init,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || res.statusText);
}
if (res.status === 202 || res.status === 204) return undefined as T;
const contentLength = res.headers.get('content-length');
if (contentLength === '0') return undefined as T;
const text = await res.text();
if (!text.trim()) return undefined as T;
return JSON.parse(text) as T;
}
export async function createSession(): Promise<CreateSessionResponse> {
return request<CreateSessionResponse>(BASE, { method: 'POST' });
}
export async function listSessions(): Promise<ListSessionsResponse> {
return request<ListSessionsResponse>(BASE);
}
export async function getSession(id: string): Promise<SessionDetailsResponse> {
return request<SessionDetailsResponse>(`${BASE}/${id}`);
}
export async function deleteSession(id: string): Promise<void> {
return request<void>(`${BASE}/${id}`, { method: 'DELETE' });
}
export async function sendMessage(sessionId: string, message: string): Promise<void> {
return request<void>(`${BASE}/${sessionId}/message`, {
method: 'POST',
body: JSON.stringify({ message }),
});
}
export async function compactSession(sessionId: string): Promise<CompactResponse> {
return request<CompactResponse>(`${BASE}/${sessionId}/compact`, { method: 'POST' });
}
export async function getUsage(sessionId: string): Promise<UsageResponse> {
return request<UsageResponse>(`${BASE}/${sessionId}/usage`);
}
+475
View File
@@ -0,0 +1,475 @@
import React, { useCallback } from 'react';
import { Bubble, Sender, Think, ThoughtChain, Actions, CodeHighlighter, Mermaid, Sources } from '@ant-design/x';
import { UserOutlined, RobotOutlined, GlobalOutlined } from '@ant-design/icons';
import { theme, Skeleton, Spin, Popover } from 'antd';
import { XMarkdown } from '@ant-design/x-markdown';
// 助手气泡 body 撑满可用宽度,避免 Mermaid 等内容宽度受文本行长度影响
const bubbleStyle = document.createElement('style');
bubbleStyle.textContent = '.ant-bubble-start > .ant-bubble-body { width: 80%; }';
document.head.appendChild(bubbleStyle);
import type { ComponentProps, Token } from '@ant-design/x-markdown';
import Latex from '@ant-design/x-markdown/plugins/latex';
import '@ant-design/x-markdown/themes/light.css';
import '@ant-design/x-markdown/themes/dark.css';
import type { ContentBlock } from '../types';
import ToolChain from './ToolChain';
import WelcomeScreen from './WelcomeScreen';
// ── XMarkdown 插件配置 ────────────────────────────────────────────────
// LaTeX 数学公式插件:解析 $...$ / $$...$$ / \(...\) / \[...\]
// 自定义脚注插件:解析 [^1] 语法 → <footnote> 标签
const footnoteExtension = {
name: 'footnote',
level: 'inline' as const,
start(src: string) {
const idx = src.indexOf('[^');
return idx !== -1 ? idx : undefined;
},
tokenizer(src: string) {
const match = src.match(/^\[\^(\d+)\]/);
if (!match) return;
return {
type: 'footnote',
raw: match[0],
text: match[1],
renderType: 'component' as const,
};
},
renderer(token: Token) {
return `<footnote data-key="${token.text}">${token.text}</footnote>`;
},
};
const xMarkdownConfig = { extensions: [...Latex(), footnoteExtension] };
// ── XMarkdown components 映射 ─────────────────────────────────────────
// Infographic 渲染器(动态加载 @antv/infographic
const InfographicBlock: React.FC<{ content: string }> = ({ content }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const instanceRef = React.useRef<{ render: (spec: string) => void; destroy: () => void } | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
React.useEffect(() => {
if (!containerRef.current) return;
let mounted = true;
import('@antv/infographic')
.then(({ Infographic }) => {
if (!mounted || !containerRef.current) return;
instanceRef.current = new Infographic({ container: containerRef.current });
instanceRef.current.render(content);
setLoading(false);
})
.catch(() => {
if (mounted) { setLoading(false); setError(true); }
});
return () => { mounted = false; instanceRef.current?.destroy(); };
}, [content]);
if (error) {
return (
<div style={{ padding: 12, border: '1px solid #ff4d4f', borderRadius: 8, color: '#ff4d4f', fontSize: 13 }}>
Infographic @antv/infographic
</div>
);
}
return (
<div style={{ position: 'relative', border: '1px solid var(--ant-color-border-secondary)', borderRadius: 8, padding: 16 }}>
{loading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
<Spin tip="渲染信息图..." />
</div>
)}
<div ref={containerRef} style={{ display: loading ? 'none' : 'block' }} />
</div>
);
};
// 完整的代码块渲染器
const CodeBlock: React.FC<ComponentProps> = ({ children, lang, block, streamStatus, ...rest }) => {
// 行内 code
if (!block) {
return <code {...rest}>{children}</code>;
}
const content = String(children).replace(/\n$/, '');
// Mermaid 图表:直接渲染,让 Mermaid 组件展示内置的渲染动画
if (lang === 'mermaid') {
if (!content) return null;
return <Mermaid>{content}</Mermaid>;
}
// Infographic 信息图
if (lang === 'infographic') {
if (!content) return null;
return <InfographicBlock content={content} />;
}
// 普通代码块:语法高亮
return (
<CodeHighlighter lang={lang} header={lang || undefined}>
{content}
</CodeHighlighter>
);
};
// 流式渲染:图片未闭合 → 骨架屏
const IncompleteImage = () => <Skeleton.Image active style={{ width: 60, height: 60 }} />;
// 流式渲染:链接未闭合 → 显示已有文本
const IncompleteLink: React.FC<ComponentProps> = (props) => {
const text = decodeURIComponent(String(props['data-raw'] || ''));
const match = text.match(/^\[([^\]]*)\]/);
const displayText = match ? match[1] : text.slice(1);
return <a style={{ pointerEvents: 'none' }} href="#">{displayText}</a>;
};
// 流式渲染:表格未闭合 → 骨架屏
const IncompleteTable = () => <Skeleton.Node active style={{ width: 160 }} />;
// 流式渲染:HTML 未闭合 → 骨架屏
const IncompleteHtml = () => <Skeleton.Node active style={{ width: 383, height: 120 }} />;
// 流式渲染:强调语法未闭合 → 显示已有文本
const IncompleteEmphasis: React.FC<ComponentProps> = (props) => {
const text = decodeURIComponent(String(props['data-raw'] || ''));
const match = text.match(/^([*_]{1,3})([^*_]*)/);
if (!match || !match[2]) return null;
const [, symbols, content] = match;
const level = symbols.length;
if (level === 1) return <em>{content}</em>;
if (level === 2) return <strong>{content}</strong>;
return <em><strong>{content}</strong></em>;
};
// 流式渲染:行内代码未闭合 → 显示已有文本
const IncompleteInlineCode: React.FC<ComponentProps> = (props) => {
const rawData = String(props['data-raw'] || '');
if (!rawData) return null;
return <code>{decodeURIComponent(rawData).slice(1)}</code>;
};
// Markdown 中嵌入的 <think /> 标签渲染(根据 streamStatus 自动切换状态)
const ThinkInMarkdown: React.FC<ComponentProps> = React.memo((props) => {
const isDone = props.streamStatus === 'done';
return (
<Think
title={isDone ? '思考完成' : '思考中...'}
loading={!isDone}
defaultExpanded={!isDone}
>
{props.children}
</Think>
);
});
// <sup> 引用 → Sources 内联组件(搜索增强场景)
const SupComponent: React.FC<ComponentProps> = React.memo((props) => {
const key = parseInt(String(props.children) || '0', 10);
return (
<Sources
activeKey={key}
title={props.children}
items={[{ key, title: `来源 ${key}`, url: '#' }]}
inline
/>
);
});
// 自定义脚注 [^1] → 可点击引用标记
const FootnoteComponent: React.FC<ComponentProps> = React.memo((props) => {
const key = String(props['data-key'] || props.children);
return (
<Popover content={`脚注 ${key}`} trigger="hover">
<sup
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 18,
height: 18,
borderRadius: '50%',
background: 'var(--ant-color-fill-quaternary)',
fontSize: 11,
cursor: 'pointer',
marginLeft: 2,
transition: 'background 0.2s',
}}
>
{key}
</sup>
</Popover>
);
});
const xMarkdownComponents = {
code: CodeBlock,
think: ThinkInMarkdown,
sup: SupComponent,
footnote: FootnoteComponent,
'incomplete-image': IncompleteImage,
'incomplete-link': IncompleteLink,
'incomplete-table': IncompleteTable,
'incomplete-html': IncompleteHtml,
'incomplete-emphasis': IncompleteEmphasis,
'incomplete-inline-code': IncompleteInlineCode,
};
interface ChatViewProps {
messages: ChatDisplayMessage[];
isStreaming: boolean;
hasActiveSession: boolean;
onSend: (message: string) => void;
onCancel: () => void;
}
export interface ChatDisplayMessage {
key: string;
role: 'user' | 'assistant';
blocks: ContentBlock[];
streaming?: boolean;
}
const ChatView: React.FC<ChatViewProps> = ({
messages,
isStreaming,
hasActiveSession,
onSend,
onCancel,
}) => {
const [inputValue, setInputValue] = React.useState('');
const handleSubmit = useCallback((msg: string) => {
const trimmed = msg.trim();
if (!trimmed) return;
onSend(trimmed);
setInputValue('');
}, [onSend]);
if (!hasActiveSession) {
return <WelcomeScreen onSelect={handleSubmit} />;
}
// 用 key 索引消息,供 contentRender 查找
const msgMap = new Map(messages.map((m) => [m.key, m]));
const items = messages.map((msg) => {
const textContent = msg.blocks
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('\n');
return {
key: msg.key,
role: msg.role,
content: msg.role === 'user' ? textContent : '',
loading: msg.role === 'assistant' && msg.streaming && !textContent,
};
});
// 使用 v2 的 role(单数)配置
const role = {
user: {
placement: 'end' as const,
variant: 'filled' as const,
shape: 'round' as const,
avatar: <UserOutlined />,
// styles: { content: { width: '80%' } },
},
assistant: {
placement: 'start' as const,
variant: 'borderless' as const,
avatar: <RobotOutlined />,
streaming: true,
styles: { content: { width: '80%' } },
header: (_content: unknown, { status }: { status?: string }) => {
if (status === 'loading' || status === 'updating') {
return (
<ThoughtChain.Item
style={{ marginBottom: 8 }}
status="loading"
variant="solid"
icon={<GlobalOutlined />}
title="模型运行中"
/>
);
}
if (status === 'success') {
return (
<ThoughtChain.Item
style={{ marginBottom: 8 }}
status="success"
variant="solid"
icon={<GlobalOutlined />}
title="执行完成"
/>
);
}
return null;
},
footer: (_content: string, { key, status }: { key?: string | number; status?: string }) => {
if (status === 'updating' || status === 'loading') return null;
const msg = msgMap.get(String(key));
if (!msg || msg.role !== 'assistant') return null;
const textBlocks = msg.blocks
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('\n');
return (
<div style={{ display: 'flex' }}>
<Actions
items={[
{ key: 'copy', actionRender: <Actions.Copy text={textBlocks} /> },
]}
/>
</div>
);
},
contentRender: (_content: unknown, { key }: { key?: string | number }) => {
const msg = msgMap.get(String(key));
if (!msg) return '';
return <AssistantContent blocks={msg.blocks} streaming={msg.streaming} />;
},
},
};
return (
<div style={{
height: '100%',
width: 'calc(100% - 280px)',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', justifyContent: 'center' }}>
<Bubble.List
items={items}
role={role}
autoScroll
style={{ height: '100%' }}
/>
</div>
<div style={{ padding: '0 16px 16px' }}>
<Sender
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
onCancel={onCancel}
loading={isStreaming}
placeholder="输入消息..."
/>
</div>
</div>
);
};
// ── 助手消息内容渲染 ──────────────────────────────────────────────────
const AssistantContent: React.FC<{ blocks: ContentBlock[]; streaming?: boolean }> = ({
blocks,
streaming,
}) => {
const { theme: antdTheme } = theme.useToken();
const mdClassName = antdTheme.id === 0 ? 'x-markdown-light' : 'x-markdown-dark';
const elements: React.ReactNode[] = [];
// 收集工具调用
const toolCalls = new Map<string, {
id: string; name: string; input: string;
output?: string; isError?: boolean;
}>();
let firstToolIndex = -1;
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
if (block.type === 'tool_use') {
if (firstToolIndex === -1) firstToolIndex = i;
toolCalls.set(block.id, { id: block.id, name: block.name, input: block.input });
} else if (block.type === 'tool_result') {
if (firstToolIndex === -1) firstToolIndex = i;
const existing = toolCalls.get(block.tool_use_id);
if (existing) {
existing.output = block.output;
existing.isError = block.is_error;
} else {
toolCalls.set(block.tool_use_id, {
id: block.tool_use_id, name: block.tool_name, input: '',
output: block.output, isError: block.is_error,
});
}
}
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
switch (block.type) {
case 'thinking':
elements.push(
<Think
key={`think-${i}`}
loading={streaming}
blink={streaming}
title={streaming ? '思考中...' : '思考过程'}
defaultExpanded={false}
>
<div style={{ whiteSpace: 'pre-wrap', fontSize: 13, opacity: 0.85 }}>
{block.thinking}
</div>
</Think>
);
break;
case 'text':
elements.push(
<XMarkdown
key={`text-${i}`}
className={mdClassName}
components={xMarkdownComponents}
config={xMarkdownConfig}
paragraphTag="div"
openLinksInNewTab
streaming={{
hasNextChunk: !!streaming,
enableAnimation: !!streaming,
tail: false,
animationConfig: { fadeDuration: 400 },
}}
>
{block.text}
</XMarkdown>
);
break;
case 'redacted_thinking':
elements.push(
<Think key={`redacted-${i}`} title="已编辑的思考" defaultExpanded={false}>
<span style={{ opacity: 0.5 }}>[]</span>
</Think>
);
break;
case 'tool_use':
if (i === firstToolIndex) {
elements.push(
<ToolChain key="tool-chain" tools={Array.from(toolCalls.values())} />
);
}
break;
case 'tool_result':
// 由 ToolChain 统一渲染
break;
}
}
return <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>{elements}</div>;
};
export default ChatView;
+116
View File
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { Conversations } from '@ant-design/x';
import { DeleteOutlined, PlusOutlined, BulbOutlined, BulbFilled } from '@ant-design/icons';
import type { SessionSummary } from '../types';
import * as api from '../api';
interface SessionSidebarProps {
activeSessionId: string | null;
onSessionChange: (id: string) => void;
onNewSession: () => void;
onDeleteSession: (id: string) => void;
isDark: boolean;
onToggleTheme: () => void;
}
const SessionSidebar: React.FC<SessionSidebarProps> = ({
activeSessionId,
onSessionChange,
onNewSession,
onDeleteSession,
isDark,
onToggleTheme,
}) => {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const fetchSessions = async () => {
try {
const res = await api.listSessions();
setSessions(res.sessions);
} catch {
// 忽略
}
};
useEffect(() => {
fetchSessions();
}, [activeSessionId]);
const items = sessions.map((s) => ({
key: s.id,
label: `会话 ${s.id.replace('session-', '')}`,
timestamp: s.created_at,
}));
return (
<div style={{
width: 280,
height: '100%',
display: 'flex',
flexDirection: 'column',
padding: '0 12px',
boxSizing: 'border-box',
background: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
}}>
{/* Logo */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '24px 24px',
boxSizing: 'border-box',
}}>
<span style={{ fontSize: 24 }}>🐾</span>
<span style={{ fontWeight: 'bold', fontSize: 16 }}>Claw Code</span>
</div>
{/* 会话列表 */}
<div style={{ flex: 1, overflow: 'auto', marginTop: 12, padding: 0 }}>
<Conversations
items={items}
activeKey={activeSessionId || undefined}
onActiveChange={(key) => onSessionChange(key)}
menu={(conversation) => ({
items: [
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
},
],
onClick: (info) => {
if (info.key === 'delete') {
onDeleteSession(conversation.key);
}
},
})}
creation={{
onClick: onNewSession,
label: '新建会话',
icon: <PlusOutlined />,
}}
/>
</div>
{/* 主题切换 */}
<div style={{
padding: '8px 16px',
borderTop: '1px solid rgba(0,0,0,0.06)',
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: 'pointer',
fontSize: 14,
userSelect: 'none',
}}
onClick={onToggleTheme}
>
{isDark ? <BulbFilled /> : <BulbOutlined />}
<span>{isDark ? '浅色模式' : '深色模式'}</span>
</div>
</div>
);
};
export default SessionSidebar;
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import { ThoughtChain } from '@ant-design/x';
interface ToolCall {
id: string;
name: string;
input: string;
output?: string;
isError?: boolean;
}
interface ToolChainProps {
tools: ToolCall[];
}
const ToolChain: React.FC<ToolChainProps> = ({ tools }) => {
const items = tools.map((tool) => {
const hasResult = tool.output !== undefined;
let status: 'loading' | 'success' | 'error' = 'loading';
if (hasResult) {
status = tool.isError ? 'error' : 'success';
}
return {
key: tool.id,
status,
title: tool.name,
description: hasResult ? (tool.isError ? '执行出错' : '执行完成') : '执行中...',
collapsible: true,
content: (
<div style={{ fontSize: 13 }}>
{tool.input && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}></div>
<pre style={{
margin: 0,
padding: 8,
borderRadius: 6,
background: 'rgba(0,0,0,0.04)',
overflow: 'auto',
maxHeight: 200,
fontSize: 12,
}}>
{tryFormatJSON(tool.input)}
</pre>
</div>
)}
{tool.output !== undefined && (
<div>
<div style={{ fontWeight: 500, marginBottom: 4 }}>
{tool.isError ? '错误' : '输出'}
</div>
<pre style={{
margin: 0,
padding: 8,
borderRadius: 6,
background: tool.isError ? 'rgba(255,0,0,0.04)' : 'rgba(0,0,0,0.04)',
overflow: 'auto',
maxHeight: 300,
fontSize: 12,
}}>
{tool.output}
</pre>
</div>
)}
</div>
),
};
});
if (items.length === 0) return null;
return <ThoughtChain items={items} />;
};
function tryFormatJSON(str: string): string {
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch {
return str;
}
}
export default ToolChain;
export type { ToolCall };
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { Welcome, Prompts } from '@ant-design/x';
const examplePrompts = [
{ key: '1', label: '总结当前工作区', description: '分析项目结构和代码' },
{ key: '2', label: '帮我写一个测试', description: '为指定模块生成测试用例' },
{ key: '3', label: '查找 Bug', description: '检查代码中的潜在问题' },
];
interface WelcomeScreenProps {
onSelect: (prompt: string) => void;
}
const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ onSelect }) => {
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 48,
}}>
<Welcome
icon="🐾"
title="Claw Code"
description="本地编码助手,连接你的 Claw Server"
style={{ marginBottom: 32 }}
/>
<Prompts
title="试试这些"
items={examplePrompts}
onItemClick={(info) => {
onSelect(String(info.data.label));
}}
/>
</div>
);
};
export default WelcomeScreen;
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useRef, useCallback } from 'react';
import type { SessionEvent } from '../types';
export function useSSE(
sessionId: string | null,
onEvent: (event: SessionEvent) => void,
): void {
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const stableCallback = useCallback((event: SessionEvent) => {
onEventRef.current(event);
}, []);
useEffect(() => {
if (!sessionId) return;
const es = new EventSource(`/sessions/${sessionId}/events`);
const eventTypes: SessionEvent['type'][] = [
'snapshot',
'message',
'message_delta',
'thinking_delta',
'tool_use_start',
'tool_result',
'usage',
'turn_complete',
];
for (const type of eventTypes) {
es.addEventListener(type, (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as SessionEvent;
stableCallback(data);
} catch {
// 忽略解析错误
}
});
}
es.onerror = () => {
// EventSource 会自动重连
};
return () => {
es.close();
};
}, [sessionId, stableCallback]);
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// 全局样式:消除默认 margin/padding,防止 100vh 溢出产生页面滚动条
document.body.style.margin = '0';
document.body.style.padding = '0';
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+160
View File
@@ -0,0 +1,160 @@
// 镜像 crates/runtime/src/session.rs 和 crates/server/src/lib.rs 的类型
// ── ContentBlock ──────────────────────────────────────────────────────
export interface TextBlock {
type: 'text';
text: string;
}
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
}
export interface RedactedThinkingBlock {
type: 'redacted_thinking';
data: unknown;
}
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
input: string;
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
tool_name: string;
output: string;
is_error: boolean;
}
export type ContentBlock =
| TextBlock
| ThinkingBlock
| RedactedThinkingBlock
| ToolUseBlock
| ToolResultBlock;
// ── MessageRole ───────────────────────────────────────────────────────
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
// ── ConversationMessage ───────────────────────────────────────────────
export interface TokenUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
}
export interface ConversationMessage {
role: MessageRole;
blocks: ContentBlock[];
usage?: TokenUsage;
}
// ── SSE SessionEvent ──────────────────────────────────────────────────
export interface SnapshotEvent {
type: 'snapshot';
session_id: string;
messages: ConversationMessage[];
}
export interface MessageEvent {
type: 'message';
session_id: string;
message: ConversationMessage;
}
export interface MessageDeltaEvent {
type: 'message_delta';
session_id: string;
text: string;
}
export interface ToolUseStartEvent {
type: 'tool_use_start';
session_id: string;
tool_use_id: string;
tool_name: string;
input: string;
}
export interface ToolResultEvent {
type: 'tool_result';
session_id: string;
tool_use_id: string;
tool_name: string;
output: string;
is_error: boolean;
}
export interface ThinkingDeltaEvent {
type: 'thinking_delta';
session_id: string;
thinking: string;
}
export interface UsageEvent {
type: 'usage';
session_id: string;
usage: TokenUsage;
}
export interface TurnCompleteEvent {
type: 'turn_complete';
session_id: string;
usage: TokenUsage;
iterations: number;
}
export type SessionEvent =
| SnapshotEvent
| MessageEvent
| MessageDeltaEvent
| ToolUseStartEvent
| ToolResultEvent
| ThinkingDeltaEvent
| UsageEvent
| TurnCompleteEvent;
// ── REST API 响应类型 ─────────────────────────────────────────────────
export interface SessionSummary {
id: string;
created_at: number;
message_count: number;
}
export interface CreateSessionResponse {
session_id: string;
}
export interface SessionDetailsResponse {
id: string;
created_at: number;
messages: ConversationMessage[];
}
export interface UsageResponse {
session_id: string;
usage: TokenUsage;
turns: number;
}
export interface CompactResponse {
session_id: string;
summary: string;
removed_message_count: number;
}
export interface ListSessionsResponse {
sessions: SessionSummary[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"moduleDetection": "force",
"jsx": "react-jsx",
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/sessions': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});