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:
+351
@@ -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();
|
||||
}
|
||||
@@ -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();}
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
};
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user