chore(v2): 从兴邦预审v2白名单抽取独立可运行基座

按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:58:26 +08:00
co-authored by Claude Code
commit d56c699ce6
224 changed files with 33392 additions and 0 deletions
+351
View File
@@ -0,0 +1,351 @@
/**
* API client for mini OpenClaw backend.
* Custom SSE parser for POST requests (native EventSource only supports GET).
*/
import {readChatStream} from "./chat-stream";
import {coreFetch as fetch} from "./core-fetch";
// Same-origin streaming proxy shares the current workbench backend configuration.
const API_BASE = "/api/core";
export interface SSEEvent {
event: string;
data: Record<string, unknown>;
}
/**
* Stream chat messages via POST SSE.
* Yields parsed SSE events as they arrive.
*/
export async function* streamChat(
message: string,
sessionId: string
): AsyncGenerator<SSEEvent> {
yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true}, {fetcher:fetch});
}
/**
* Read a file from the backend.
*/
export async function readFile(path: string): Promise<string> {
const resp = await fetch(`${API_BASE}/files?path=${encodeURIComponent(path)}`);
if (!resp.ok) throw new Error(`Failed to read file: ${resp.status}`);
const data = await resp.json();
return data.content;
}
/**
* Save a file to the backend.
*/
export async function saveFile(path: string, content: string): Promise<void> {
const resp = await fetch(`${API_BASE}/files`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
});
if (!resp.ok) throw new Error(`Failed to save file: ${resp.status}`);
}
/**
* List all sessions.
*/
export async function listSessions(): Promise<
Array<{ id: string; title: string; updated_at: number }>
> {
const resp = await fetch(`${API_BASE}/sessions`);
if (!resp.ok) throw new Error(`Failed to list sessions: ${resp.status}`);
const data = await resp.json();
return data.sessions;
}
/**
* Create a new session.
*/
export async function createSession(): Promise<{ id: string; title: string }> {
const resp = await fetch(`${API_BASE}/sessions`, { method: "POST" });
if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`);
return resp.json();
}
/**
* Rename a session.
*/
export async function renameSession(id: string, title: string): Promise<void> {
const resp = await fetch(`${API_BASE}/sessions/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!resp.ok) throw new Error(`Failed to rename session: ${resp.status}`);
}
/**
* Delete a session.
*/
export async function deleteSession(id: string): Promise<void> {
const resp = await fetch(`${API_BASE}/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!resp.ok) throw new Error(`Failed to delete session: ${resp.status}`);
}
/**
* Get raw messages for a session (including system prompt).
*/
export async function getRawMessages(
sessionId: string
): Promise<{ session_id: string; title: string; messages: Array<{ role: string; content: string }> }> {
const resp = await fetch(
`${API_BASE}/sessions/${encodeURIComponent(sessionId)}/messages`
);
if (!resp.ok) throw new Error(`Failed to get raw messages: ${resp.status}`);
return resp.json();
}
/**
* Get session conversation history (no system prompt, includes tool_calls).
*/
export async function getSessionHistory(
sessionId: string
): Promise<{
session_id: string;
messages: Array<{ role: string; content: string; tool_calls?: Array<{ tool: string; input?: string; output?: string }> }>;
}> {
const resp = await fetch(
`${API_BASE}/sessions/${encodeURIComponent(sessionId)}/history`
);
if (!resp.ok) throw new Error(`Failed to get session history: ${resp.status}`);
return resp.json();
}
/**
* Get raw LLM traces for a session (原始报文).
*/
export async function getRawTraces(
sessionId: string,
limit: number = 20
): Promise<{ traces: Array<any> }> {
const resp = await fetch(
`${API_BASE}/chat/raw-traces?session_id=${encodeURIComponent(sessionId)}&limit=${limit}`
);
if (!resp.ok) throw new Error(`Failed to get raw traces: ${resp.status}`);
return resp.json();
}
/**
* Get complete interaction trajectory for a session (用户↔AI交互轨迹).
* 返回完整消息流(含 tool message)和旧格式 steps(向后兼容)
*/
export async function getTrajectory(
sessionId: string
): Promise<{
session_id: string;
messages: Array<{
role: string;
content: string;
timestamp?: number;
tool_calls?: Array<{ name: string; args: any }>;
name?: string;
tool_call_id?: string;
}>;
steps: Array<{
role: string;
content: string;
timestamp?: number;
llm_calls?: Array<{
model: string;
finish_reason: string;
request_messages: Array<any>;
response_text: string;
tool_calls_list: Array<{ name: string; args: any }>;
usage: any;
duration_ms: number;
}>;
}>;
}> {
const resp = await fetch(
`${API_BASE}/chat/trajectory?session_id=${encodeURIComponent(sessionId)}`
);
if (!resp.ok) throw new Error(`Failed to get trajectory: ${resp.status}`);
return resp.json();
}
/**
* List available skills.
*/
export async function listSkills(): Promise<
Array<{ name: string; path: string; description: string }>
> {
const resp = await fetch(`${API_BASE}/skills`);
if (!resp.ok) throw new Error(`Failed to list skills: ${resp.status}`);
const data = await resp.json();
return data.skills;
}
/**
* Generate a title for a session using AI.
*/
export async function generateTitle(
sessionId: string
): Promise<{ title: string }> {
const resp = await fetch(
`${API_BASE}/sessions/${encodeURIComponent(sessionId)}/generate-title`,
{ method: "POST" }
);
if (!resp.ok) throw new Error(`Failed to generate title: ${resp.status}`);
return resp.json();
}
/**
* Get token count for a session (system + messages).
*/
export async function getSessionTokenCount(
sessionId: string
): Promise<{ system_tokens: number; message_tokens: number; total_tokens: number }> {
const resp = await fetch(
`${API_BASE}/tokens/session/${encodeURIComponent(sessionId)}`
);
if (!resp.ok) throw new Error(`Failed to get token count: ${resp.status}`);
return resp.json();
}
/**
* Get token counts for a list of files.
*/
export async function getFileTokenCounts(
paths: string[]
): Promise<{ files: Array<{ path: string; tokens: number }> }> {
const resp = await fetch(`${API_BASE}/tokens/files`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths }),
});
if (!resp.ok) throw new Error(`Failed to get file token counts: ${resp.status}`);
return resp.json();
}
/**
* Compress a session's conversation history.
*/
export async function compressSession(
sessionId: string
): Promise<{ archived_count: number; remaining_count: number }> {
const resp = await fetch(
`${API_BASE}/sessions/${encodeURIComponent(sessionId)}/compress`,
{ method: "POST" }
);
if (!resp.ok) throw new Error(`Failed to compress session: ${resp.status}`);
return resp.json();
}
/**
* Get current RAG mode status.
*/
export async function getRagMode(): Promise<{ rag_mode: boolean }> {
const resp = await fetch(`${API_BASE}/config/rag-mode`);
if (!resp.ok) throw new Error(`Failed to get RAG mode: ${resp.status}`);
return resp.json();
}
/**
* Set RAG mode enabled/disabled.
*/
export async function setRagMode(
enabled: boolean
): Promise<{ rag_mode: boolean }> {
const resp = await fetch(`${API_BASE}/config/rag-mode`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!resp.ok) throw new Error(`Failed to set RAG mode: ${resp.status}`);
return resp.json();
}
/* ── 兴邦金租智能体系统 API (新版,基于会议痛点) ─── */
const XINGBANG_API_BASE = `${API_BASE}/xingbang`;
/**
* 获取兴邦系统配置(五Agent列表、项目类型等)
*/
export async function getXingbangConfig(): Promise<any> {
const resp = await fetch(`${XINGBANG_API_BASE}/config`);
if (!resp.ok) throw new Error(`Xingbang config error: ${resp.status}`);
return resp.json();
}
/**
* 流式运行兴邦五Agent流水线(SSE)
*/
export async function* streamXingbangWorkflow(
customer_name: string,
project_type: string,
amount: number
): AsyncGenerator<SSEEvent> {
const response = await fetch(`${XINGBANG_API_BASE}/workflow/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customer_name, project_type, amount }),
});
if (!response.ok) throw new Error(`Xingbang workflow error: ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let currentEvent = "message";
for (const line of lines) {
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
const dataStr = line.slice(5).trim();
if (dataStr) {
try {
yield { event: currentEvent, data: JSON.parse(dataStr) };
} catch {
// skip malformed JSON
}
}
}
if (line === "") currentEvent = "message";
}
}
}
/**
* 执行单个Agent
*/
export async function executeXingbangAgent(
agent_type: string,
input_data: Record<string, any>
): Promise<any> {
const resp = await fetch(`${XINGBANG_API_BASE}/agent/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_type, input_data }),
});
if (!resp.ok) throw new Error(`Xingbang agent error: ${resp.status}`);
return resp.json();
}
/**
* 健康检查
*/
export async function checkXingbangHealth(): Promise<any> {
const resp = await fetch(`${XINGBANG_API_BASE}/health`);
if (!resp.ok) throw new Error(`Xingbang health check error: ${resp.status}`);
return resp.json();
}
+92
View File
@@ -0,0 +1,92 @@
/** Stateful SSE decoder: TCP chunks are not event boundaries. */
export interface ChatEvent { event: string; data: Record<string, unknown> }
export class ChatEventDecoder {
private buffer = "";
private event = "message";
private data: string[] = [];
push(text: string, flush = false): ChatEvent[] {
this.buffer += text;
const result: ChatEvent[] = [];
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
if (flush) { if (this.buffer) lines.push(this.buffer); lines.push(""); this.buffer = ""; }
for (const raw of lines) {
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (!line) {
if (this.data.length) {
let parsed: unknown;
try { parsed = JSON.parse(this.data.join("\n")); }
catch { throw new Error("回答数据格式异常,请重试;已接收的内容会保留。"); }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("回答事件格式不受支持");
result.push({event: this.event, data: parsed as Record<string, unknown>});
}
this.event = "message"; this.data = [];
} else if (line.startsWith("event:")) {
this.event = line.slice(6).trim();
} else if (line.startsWith("data:")) {
this.data.push(line.slice(5).replace(/^ /, ""));
}
}
return result;
}
}
/** No automatic request replay: a tool may already have executed. */
export async function* readChatStream(url: string, body: unknown, options: {
fetcher?: typeof fetch; idleMs?: number; totalMs?: number;
} = {}): AsyncGenerator<ChatEvent> {
const controller = new AbortController();
const fetcher = options.fetcher || fetch;
const idleMs = options.idleMs ?? 90_000;
let timedOut = false, receivedDone = false, receivedError = false, answerSeen = false;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
const expire = () => { timedOut = true; controller.abort(); };
let idle = setTimeout(expire, idleMs);
const total = setTimeout(expire, options.totalMs ?? 180_000);
const touch = () => { clearTimeout(idle); idle = setTimeout(expire, idleMs); };
const decoder = new TextDecoder();
const parser = new ChatEventDecoder();
const annotate = (events: ChatEvent[]) => {
const normalized: ChatEvent[] = [];
for (const event of events) {
if (event.event === "token" && typeof event.data.content === "string" && event.data.content.trim()) answerSeen = true;
if (event.event === "done") {
if (!answerSeen) {
if (typeof event.data.content !== "string" || !event.data.content.trim()) throw new Error("模型没有返回回答正文,请检查聊天模型配置后重试。");
normalized.push({event:"token",data:{content:event.data.content}});
answerSeen = true;
}
receivedDone = true;
}
if (event.event === "error") receivedError = true;
normalized.push(event);
}
return normalized;
};
try {
const response = await fetcher(url, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body),signal:controller.signal});
if (!response.ok) throw new Error(`聊天服务请求失败(HTTP ${response.status}),请检查当前后端连接。`);
if (!response.headers.get("content-type")?.includes("text/event-stream")) throw new Error("聊天服务未返回事件流,请检查后端地址。");
reader = response.body?.getReader();
if (!reader) throw new Error("聊天服务未返回数据流。");
touch();
while (true) {
const {done,value} = await reader.read();
if (done) break;
for (const event of annotate(parser.push(decoder.decode(value,{stream:true})))) { touch(); yield event; }
if (receivedError) return;
}
for (const event of annotate(parser.push(decoder.decode(),true))) { touch(); yield event; }
if (!receivedDone && !receivedError) throw new Error("回答连接提前中断;已接收的内容已保留,请检查后重试。");
} catch (error) {
if (timedOut) {
if (receivedDone) return; // An optional title request must not invalidate a delivered answer.
throw new Error("回答等待超时;请检查模型服务后重试。不会自动重复执行工具。");
}
throw error;
} finally {
clearTimeout(idle);clearTimeout(total);controller.abort();
if (reader) {try {await reader.cancel();}catch {} reader.releaseLock();}
}
}
+7
View File
@@ -0,0 +1,7 @@
/** Reuse the existing access identity without putting credentials in URLs or logs. */
export const coreFetch: typeof fetch = (input, init) => {
const headers = new Headers(init?.headers);
const token = typeof window === "undefined" ? null : window.sessionStorage.getItem("xingbang-access-token");
if (token) headers.set("Authorization", `Bearer ${token}`);
return globalThis.fetch(input, {...init, headers});
};
+8
View File
@@ -0,0 +1,8 @@
/** getRandomValues also works on HTTP intranet pages where randomUUID is unavailable. */
export function newRequestId(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
+575
View File
@@ -0,0 +1,575 @@
"use client";
import React, {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
} from "react";
import {
streamChat,
listSessions as apiListSessions,
createSession as apiCreateSession,
renameSession as apiRenameSession,
deleteSession as apiDeleteSession,
getRawMessages as apiGetRawMessages,
getSessionHistory as apiGetSessionHistory,
compressSession as apiCompressSession,
getRagMode as apiGetRagMode,
setRagMode as apiSetRagMode,
getRawTraces as apiGetRawTraces,
} from "./api";
// ── Types ──────────────────────────────────────────────────
export interface ToolCall {
tool: string;
input?: string;
output?: string;
status: "running" | "done";
}
export interface RetrievalResult {
text: string;
score: string;
source: string;
}
export interface RawTrace {
trace_id: string;
session_id: string;
timestamp_start: number;
timestamp_end?: number;
duration_ms?: number;
request: {
model: string;
messages: any[];
temperature?: number;
invocation_params?: any;
};
response?: {
generations: any[];
llm_output: any;
};
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
stop_reason?: string;
finish_reason?: string; // 新增:统一的完成原因字段(stop/length/tool_calls)
error?: {
type: string;
message: string;
};
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
toolCalls?: ToolCall[];
retrievals?: RetrievalResult[];
rawTraces?: RawTrace[];
timestamp: number;
}
export interface SessionMeta {
id: string;
title: string;
updated_at: number;
}
export interface RawMessage {
role: string;
content: string;
}
interface AppState {
// Chat
messages: ChatMessage[];
isStreaming: boolean;
sendMessage: (text: string) => Promise<void>;
// Sessions
sessionId: string;
setSessionId: (id: string) => void;
sessions: SessionMeta[];
loadSessions: () => void;
createSession: () => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
deleteSession: (id: string) => Promise<void>;
// Sidebar
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
// Inspector (Monaco editor)
inspectorFile: string | null;
setInspectorFile: (path: string | null) => void;
inspectorOpen: boolean;
setInspectorOpen: (open: boolean) => void;
toggleInspector: () => void;
// Right panel tab
rightTab: "memory" | "skills";
setRightTab: (tab: "memory" | "skills") => void;
// Raw messages
rawMessages: RawMessage[] | null;
loadRawMessages: () => void;
// Expanded file (editor full-panel mode)
expandedFile: boolean;
setExpandedFile: (v: boolean) => void;
// Panel widths
sidebarWidth: number;
setSidebarWidth: (w: number | ((prev: number) => number)) => void;
inspectorWidth: number;
setInspectorWidth: (w: number | ((prev: number) => number)) => void;
// Compression
isCompressing: boolean;
compressCurrentSession: () => Promise<void>;
// RAG mode
ragMode: boolean;
toggleRagMode: () => void;
}
const AppContext = createContext<AppState | null>(null);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [sessionId, setSessionIdRaw] = useState("default");
const [sessions, setSessions] = useState<SessionMeta[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [inspectorFile, setInspectorFileRaw] = useState<string | null>(null);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [rightTab, setRightTab] = useState<"memory" | "skills">("memory");
const [rawMessages, setRawMessages] = useState<RawMessage[] | null>(null);
const [expandedFile, setExpandedFile] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(260);
const [inspectorWidth, setInspectorWidth] = useState(360);
const [isCompressing, setIsCompressing] = useState(false);
const [ragMode, setRagMode] = useState(false);
const abortRef = useRef(false);
// Load RAG mode on mount
useEffect(() => {
apiGetRagMode()
.then((data) => setRagMode(data.rag_mode))
.catch(() => {});
}, []);
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
const toggleInspector = useCallback(() => setInspectorOpen((v) => !v), []);
// When a file is selected, auto-open the inspector
const setInspectorFile = useCallback((path: string | null) => {
setInspectorFileRaw(path);
if (path) setInspectorOpen(true);
}, []);
// ── Session management ─────────────────────────────
const loadSessions = useCallback(() => {
apiListSessions()
.then((list) => setSessions(list))
.catch(() => {});
}, []);
// Load sessions on mount
useEffect(() => {
loadSessions();
}, [loadSessions]);
const setSessionId = useCallback(
(id: string) => {
setSessionIdRaw(id);
setMessages([]);
setRawMessages(null);
// Load existing conversation history from backend
apiGetSessionHistory(id)
.then((data) => {
if (data.messages && data.messages.length > 0) {
const loaded: ChatMessage[] = [];
let msgIndex = 0;
for (const msg of data.messages) {
if (msg.role === "user") {
loaded.push({
id: `hist-user-${msgIndex++}`,
role: "user",
content: msg.content,
timestamp: Date.now() - (data.messages.length - msgIndex) * 1000,
});
} else if (msg.role === "assistant") {
const toolCalls: ToolCall[] = (msg.tool_calls || []).map(
(tc: { tool: string; input?: string; output?: string }) => ({
tool: tc.tool,
input: tc.input || "",
output: tc.output || "",
status: "done" as const,
})
);
loaded.push({
id: `hist-asst-${msgIndex++}`,
role: "assistant",
content: msg.content,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
timestamp: Date.now() - (data.messages.length - msgIndex) * 1000,
});
}
}
setMessages(loaded);
}
})
.catch(() => {
// Session might not exist yet, that's OK
});
},
[]
);
const createSession = useCallback(async () => {
try {
const meta = await apiCreateSession();
setSessions((prev) => [{ id: meta.id, title: meta.title, updated_at: Date.now() / 1000 }, ...prev]);
setSessionId(meta.id);
} catch {
// ignore
}
}, [setSessionId]);
const renameSessionFn = useCallback(async (id: string, title: string) => {
try {
await apiRenameSession(id, title);
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s))
);
} catch {
// ignore
}
}, []);
const deleteSessionFn = useCallback(
async (id: string) => {
try {
await apiDeleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (sessionId === id) {
setSessionId("default");
}
} catch {
// ignore
}
},
[sessionId, setSessionId]
);
const loadRawMessages = useCallback(() => {
if (!sessionId) return;
apiGetRawMessages(sessionId)
.then((data) => setRawMessages(data.messages))
.catch(() => setRawMessages(null));
}, [sessionId]);
// ── Compression ──────────────────────────────────────
const compressCurrentSession = useCallback(async () => {
if (isCompressing) return;
setIsCompressing(true);
try {
await apiCompressSession(sessionId);
// Refresh raw messages and chat history
loadRawMessages();
apiGetSessionHistory(sessionId)
.then((data) => {
if (data.messages && data.messages.length > 0) {
const loaded: ChatMessage[] = [];
let msgIndex = 0;
for (const msg of data.messages) {
if (msg.role === "user") {
loaded.push({
id: `hist-user-${msgIndex++}`,
role: "user",
content: msg.content,
timestamp: Date.now() - (data.messages.length - msgIndex) * 1000,
});
} else if (msg.role === "assistant") {
const toolCalls: ToolCall[] = (msg.tool_calls || []).map(
(tc: { tool: string; input?: string; output?: string }) => ({
tool: tc.tool,
input: tc.input || "",
output: tc.output || "",
status: "done" as const,
})
);
loaded.push({
id: `hist-asst-${msgIndex++}`,
role: "assistant",
content: msg.content,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
timestamp: Date.now() - (data.messages.length - msgIndex) * 1000,
});
}
}
setMessages(loaded);
} else {
setMessages([]);
}
})
.catch(() => {});
} catch {
// ignore
} finally {
setIsCompressing(false);
}
}, [isCompressing, sessionId, loadRawMessages]);
// ── RAG mode ────────────────────────────────────────
const toggleRagMode = useCallback(() => {
const newMode = !ragMode;
setRagMode(newMode);
apiSetRagMode(newMode).catch(() => setRagMode(ragMode));
}, [ragMode]);
// ── Send message ───────────────────────────────────
// Ref to track the current assistant message ID during streaming
const currentAssistantIdRef = useRef("");
const sendMessage = useCallback(
async (text: string) => {
if (!text.trim() || isStreaming || isCompressing) return;
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: text,
timestamp: Date.now(),
};
const firstAssistantId = `assistant-${Date.now()}`;
const assistantMsg: ChatMessage = {
id: firstAssistantId,
role: "assistant",
content: "",
toolCalls: [],
timestamp: Date.now(),
};
currentAssistantIdRef.current = firstAssistantId;
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setIsStreaming(true);
abortRef.current = false;
try {
for await (const event of streamChat(text, sessionId)) {
if (abortRef.current) break;
// Handle retrieval event (RAG mode)
if (event.event === "retrieval") {
const targetId = currentAssistantIdRef.current;
const retrievalData = event.data as {
query: string;
results: Array<{ text: string; score: string; source: string }>;
};
setMessages((prev) => {
const updated = [...prev];
const idx = updated.findIndex((m) => m.id === targetId);
if (idx === -1) return prev;
updated[idx] = {
...updated[idx],
retrievals: retrievalData.results,
};
return updated;
});
continue;
}
// Handle title event (auto-generated after first message)
if (event.event === "title") {
const titleData = event.data as { session_id: string; title: string };
setSessions((prev) =>
prev.map((s) =>
s.id === titleData.session_id
? { ...s, title: titleData.title }
: s
)
);
continue;
}
// Handle new_response — create a new assistant bubble
if (event.event === "new_response") {
const newId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
currentAssistantIdRef.current = newId;
setMessages((prev) => [
...prev,
{
id: newId,
role: "assistant",
content: "",
toolCalls: [],
timestamp: Date.now(),
},
]);
continue;
}
const targetId = currentAssistantIdRef.current;
setMessages((prev) => {
const updated = [...prev];
const idx = updated.findIndex((m) => m.id === targetId);
if (idx === -1) return prev;
const msg = { ...updated[idx] };
switch (event.event) {
case "token":
msg.content += (event.data.content as string) || "";
break;
case "tool_start":
msg.toolCalls = [
...(msg.toolCalls || []),
{
tool: event.data.tool as string,
input: event.data.input as string,
status: "running",
},
];
break;
case "tool_end": {
const calls = [...(msg.toolCalls || [])];
for (let i = calls.length - 1; i >= 0; i--) {
if (
calls[i].tool === event.data.tool &&
calls[i].status === "running"
) {
calls[i] = {
...calls[i],
output: event.data.output as string,
status: "done",
};
break;
}
}
msg.toolCalls = calls;
break;
}
case "done":
break;
case "error":
msg.content +=
`\n\n**未完成:** ${event.data.error || "聊天服务未返回有效回答"}`;
break;
}
updated[idx] = msg;
return updated;
});
}
} catch (err) {
const targetId = currentAssistantIdRef.current;
setMessages((prev) => {
const updated = [...prev];
const idx = updated.findIndex((m) => m.id === targetId);
if (idx !== -1) {
updated[idx] = {
...updated[idx],
content:
updated[idx].content +
`\n\n**连接或回答异常:** ${err instanceof Error ? err.message : "未知错误,请检查后端服务"}`,
};
}
return updated;
});
} finally {
setIsStreaming(false);
// Refresh sessions list to pick up any updates
loadSessions();
// 加载原始报文并关联到最后一条 AI 消息
try {
const rawTracesData = await apiGetRawTraces(sessionId, 5);
if (rawTracesData.traces && rawTracesData.traces.length > 0) {
const targetId = currentAssistantIdRef.current;
setMessages((prev) => {
const updated = [...prev];
const idx = updated.findIndex((m) => m.id === targetId);
if (idx !== -1) {
updated[idx] = {
...updated[idx],
rawTraces: rawTracesData.traces,
};
}
return updated;
});
}
} catch (err) {
console.error("Failed to load raw traces:", err);
}
}
},
[isStreaming, isCompressing, sessionId, loadSessions]
);
return (
<AppContext.Provider
value={{
messages,
isStreaming,
sendMessage,
sessionId,
setSessionId,
sessions,
loadSessions,
createSession,
renameSession: renameSessionFn,
deleteSession: deleteSessionFn,
sidebarOpen,
setSidebarOpen,
toggleSidebar,
inspectorFile,
setInspectorFile,
inspectorOpen,
setInspectorOpen,
toggleInspector,
rightTab,
setRightTab,
rawMessages,
loadRawMessages,
expandedFile,
setExpandedFile,
sidebarWidth,
setSidebarWidth,
inspectorWidth,
setInspectorWidth,
isCompressing,
compressCurrentSession,
ragMode,
toggleRagMode,
}}
>
{children}
</AppContext.Provider>
);
}
export function useApp(): AppState {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used within AppProvider");
return ctx;
}