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:
@@ -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;
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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[];
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user