import type { IncomingMessage, ServerResponse } from "node:http";
import { dispatchSlashCommand } from "../slash/dispatch.ts";
import { normalizeChatImages } from "../services/chat-images.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";

async function trySlashDispatch(
	msg: string,
	imgs: ReturnType<typeof normalizeChatImages>,
	sendCmd: WebUiContext["rpc"]["sendCmd"],
): Promise<Awaited<ReturnType<typeof dispatchSlashCommand>> | null> {
	const trimmed = msg.trim();
	if (!trimmed.startsWith("/")) return null;
	const slash = await dispatchSlashCommand(trimmed, sendCmd);
	return slash?.handled ? slash : null;
}

export function handleChatRoute(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: WebUiContext,
	pathname: string,
): boolean {
	const { sendCmd, submitPrompt } = ctx.rpc;

	if (req.method === "POST" && pathname === "/api/chat") {
		void readBody(req)
			.then(async ({ message, images, streamingBehavior }) => {
				const msg = typeof message === "string" ? message : "";
				const imgs = normalizeChatImages(images);
				if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
				if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);

				const slash = await trySlashDispatch(msg, imgs, sendCmd);
				if (slash) {
					return json(res, { ok: true, slash: true, ...slash });
				}

				const behavior =
					streamingBehavior === "steer" || streamingBehavior === "followUp"
						? streamingBehavior
						: undefined;
				submitPrompt({ message: msg, images: imgs, streamingBehavior: behavior });
				return json(res, { ok: true, accepted: true }, 202);
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/steer") {
		void readBody(req)
			.then(async ({ message, images }) => {
				const msg = typeof message === "string" ? message.trim() : "";
				const imgs = normalizeChatImages(images);
				if (!msg && !imgs?.length) throw new Error("消息不能为空");
				const result = await sendCmd({ type: "steer", message: msg, images: imgs });
				if (!result.success) throw new Error(result.error || "steer 失败");
				json(res, { ok: true });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/follow-up") {
		void readBody(req)
			.then(async ({ message, images }) => {
				const msg = typeof message === "string" ? message.trim() : "";
				const imgs = normalizeChatImages(images);
				if (!msg && !imgs?.length) throw new Error("消息不能为空");
				const result = await sendCmd({ type: "follow_up", message: msg, images: imgs });
				if (!result.success) throw new Error(result.error || "follow_up 失败");
				json(res, { ok: true });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/bash") {
		void readBody(req)
			.then(async ({ command, excludeFromContext }) => {
				const cmd = typeof command === "string" ? command.trim() : "";
				if (!cmd) throw new Error("命令不能为空");
				const result = await sendCmd({
					type: "bash",
					command: cmd,
					excludeFromContext: excludeFromContext === true,
				});
				if (!result.success) throw new Error(result.error || "命令执行失败");
				json(res, result.data);
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "GET" && pathname === "/api/events") {
		res.writeHead(200, {
			"Content-Type": "text/event-stream",
			"Cache-Control": "no-cache",
			Connection: "keep-alive",
		});
		ctx.rpc.connectSseClient(res);
		req.on("close", () => ctx.rpc.removeSseClient(res));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/messages") {
		void sendCmd({ type: "get_messages" })
			.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/abort") {
		void sendCmd({ type: "abort" })
			.then(() => json(res, { ok: true }))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/abort-retry") {
		void sendCmd({ type: "abort_retry" })
			.then(() => json(res, { ok: true }))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	return false;
}


import type { IncomingMessage, ServerResponse } from "node:http";
import {
	appendSessionName,
	buildSessionListResponse,
	deleteSessionFile,
	pinSession,
	readSessionMessages,
	readSessionSummary,
	resolveSessionFile,
} from "../services/sessions.ts";
import type { WebUiContext } from "../types/context.ts";
import { isSameResolvedPath } from "../utils/paths.ts";
import { json, readBody } from "../http/request.ts";

export function handleSessionsRoute(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: WebUiContext,
	pathname: string,
): boolean {
	const { paths } = ctx.config;
	const { sendCmd } = ctx.rpc;

	if (req.method === "POST" && pathname === "/api/new-session") {
		void sendCmd({ type: "new_session" })
			.then(async (r: any) => {
				if (!r.success) return json(res, { error: r.error }, 500);
				if (r.data?.cancelled) return json(res, r.data);
				const state = await sendCmd({ type: "get_state" });
				const sessionFile = state.success ? state.data?.sessionFile : undefined;
				return json(res, { ...r.data, sessionFile });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "GET" && pathname === "/api/sessions") {
		json(res, buildSessionListResponse(paths));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/sessions/history") {
		void readBody(req)
			.then(({ path: sp }) => {
				const messages = readSessionMessages(sp as string);
				const summary = readSessionSummary(paths, sp as string);
				json(res, { messages, session: summary });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/sessions/delete") {
		void readBody(req)
			.then(({ path: sp }) => {
				deleteSessionFile(paths, sp as string);
				json(res, { ok: true });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/sessions/pin") {
		void readBody(req)
			.then(({ path: sp, pinned }) => {
				const result = pinSession(paths, sp as string, Boolean(pinned));
				json(res, { ok: true, ...result });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/sessions/load") {
		void readBody(req)
			.then(async ({ path: sp }) => {
				const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: paths.repoRoot });
				if (!sw.success) throw new Error(sw.error);
				const mr = await sendCmd({ type: "get_messages" });
				if (!mr.success) throw new Error(mr.error);
				const summary = readSessionSummary(paths, sp as string);
				json(res, { messages: mr.data.messages, session: summary });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/sessions/activate") {
		void readBody(req)
			.then(async ({ path: sp }) => {
				const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: paths.repoRoot });
				if (!sw.success) throw new Error(sw.error);
				const state = await sendCmd({ type: "get_state" });
				if (!state.success) throw new Error(state.error);
				json(res, { ok: true, state: state.data });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/sessions/name") {
		void readBody(req)
			.then(async ({ path: sp, name }) => {
				if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
				const savedName = appendSessionName(paths, sp, typeof name === "string" ? name : "");
				const sessionPath = resolveSessionFile(paths, sp);
				const state = await sendCmd({ type: "get_state" });
				if (
					state.success &&
					state.data?.sessionFile &&
					isSameResolvedPath(String(state.data.sessionFile), sessionPath)
				) {
					const rename = await sendCmd({ type: "set_session_name", name: savedName });
					if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
				}
				json(res, { ok: true, name: savedName });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "GET" && pathname === "/api/session-state") {
		void sendCmd({ type: "get_state" })
			.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	return false;
}


import type { IncomingMessage, ServerResponse } from "node:http";
import { listSlashCommands } from "../services/slash-commands.ts";
import type { WebUiContext } from "../types/context.ts";
import { json } from "../http/request.ts";

export function handleCommandsRoute(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: WebUiContext,
	pathname: string,
): boolean {
	if (req.method === "GET" && pathname === "/api/commands") {
		void listSlashCommands(ctx.rpc.sendCmd)
			.then((commands) => json(res, { commands }))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}
	return false;
}


让我继续读取更多关键文件，包括前端 API 层、模型路由、配置路由等，以及前端与后端的交互方式。


import type { IncomingMessage, ServerResponse } from "node:http";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";

export function handleModelsRoute(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: WebUiContext,
	pathname: string,
): boolean {
	const { sendCmd } = ctx.rpc;

	if (req.method === "GET" && pathname === "/api/models") {
		void sendCmd({ type: "get_available_models" })
			.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/model") {
		void readBody(req)
			.then(({ provider, modelId }) =>
				sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
					r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
				),
			)
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/thinking") {
		void readBody(req)
			.then(({ level }) =>
				sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
					r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
				),
			)
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	return false;
}


import type { IncomingMessage, ServerResponse } from "node:http";
import { arch, freemem, hostname, platform, release, totalmem, type as osType } from "node:os";
import { readWebuiAvatarSettings, writeWebuiAvatarSettings } from "../db/index.ts";
import { setExtensionEnabled } from "../settings/extension-settings.ts";
import { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from "../settings/mcp-settings.ts";
import { listSkillSettings, setSkillEnabled } from "../settings/skills-settings.ts";
import { normalizeAvatarUrl } from "../services/avatars.ts";
import {
	listExtensionsForSettings,
	listLoadedExtensionsByPath,
	mergeExtensionToggleResponse,
} from "../services/extensions-display.ts";
import { readModelsConfig, writeModelsConfig } from "../services/models-config.ts";
import { readSystemPrompt, writeSystemPrompt } from "../services/system-prompt.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";

function listMcpTools(ctx: WebUiContext): Record<string, unknown>[] {
	return listMcpSettings(ctx.config.paths.mcpConfigFile, ctx.config.paths.mcpCacheFile);
}

export function handleSettingsRoute(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: WebUiContext,
	pathname: string,
): boolean {
	const { paths } = ctx.config;
	const { sendCmd } = ctx.rpc;

	if (req.method === "GET" && pathname === "/api/settings") {
		void listSkillSettings(paths.repoRoot, paths.agentDir)
			.then((skills) =>
				listExtensionsForSettings(paths, sendCmd).then((extensions) =>
					json(res, {
						...readWebuiAvatarSettings(),
						webuiDbPath: ctx.db.dbPath,
						systemPrompt: readSystemPrompt(paths),
						systemPromptPath: paths.systemPromptFile,
						modelsConfig: readModelsConfig(paths),
						modelsConfigPath: paths.modelsConfigFile,
						extensionsPath: paths.agentExtensionsDir,
						mcpCachePath: paths.mcpCacheFile,
						mcpConfigPath: paths.mcpConfigFile,
						skills,
						extensions,
						mcpTools: listMcpTools(ctx),
					}),
				),
			)
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/skills/toggle") {
		void readBody(req)
			.then(async ({ path: skillPath, enabled }) => {
				if (typeof skillPath !== "string" || !skillPath.trim()) {
					throw new Error("skill path 无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const skill = await setSkillEnabled(paths.repoRoot, paths.agentDir, skillPath.trim(), enabled);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, skill });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/extensions/toggle") {
		void readBody(req)
			.then(async ({ path: extensionPath, enabled }) => {
				if (typeof extensionPath !== "string" || !extensionPath.trim()) {
					throw new Error("extension path 无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const extension = await setExtensionEnabled(
					paths.repoRoot,
					paths.agentDir,
					extensionPath.trim(),
					enabled,
				);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				let loadedByPath = new Map<string, any>();
				try {
					loadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);
				} catch {
					/* ignore */
				}
				json(res, {
					ok: true,
					extension: mergeExtensionToggleResponse(paths, extension, loadedByPath),
				});
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/mcp/server/toggle") {
		void readBody(req)
			.then(async ({ server, enabled }) => {
				if (typeof server !== "string" || !server.trim()) {
					throw new Error("server 名称无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const entry = setMcpServerEnabled(
					paths.mcpConfigFile,
					paths.mcpCacheFile,
					server.trim(),
					enabled,
				);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, server: entry });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/mcp/tool/toggle") {
		void readBody(req)
			.then(async ({ server, tool, enabled }) => {
				if (typeof server !== "string" || !server.trim()) {
					throw new Error("server 名称无效");
				}
				if (typeof tool !== "string" || !tool.trim()) {
					throw new Error("tool 名称无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const entry = setMcpToolEnabled(
					paths.mcpConfigFile,
					paths.mcpCacheFile,
					server.trim(),
					tool.trim(),
					enabled,
				);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, tool: entry });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/reload") {
		void sendCmd({ type: "reload" })
			.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/models-config") {
		void readBody(req)
			.then(async ({ modelsConfig }) => {
				if (typeof modelsConfig !== "string") {
					throw new Error("modelsConfig 必须是字符串");
				}
				writeModelsConfig(paths, modelsConfig);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, modelsConfigPath: paths.modelsConfigFile });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/system-prompt") {
		void readBody(req)
			.then(async ({ systemPrompt }) => {
				if (typeof systemPrompt !== "string") {
					throw new Error("systemPrompt 必须是字符串");
				}
				writeSystemPrompt(paths, systemPrompt);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, systemPromptPath: paths.systemPromptFile });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/avatars") {
		void readBody(req)
			.then(({ userAvatarUrl, agentAvatarUrl }) => {
				const settings = {
					userAvatarUrl: normalizeAvatarUrl(userAvatarUrl),
					agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),
				};
				writeWebuiAvatarSettings(settings);
				json(res, { ok: true, ...settings });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "GET" && pathname === "/api/environment") {
		json(res, {
			nodeVersion: process.version,
			platform: platform(),
			arch: arch(),
			osType: osType(),
			osRelease: release(),
			hostname: hostname(),
			pid: process.pid,
			cwd: process.cwd(),
			uptime: Math.floor(process.uptime()),
			totalMemMb: Math.round(totalmem() / 1024 / 1024),
			freeMemMb: Math.round(freemem() / 1024 / 1024),
			execPath: process.execPath,
		});
		return true;
	}

	return false;
}


import type { IncomingMessage, ServerResponse } from "node:http";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";

export function handleExtensionUiRoute(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: WebUiContext,
	pathname: string,
): boolean {
	if (req.method !== "POST" || pathname !== "/api/extension-ui-response") {
		return false;
	}

	void readBody(req)
		.then(({ id, response }) => {
			if (typeof id !== "string" || !id.trim()) throw new Error("缺少 extension UI 响应 id");
			if (!response || typeof response !== "object") throw new Error("缺少 extension UI 响应内容");
			ctx.rpc.sendExtensionUiResponse(id, response as Record<string, unknown>);
			json(res, { ok: true });
		})
		.catch((err) => json(res, { error: err.message }, 500));

	return true;
}


让我读取前端 API 层，看看前端是如何与后端 HTTP 服务交互的。


import { apiUrl } from "./base";

export class ApiError extends Error {
	constructor(
		message: string,
		public status?: number,
	) {
		super(message);
		this.name = "ApiError";
	}
}

export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
	const res = await fetch(apiUrl(url), init);
	const data = (await res.json().catch(() => ({}))) as T & { error?: string };
	if (!res.ok || (data && "error" in data && data.error)) {
		throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
	}
	return data;
}

export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
	return apiFetch<T>(url, {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: body !== undefined ? JSON.stringify(body) : undefined,
	});
}

export async function apiGet<T>(url: string): Promise<T> {
	return apiFetch<T>(url);
}


import { apiGet, apiPost } from "./client";
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
import type { BashResult } from "../utils/bash";
import type { NewSessionResponse } from "../types/session";

export interface ChatResponse {
	ok?: boolean;
	accepted?: boolean;
	slash?: boolean;
	message?: string;
	action?: "new_session" | "reload_messages" | "reload_sessions" | "copy";
	sessionFile?: string;
	error?: string;
}

export type SendMode = "prompt" | "steer" | "follow_up";

export function sendChat(
	message: string,
	images?: ImageContent[],
	options?: { streamingBehavior?: "steer" | "followUp" },
): Promise<ChatResponse> {
	return apiPost("/api/chat", { message, images, streamingBehavior: options?.streamingBehavior });
}

export function sendSteer(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
	return apiPost("/api/steer", { message, images });
}

export function sendFollowUp(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
	return apiPost("/api/follow-up", { message, images });
}

export function runBash(command: string, options?: { excludeFromContext?: boolean }): Promise<BashResult> {
	return apiPost("/api/bash", { command, excludeFromContext: options?.excludeFromContext });
}

export function abortChat(): Promise<{ ok: boolean }> {
	return apiPost("/api/abort");
}

export function abortRetry(): Promise<{ ok: boolean }> {
	return apiPost("/api/abort-retry");
}

export function sendExtensionUiResponse(
	id: string,
	response: Record<string, unknown>,
): Promise<{ ok: boolean }> {
	return apiPost("/api/extension-ui-response", { id, response });
}

export function fetchSessionState(): Promise<SessionState> {
	return apiGet("/api/session-state");
}

export function fetchModels(): Promise<{ models: ModelInfo[] }> {
	return apiGet("/api/models");
}

export function setModel(provider: string, modelId: string): Promise<{ ok: boolean }> {
	return apiPost("/api/model", { provider, modelId });
}

export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
	return apiPost("/api/thinking", { level });
}

export function createNewSession(): Promise<NewSessionResponse> {
	return apiPost("/api/new-session");
}


import { apiGet, apiPost } from "./client";
import { apiUrl } from "./base";
import type { SessionState } from "../types/message";
import type { SessionHistoryPayload, SessionSummary } from "../types/session";

export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
	return apiGet("/api/sessions");
}

export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload> {
	return apiPost("/api/sessions/history", { path });
}

export async function activateSessionBackend(path: string): Promise<SessionState | null> {
	const activateRes = await fetch(apiUrl("/api/sessions/activate"), {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ path }),
	});
	const activateData = (await activateRes.json().catch(() => ({}))) as {
		error?: string;
		state?: SessionState;
	};
	if (activateRes.ok && !activateData.error) {
		return activateData.state ?? null;
	}

	const activateError = activateData.error || activateRes.statusText || "Unknown error";
	if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
		throw new Error(activateError);
	}

	const fallbackRes = await fetch(apiUrl("/api/sessions/load"), {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ path }),
	});
	const fallbackData = (await fallbackRes.json().catch(() => ({}))) as { error?: string };
	if (!fallbackRes.ok || fallbackData.error) {
		throw new Error(fallbackData.error || fallbackRes.statusText);
	}
	return null;
}

export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
	return apiPost("/api/sessions/load", { path });
}

export function deleteSession(path: string): Promise<{ ok?: boolean }> {
	return apiPost("/api/sessions/delete", { path });
}

export function renameSession(path: string, name: string): Promise<{ ok?: boolean }> {
	return apiPost("/api/sessions/name", { path, name });
}

export function setSessionPinned(path: string, pinned: boolean): Promise<{ ok?: boolean; pinned?: boolean; pinnedPaths?: string[] }> {
	return apiPost("/api/sessions/pin", { path, pinned });
}


让我继续读取前端的 SSE 事件连接代码、类型定义和配置路径，以及后端的配置和路径解析。


const API_BASE = (import.meta.env.VITE_API_BASE ?? "").trim().replace(/\/$/, "");

/** Resolve an API path. Empty base keeps same-origin relative paths for web `dist/`. */
export function apiUrl(path: string): string {
	if (!path.startsWith("/")) {
		throw new Error(`API path must start with /: ${path}`);
	}
	return API_BASE ? `${API_BASE}${path}` : path;
}

export function getApiBase(): string {
	return API_BASE;
}


import type { RpcMessage } from "./message";

export interface AgentStartEvent {
	type: "agent_start";
}

export interface AgentEndEvent {
	type: "agent_end";
}

export interface MessageStartEvent {
	type: "message_start";
	message: RpcMessage;
}

export interface MessageUpdateEvent {
	type: "message_update";
	message: RpcMessage;
}

export interface MessageEndEvent {
	type: "message_end";
	message: RpcMessage;
}

export interface ToolExecutionStartEvent {
	type: "tool_execution_start";
	toolCallId?: string;
	toolName: string;
	args?: unknown;
}

export interface ToolExecutionUpdateEvent {
	type: "tool_execution_update";
	toolCallId?: string;
	toolName: string;
	partialResult?: { content?: unknown };
}

export interface ToolExecutionEndEvent {
	type: "tool_execution_end";
	toolCallId?: string;
	toolName: string;
	isError?: boolean;
	result?: { content?: unknown };
}

export interface CompactionStartEvent {
	type: "compaction_start";
	reason?: string;
}

export interface CompactionEndEvent {
	type: "compaction_end";
	reason?: string;
	aborted?: boolean;
	errorMessage?: string;
	willRetry?: boolean;
	result?: { tokensBefore?: number; summary?: string };
}

export interface QueueUpdateEvent {
	type: "queue_update";
	steering?: string[];
	followUp?: string[];
}

export interface AutoRetryStartEvent {
	type: "auto_retry_start";
	attempt?: number;
	maxAttempts?: number;
	delayMs?: number;
	errorMessage?: string;
}

export interface AutoRetryEndEvent {
	type: "auto_retry_end";
	success?: boolean;
	attempt?: number;
	finalError?: string;
}

export interface ExtensionErrorEvent {
	type: "extension_error";
	extensionPath?: string;
	event?: string;
	error?: string;
}

export interface BashUpdateEvent {
	type: "bash_update";
	command?: string;
	partialOutput?: string;
}

export interface ExtensionUiRequestEvent {
	type: "extension_ui_request";
	id: string;
	method: string;
	title?: string;
	message?: string;
	options?: string[];
	notifyType?: "info" | "warning" | "error";
	statusKey?: string;
	statusText?: string;
	widgetKey?: string;
	widgetLines?: string[];
	prefill?: string;
	[key: string]: unknown;
}

/** Agent stream events replayed on SSE reconnect (excludes connected / prompt_rejected). */
export type StreamSseEvent =
	| AgentStartEvent
	| AgentEndEvent
	| MessageStartEvent
	| MessageUpdateEvent
	| MessageEndEvent
	| ToolExecutionStartEvent
	| ToolExecutionUpdateEvent
	| ToolExecutionEndEvent
	| CompactionStartEvent
	| CompactionEndEvent
	| QueueUpdateEvent
	| AutoRetryStartEvent
	| AutoRetryEndEvent
	| ExtensionErrorEvent
	| BashUpdateEvent
	| ExtensionUiRequestEvent;

export interface ConnectedEvent {
	type: "connected";
	isStreaming?: boolean;
	replay?: StreamSseEvent[];
}

export interface PromptRejectedEvent {
	type: "prompt_rejected";
	error: string;
}

export type SseEvent = ConnectedEvent | PromptRejectedEvent | StreamSseEvent;

export interface SkillInfo {
	name?: string;
	command?: string;
	description?: string;
	path?: string;
	scope?: string;
	source?: string;
	enabled?: boolean;
	toggleable?: boolean;
}

export interface McpToolInfo {
	server?: string;
	name?: string;
	description?: string;
	parameters?: string[];
	required?: string[];
	enabled?: boolean;
}

export interface McpServerInfo {
	name?: string;
	configured?: boolean;
	enabled?: boolean;
	cached?: boolean;
	toolCount?: number;
	enabledToolCount?: number;
	resourceCount?: number;
	cachedAt?: string;
	tools?: McpToolInfo[];
}

export interface ExtensionInfo {
	name?: string;
	path?: string;
	resolvedPath?: string;
	kind?: string;
	category?: "local" | "npm";
	scope?: string;
	source?: string;
	location?: string;
	version?: string;
	enabled?: boolean;
	commands?: string[];
	tools?: string[];
	handlers?: string[];
	flags?: string[];
	shortcuts?: string[];
}

export interface SettingsData {
	systemPrompt?: string;
	systemPromptPath?: string;
	modelsConfig?: string;
	modelsConfigPath?: string;
	userAvatarUrl?: string;
	agentAvatarUrl?: string;
	skills?: SkillInfo[];
	mcpTools?: McpServerInfo[];
	mcpConfigPath?: string;
	mcpCachePath?: string;
	extensions?: ExtensionInfo[];
	extensionsPath?: string;
	error?: string;
}

export interface AvatarSettings {
	userAvatarUrl: string;
	agentAvatarUrl: string;
}

export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "env";

export interface EnvironmentInfo {
	nodeVersion?: string;
	platform?: string;
	arch?: string;
	osType?: string;
	osRelease?: string;
	hostname?: string;
	pid?: number;
	cwd?: string;
	uptime?: number;
	totalMemMb?: number;
	freeMemMb?: number;
	execPath?: string;
}

export interface RetryState {
	active: boolean;
	attempt?: number;
	maxAttempts?: number;
	delayMs?: number;
	startedAt?: number;
	errorMessage?: string;
}

export interface ExtensionDialogState {
	id: string;
	method: "select" | "confirm" | "input" | "editor";
	title: string;
	message?: string;
	options?: string[];
	prefill?: string;
}


import {
	createContext,
	useCallback,
	useContext,
	useEffect,
	useMemo,
	useRef,
	useState,
	type ReactNode,
	type SetStateAction,
} from "react";
import * as chatApi from "../api/chat";
import * as sessionsApi from "../api/sessions";
import { apiUrl } from "../api/base";
import { useBootstrap } from "./BootstrapContext";
import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events";
import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
import { formatBashOutput, parseBashInput } from "../utils/bash";
import { buildHistoryMessages } from "../utils/historyMessages";
import {
	extractContent,
	extractImages,
	extractThinking,
	formatToolCall,
	getToolCalls,
	buildToolCallUpdateText,
} from "../utils/toolCall";
import {
	formatSessionTitle,
	isMachineSessionLabel,
	modelKey,
	titleFromFirstUserMessage,
} from "../utils/sessionTitle";

interface ChatContextValue {
	connected: boolean;
	isStreaming: boolean;
	messages: ChatMessage[];
	sessions: SessionSummary[];
	activeSessionPath: string | null;
	headerTitle: string;
	headerMeta: string;
	sidebarOpen: boolean;
	sessionState: SessionState | null;
	availableModels: ModelInfo[];
	currentModelKey: string;
	thinkingLevel: string;
	availableThinkingLevels: ThinkingLevel[];
	isApplyingModelSettings: boolean;
	pendingSteering: string[];
	pendingFollowUp: string[];
	retryState: RetryState | null;
	isCompacting: boolean;
	extensionWidgets: Record<string, string[]>;
	extensionStatuses: Record<string, string>;
	extensionDialog: ExtensionDialogState | null;
	toolsExpanded: boolean;
	toggleSidebar: () => void;
	closeSidebar: () => void;
	loadSessions: () => Promise<void>;
	loadSession: (path: string) => Promise<void>;
	deleteSession: (path: string) => Promise<void>;
	renameSession: (path: string, currentTitle: string) => Promise<void>;
	toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
	newSession: () => void;
	sendMessage: (
		text: string,
		images?: ImageContent[],
		options?: { mode?: "prompt" | "steer" | "follow_up" },
	) => Promise<void>;
	runBashCommand: (text: string) => Promise<void>;
	abortStream: () => Promise<void>;
	abortRetry: () => Promise<void>;
	toggleToolsExpanded: () => void;
	respondExtensionDialog: (response: Record<string, unknown>) => void;
	dismissExtensionDialog: () => void;
	applyModelSelection: (key: string) => Promise<void>;
	applyThinkingSelection: (level: string) => Promise<void>;
	addSystemMessage: (text: string) => void;
}

const ChatContext = createContext<ChatContextValue | null>(null);

const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
const LAST_ACTIVE_SESSION_KEY = "webui:lastActiveSessionPath";

/** Survives React StrictMode remount so boot only runs once per page load. */
let sharedSessionBootPromise: Promise<void> | null = null;

function resolvePersistSessionPath(backend: string | null, active: string | null): string | null {
	return backend || active;
}

function pickMostRecentSession(list: SessionSummary[]): SessionSummary | null {
	if (list.length === 0) return null;
	return [...list].sort(
		(a, b) =>
			new Date(String(b.modified || b.created || 0)).getTime() -
			new Date(String(a.modified || a.created || 0)).getTime(),
	)[0];
}

function getAvailableThinkingLevels(model: ModelInfo | undefined): ThinkingLevel[] {
	if (!model?.reasoning) return ["off"];
	return THINKING_LEVELS_LIST.filter((level) => {
		const mapped = model.thinkingLevelMap?.[level];
		if (mapped === null) return false;
		if (level === "xhigh") return mapped !== undefined;
		return true;
	});
}

function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
	return normalizeChatMessages(buildHistoryMessages(payload));
}

function findStreamingThinkingId(messages: ChatMessage[]): string | null {
	for (let i = messages.length - 1; i >= 0; i--) {
		const msg = messages[i];
		if (msg.role === "thinking" && msg.streaming) {
			return msg.id;
		}
	}
	return null;
}

function withoutStreamingTurnMessages(
	prev: ChatMessage[],
	thinkingId: string | null,
	assistantId: string | null,
): ChatMessage[] {
	return prev.filter((msg) => msg.id !== thinkingId && msg.id !== assistantId);
}

/** Index where the current turn begins (just after the last user message). */
function lastTurnStartIndex(messages: ChatMessage[]): number {
	for (let i = messages.length - 1; i >= 0; i--) {
		if (messages[i].role === "user") return i + 1;
	}
	return 0;
}

/** Drop every still-streaming assistant/thinking bubble in the current turn. */
function stripStreamingTurnBubbles(messages: ChatMessage[]): ChatMessage[] {
	const turnStart = lastTurnStartIndex(messages);
	return messages.filter((msg, i) => {
		if (i < turnStart) return true;
		if (!msg.streaming) return true;
		return msg.role !== "assistant" && msg.role !== "thinking";
	});
}

function appendStreamingAssistantTurn(
	prev: ChatMessage[],
	thinking: string,
	assistantText: string,
	thinkingIdRef: { current: string | null },
	assistantIdRef: { current: string | null },
): ChatMessage[] {
	// Reattach to any live streaming bubble whose id the ref may have lost (e.g.
	// after a reducer re-run, SSE reconnect, or out-of-order events) so we update
	// it in place instead of spawning a duplicate orphan.
	if (!thinkingIdRef.current && thinking.length > 0) {
		thinkingIdRef.current = findStreamingThinkingId(prev);
	}
	if (!assistantIdRef.current && assistantText.length > 0) {
		assistantIdRef.current = findStreamingAssistantId(prev);
	}

	const next = stripStreamingTurnBubbles(prev);
	const turnMessages: ChatMessage[] = [];

	if (thinking.length > 0 || thinkingIdRef.current) {
		if (!thinkingIdRef.current) {
			thinkingIdRef.current = nextMessageId();
		}
		turnMessages.push({
			id: thinkingIdRef.current,
			role: "thinking",
			content: thinking,
			streaming: true,
		});
	}

	if (assistantText.length > 0 || assistantIdRef.current) {
		if (!assistantIdRef.current) {
			assistantIdRef.current = nextMessageId();
		}
		turnMessages.push({
			id: assistantIdRef.current,
			role: "assistant",
			content: assistantText,
			streaming: true,
		});
	}

	return [...next, ...turnMessages];
}

function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
	const turnStart = lastTurnStartIndex(prev);

	// Remove orphaned streaming assistant bubbles whose content is just a prefix
	// of a longer assistant bubble in the same turn (the real, final reply).
	const survivors = prev.filter((msg, i) => {
		if (i < turnStart || msg.role !== "assistant" || !msg.streaming) return true;
		const shorter = msg.content.trim();
		if (!shorter) return true;
		for (let j = turnStart; j < prev.length; j++) {
			if (j === i) continue;
			const other = prev[j];
			if (other.role !== "assistant") continue;
			const longer = other.content.trim();
			if (longer.length > shorter.length && longer.startsWith(shorter)) {
				return false;
			}
		}
		return true;
	});

	// Clear the streaming flag on every remaining bubble in the turn.
	const survivorTurnStart = lastTurnStartIndex(survivors);
	return survivors.map((msg, i) =>
		i >= survivorTurnStart && msg.streaming ? { ...msg, streaming: false } : msg,
	);
}

function findStreamingAssistantId(messages: ChatMessage[]): string | null {
	for (let i = messages.length - 1; i >= 0; i--) {
		const msg = messages[i];
		if (msg.role === "assistant" && msg.streaming) {
			return msg.id;
		}
	}
	return null;
}

/** Last thinking bubble in the current turn (since the preceding user message). */
function findLastTurnThinkingId(messages: ChatMessage[]): string | null {
	for (let i = messages.length - 1; i >= 0; i--) {
		const msg = messages[i];
		if (msg.role === "user") break;
		if (msg.role === "thinking") return msg.id;
	}
	return null;
}

/** Last assistant bubble in the current turn (since the preceding user message). */
function findLastTurnAssistantId(messages: ChatMessage[]): string | null {
	for (let i = messages.length - 1; i >= 0; i--) {
		const msg = messages[i];
		if (msg.role === "user") break;
		if (msg.role === "assistant") return msg.id;
	}
	return null;
}

function resolveTurnThinkingId(messages: ChatMessage[], refId: string | null): string | null {
	return refId ?? findStreamingThinkingId(messages) ?? findLastTurnThinkingId(messages);
}

function resolveTurnAssistantId(messages: ChatMessage[], refId: string | null): string | null {
	return refId ?? findStreamingAssistantId(messages) ?? findLastTurnAssistantId(messages);
}

function findLastTurnAssistantMessage(messages: ChatMessage[]): ChatMessage | null {
	for (let i = messages.length - 1; i >= 0; i--) {
		const msg = messages[i];
		if (msg.role === "user") break;
		if (msg.role === "assistant") return msg;
	}
	return null;
}

function applyAssistantMessageUpdate(
	prev: ChatMessage[],
	thinking: string,
	assistantText: string,
	thinkingIdRef: { current: string | null },
	assistantIdRef: { current: string | null },
): ChatMessage[] {
	const lastAssistant = findLastTurnAssistantMessage(prev);
	if (lastAssistant && !lastAssistant.streaming) {
		const trimmed = assistantText.trim();
		if (
			trimmed &&
			(lastAssistant.content === trimmed ||
				trimmed.startsWith(lastAssistant.content) ||
				lastAssistant.content.startsWith(trimmed))
		) {
			if (trimmed.length >= lastAssistant.content.length) {
				return prev.map((msg) =>
					msg.id === lastAssistant.id ? { ...msg, content: assistantText } : msg,
				);
			}
			return prev;
		}
	}
	return appendStreamingAssistantTurn(prev, thinking, assistantText, thinkingIdRef, assistantIdRef);
}

function finalizeAssistantMessageEnd(
	prev: ChatMessage[],
	thinking: string,
	assistantText: string,
	toolCalls: ToolCallBlock[],
	thinkingId: string | null,
	assistantId: string | null,
): ChatMessage[] {
	let next = withoutStreamingTurnMessages(prev, thinkingId, assistantId);

	if (thinking) {
		next = [
			...next,
			{
				id: thinkingId ?? nextMessageId(),
				role: "thinking" as const,
				content: thinking,
			},
		];
	}

	const trimmedAssistant = assistantText.trim();
	if (trimmedAssistant) {
		const lastAssistant = findLastTurnAssistantMessage(next);
		if (!(lastAssistant && !lastAssistant.streaming && lastAssistant.content === trimmedAssistant)) {
			next = [
				...next,
				{
					id: assistantId ?? nextMessageId(),
					role: "assistant" as const,
					content: assistantText,
				},
			];
		}
	}

	for (const tc of toolCalls) {
		const tid = tc.toolCallId || tc.id;
		const tidStr = tid ? String(tid) : undefined;
		const text = formatToolCall(tc);
		const existingIdx = tidStr ? next.findIndex((msg) => msg.toolCallId === tidStr) : -1;
		if (existingIdx >= 0) {
			const cur = next[existingIdx].content.trimStart();
			const running = cur.startsWith("🔧") || cur.startsWith("✅") || cur.startsWith("❌");
			if (!running) {
				next = next.map((msg, i) => (i === existingIdx ? { ...msg, content: text } : msg));
			}
		} else {
			next = [
				...next,
				{
					id: tidStr ? `tool-${tidStr}` : nextMessageId(),
					role: "tool_call" as const,
					content: text,
					toolCallId: tidStr,
				},
			];
		}
	}
	return next;
}

function messageDedupeSignature(msg: ChatMessage): string | null {
	if (msg.role !== "assistant" && msg.role !== "thinking" && msg.role !== "tool_call") {
		return null;
	}
	const content = msg.content.trim().replace(/\s+/g, " ");
	if (!content) return null;
	return `${msg.role}:${msg.toolCallId ?? ""}:${content}`;
}

function normalizeContent(content: string): string {
	return content.trim().replace(/\s+/g, " ");
}

/**
 * Drop streaming assistant bubbles whose content is just a prefix of another
 * assistant bubble in the same turn. This clears orphaned streaming bubbles
 * (the duplicate-message symptom) while leaving genuinely distinct assistant
 * replies untouched, since a real pre-tool reply is already non-streaming by
 * the time the next reply starts streaming.
 */
function collapseStreamingPrefixOrphans(messages: ChatMessage[]): ChatMessage[] {
	let turnAssistants: { index: number; content: string }[] = [];
	const dropIndices = new Set<number>();

	const flushTurn = () => {
		for (const a of turnAssistants) {
			if (!a.content) continue;
			const orphan = turnAssistants.some(
				(b) =>
					b.index !== a.index &&
					b.content.length > a.content.length &&
					b.content.startsWith(a.content),
			);
			if (orphan && messages[a.index].streaming) {
				dropIndices.add(a.index);
			}
		}
		turnAssistants = [];
	};

	messages.forEach((msg, i) => {
		if (msg.role === "user") {
			flushTurn();
			return;
		}
		if (msg.role === "assistant") {
			turnAssistants.push({ index: i, content: normalizeContent(msg.content) });
		}
	});
	flushTurn();

	if (dropIndices.size === 0) return messages;
	return messages.filter((_, i) => !dropIndices.has(i));
}

function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
	const result: ChatMessage[] = [];
	let turnSignatures = new Map<string, number>();

	for (const msg of messages) {
		if (msg.role === "user") {
			turnSignatures = new Map();
			result.push(msg);
			continue;
		}

		const signature = messageDedupeSignature(msg);
		if (!signature) {
			result.push(msg);
			continue;
		}

		const existingIndex = turnSignatures.get(signature);
		if (existingIndex === undefined) {
			turnSignatures.set(signature, result.length);
			result.push(msg);
			continue;
		}

		const existing = result[existingIndex];
		if (existing.streaming && !msg.streaming) {
			result[existingIndex] = msg;
		}
	}

	return collapseStreamingPrefixOrphans(result);
}

export function ChatProvider({ children }: { children: ReactNode }) {
	const { markRouteReady } = useBootstrap();
	const [connected, setConnected] = useState(false);
	const [isStreaming, setIsStreaming] = useState(false);
	const [messages, setRawMessages] = useState<ChatMessage[]>([]);
	const [sessions, setSessions] = useState<SessionSummary[]>([]);
	const [activeSessionPath, setActiveSessionPath] = useState<string | null>(null);
	const [backendSessionPath, setBackendSessionPath] = useState<string | null>(null);
	const [headerTitle, setHeaderTitle] = useState("聊天");
	const [headerMeta, setHeaderMeta] = useState("");
	const [sidebarOpen, setSidebarOpen] = useState(false);
	const [sessionState, setSessionState] = useState<SessionState | null>(null);
	const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
	const [currentModelKey, setCurrentModelKey] = useState("");
	const [thinkingLevel, setThinkingLevel] = useState("off");
	const [isApplyingModelSettings, setIsApplyingModelSettings] = useState(false);
	const [pendingSteering, setPendingSteering] = useState<string[]>([]);
	const [pendingFollowUp, setPendingFollowUp] = useState<string[]>([]);
	const [retryState, setRetryState] = useState<RetryState | null>(null);
	const [isCompacting, setIsCompacting] = useState(false);
	const [extensionWidgets, setExtensionWidgets] = useState<Record<string, string[]>>({});
	const [extensionStatuses, setExtensionStatuses] = useState<Record<string, string>>({});
	const [extensionDialog, setExtensionDialog] = useState<ExtensionDialogState | null>(null);
	const [toolsExpanded, setToolsExpanded] = useState(false);

	const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
	const sessionActivationPromise = useRef<Promise<SessionState | null> | null>(null);
	const newSessionPromise = useRef<Promise<void> | null>(null);
	const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
	const sessionBootRef = useRef<Promise<void> | null>(null);
	const streamingAssistantId = useRef<string | null>(null);
	const streamingThinkingId = useRef<string | null>(null);
	const optimisticUserIdRef = useRef<string | null>(null);
	const compactionReloadPendingRef = useRef(false);
	const activeBashMsgIdRef = useRef<string | null>(null);
	const sessionDashboardRaf = useRef<number | null>(null);
	const backendSessionPathRef = useRef(backendSessionPath);
	const activeSessionPathRef = useRef(activeSessionPath);
	const isStreamingRef = useRef(isStreaming);

	useEffect(() => {
		backendSessionPathRef.current = backendSessionPath;
	}, [backendSessionPath]);
	useEffect(() => {
		activeSessionPathRef.current = activeSessionPath;
	}, [activeSessionPath]);
	useEffect(() => {
		isStreamingRef.current = isStreaming;
	}, [isStreaming]);

	const setMessages = useCallback((next: SetStateAction<ChatMessage[]>) => {
		setRawMessages((prev) => {
			const resolved = typeof next === "function" ? next(prev) : next;
			return normalizeChatMessages(resolved);
		});
	}, []);

	const availableThinkingLevels = useMemo(
		() => getAvailableThinkingLevels(sessionState?.model),
		[sessionState?.model],
	);

	const resetStreamingState = useCallback(() => {
		streamingAssistantId.current = null;
		streamingThinkingId.current = null;
		optimisticUserIdRef.current = null;
	}, []);

	const invalidateTurnEvents = useCallback(() => {
		resetStreamingState();
	}, [resetStreamingState]);

	const addSystemMessage = useCallback((text: string) => {
		setMessages((prev) => [...prev, { id: nextMessageId(), role: "system", content: text }]);
	}, []);

	const persistLastActiveSession = useCallback((path: string | null) => {
		try {
			if (path) {
				sessionStorage.setItem(LAST_ACTIVE_SESSION_KEY, path);
			} else {
				sessionStorage.removeItem(LAST_ACTIVE_SESSION_KEY);
			}
		} catch {
			/* ignore persistence errors */
		}
	}, []);

	const scheduleSessionDashboardRefresh = useCallback(() => {
		if (sessionDashboardRaf.current != null) return;
		sessionDashboardRaf.current = requestAnimationFrame(() => {
			sessionDashboardRaf.current = null;
			void chatApi.fetchSessionState().then((data) => {
				if (data.error) return;
				setSessionState(data);
				if (data.model) setCurrentModelKey(modelKey(data.model));
				if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
				if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);

				if (!activeSessionPathRef.current) {
					return;
				}

				if (backendSessionPathRef.current !== activeSessionPathRef.current) {
					return;
				}

				const sid = data.sessionId != null ? String(data.sessionId) : "";
				if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
					setHeaderTitle(formatSessionTitle(data.sessionName));
				}
			});
		});
	}, []);

	const applySessionState = useCallback((data: SessionState) => {
		if (data.error) return;
		setSessionState(data);
		if (data.model) setCurrentModelKey(modelKey(data.model));
		if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
		if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
		if (data.isCompacting !== undefined) setIsCompacting(data.isCompacting);

		if (!activeSessionPathRef.current) {
			return;
		}

		if (backendSessionPathRef.current !== activeSessionPathRef.current) {
			return;
		}

		const sid = data.sessionId != null ? String(data.sessionId) : "";
		const pathKey = activeSessionPathRef.current;
		setSessions((currentSessions) => {
			const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined;
			if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
				setHeaderTitle(formatSessionTitle(data.sessionName));
			} else if (row) {
				if (row.name && !isMachineSessionLabel(row.name, sid)) {
					setHeaderTitle(formatSessionTitle(row.name));
				} else if (row.firstMessage && row.firstMessage !== "(空)") {
					setHeaderTitle(formatSessionTitle(titleFromFirstUserMessage(row.firstMessage)));
				}
			}
			return currentSessions;
		});
	}, []);

	const fetchSessionStateFull = useCallback(async () => {
		try {
			const data = await chatApi.fetchSessionState();
			applySessionState(data);
		} catch {
			/* ignore */
		}
	}, [applySessionState]);

	const activateSessionBackend = useCallback(async (path: string): Promise<SessionState | null> => {
		const state = await sessionsApi.activateSessionBackend(path);
		setBackendSessionPath(path);
		return state;
	}, []);

	const syncBackendSessionPathFromState = useCallback(async (): Promise<string | null> => {
		const data = await chatApi.fetchSessionState();
		if (data.error) return null;
		const path = data.sessionFile?.trim() || null;
		if (path) {
			setBackendSessionPath(path);
		}
		return path;
	}, []);

	const loadSessions = useCallback(async () => {
		try {
			const data = await sessionsApi.fetchSessions();
			setSessions(data.sessions || []);
		} catch (err) {
			addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
		}
	}, [addSystemMessage]);

	const createAndBindNewSession = useCallback(async (): Promise<string> => {
		const data = await chatApi.createNewSession();
		if (data.error) {
			throw new Error(data.error);
		}
		const path = data.sessionFile?.trim() || (await syncBackendSessionPathFromState());
		if (!path) {
			throw new Error("无法获取会话路径");
		}
		setBackendSessionPath(path);
		void persistLastActiveSession(path);
		void loadSessions();
		return path;
	}, [syncBackendSessionPathFromState, persistLastActiveSession, loadSessions]);

	const clearChat = useCallback(
		(title = "聊天") => {
			invalidateTurnEvents();
			setMessages([]);
			setActiveSessionPath(null);
			setHeaderTitle(title);
			setSessionState(null);
		},
		[invalidateTurnEvents],
	);

	const loadSession = useCallback(
		async (path: string, options?: { skipDashboardRefresh?: boolean }) => {
			if (!compactionReloadPendingRef.current) {
				invalidateTurnEvents();
			}
			setSidebarOpen(false);
			setActiveSessionPath(path);
			setHeaderMeta("加载中...");

			try {
				let payload = sessionCache.current.get(path);
				const poisonName = (p: SessionHistoryPayload) =>
					typeof p?.session?.name === "string" &&
					p.session.name.trim() &&
					isMachineSessionLabel(p.session.name.trim(), String(p.session?.id ?? ""));
				if (payload && poisonName(payload)) {
					sessionCache.current.delete(path);
					payload = undefined;
				}
				if (!payload) {
					payload = await sessionsApi.fetchSessionHistory(path);
					if (payload.error) throw new Error(payload.error);
					sessionCache.current.set(path, payload);
				}

				const sid = payload.session?.id != null ? String(payload.session.id) : "";
				let headerLabel = "";
				if (
					typeof payload.session?.name === "string" &&
					payload.session.name.trim() &&
					!isMachineSessionLabel(payload.session.name.trim(), sid)
				) {
					headerLabel = payload.session.name.trim();
				}
				if (!headerLabel) {
					for (const m of payload.messages || []) {
						if (m.role === "user") {
							const fb = titleFromFirstUserMessage(extractContent(m.content));
							if (fb) {
								headerLabel = fb;
								break;
							}
							if (extractImages(m.content).length) {
								headerLabel = `[${extractImages(m.content).length} 张图片]`;
								break;
							}
						}
					}
				}
				setHeaderTitle(formatSessionTitle(headerLabel));
				setMessages(historyToMessages(payload));
				setHeaderMeta("正在切换会话…");
				try {
					const activationPromise = activateSessionBackend(path);
					sessionActivationPromise.current = activationPromise;
					await activationPromise;
					await fetchSessionStateFull();
				} catch (err) {
					addSystemMessage(`切换会话失败: ${err instanceof Error ? err.message : String(err)}`);
				} finally {
					sessionActivationPromise.current = null;
					setHeaderMeta("");
				}
				void persistLastActiveSession(path);
				if (!options?.skipDashboardRefresh) {
					scheduleSessionDashboardRefresh();
				}
			} catch (err) {
				setHeaderMeta("");
				addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
			}
		},
		[addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents, activateSessionBackend, fetchSessionStateFull],
	);

	const restoreSessionOnBoot = useCallback(() => {
		if (!sharedSessionBootPromise) {
			sharedSessionBootPromise = (async () => {
				const data = await sessionsApi.fetchSessions();
				const list = data.sessions || [];
				setSessions(list);

				let targetPath: string | null = null;
				try {
					const saved = sessionStorage.getItem(LAST_ACTIVE_SESSION_KEY)?.trim();
					if (saved && list.some((s) => s.path === saved)) {
						targetPath = saved;
					}
				} catch {
					/* ignore sessionStorage read errors */
				}

				if (!targetPath) {
					targetPath = pickMostRecentSession(list)?.path ?? null;
				}

				if (targetPath) {
					let activatedState: SessionState | null = null;
					await Promise.all([
						loadSession(targetPath, { skipDashboardRefresh: true }),
						(async () => {
							try {
								activatedState = await activateSessionBackend(targetPath);
							} catch (err) {
								addSystemMessage(
									`恢复上次会话失败: ${err instanceof Error ? err.message : String(err)}`,
								);
							}
						})(),
					]);
					if (activatedState) {
						applySessionState(activatedState);
					} else {
						await fetchSessionStateFull();
					}
					return;
				}

				await createAndBindNewSession();
				invalidateTurnEvents();
				setActiveSessionPath(null);
				setMessages([]);
				setHeaderTitle("聊天");
				sessionActivationPromise.current = null;
				await fetchSessionStateFull();
			})().catch((err) => {
				sharedSessionBootPromise = null;
				addSystemMessage(`初始化会话失败: ${err instanceof Error ? err.message : String(err)}`);
				throw err;
			});
		}
		sessionBootRef.current = sharedSessionBootPromise;
		return sharedSessionBootPromise;
	}, [
		loadSession,
		activateSessionBackend,
		applySessionState,
		fetchSessionStateFull,
		addSystemMessage,
		createAndBindNewSession,
		invalidateTurnEvents,
	]);

	const deleteSessionHandler = useCallback(
		async (path: string) => {
			if (isStreamingRef.current) {
				addSystemMessage("正在回复中，暂不能删除会话");
				return;
			}
			const row = sessions.find((s) => s.path === path);
			const title = formatSessionTitle(row?.name || row?.firstMessage || "该会话");
			if (!confirm(`删除会话「${title}」？此操作不可恢复。`)) return;

			try {
				await sessionsApi.deleteSession(path);
				sessionCache.current.delete(path);
				setSessions((prev) => prev.filter((s) => s.path !== path));
				if (activeSessionPathRef.current === path) {
					clearChat();
					setBackendSessionPath(null);
					sessionActivationPromise.current = null;
					void persistLastActiveSession(null);
				}
			} catch (err) {
				addSystemMessage(`删除会话失败: ${err instanceof Error ? err.message : String(err)}`);
			}
		},
		[sessions, addSystemMessage, clearChat, persistLastActiveSession],
	);

	const renameSessionHandler = useCallback(
		async (path: string, newName: string) => {
			if (isStreamingRef.current) {
				addSystemMessage("正在回复中，暂不能重命名会话");
				return;
			}
			const trimmed = newName.trim();
			if (!trimmed) {
				addSystemMessage("会话名称不能为空");
				return;
			}

			try {
				await sessionsApi.renameSession(path, trimmed);
				sessionCache.current.delete(path);
				setSessions((prev) =>
					prev.map((s) => (s.path === path ? { ...s, name: trimmed } : s)),
				);
				if (activeSessionPathRef.current === path) {
					setHeaderTitle(formatSessionTitle(trimmed));
				}
			} catch (err) {
				addSystemMessage(`重命名失败: ${err instanceof Error ? err.message : String(err)}`);
			}
		},
		[sessions, addSystemMessage],
	);

	const toggleSessionPinHandler = useCallback(
		async (path: string, pinned: boolean) => {
			if (isStreamingRef.current) {
				addSystemMessage("正在回复中，暂不能修改置顶");
				return;
			}

			try {
				await sessionsApi.setSessionPinned(path, pinned);
				await loadSessions();
			} catch (err) {
				addSystemMessage(`${pinned ? "置顶" : "取消置顶"}失败: ${err instanceof Error ? err.message : String(err)}`);
			}
		},
		[addSystemMessage, loadSessions],
	);

	const newSession = useCallback(() => {
		if (isStreamingRef.current) {
			addSystemMessage("正在回复中，暂不能创建新会话");
			return;
		}
		if (newSessionPromise.current) return;

		setBackendSessionPath(null);
		setActiveSessionPath(null);
		sessionActivationPromise.current = null;
		setHeaderMeta("");
		clearChat("新会话");
		void persistLastActiveSession(null);

		const promise = (async () => {
			await createAndBindNewSession();
			await fetchSessionStateFull();
		})();

		newSessionPromise.current = promise;
		void promise
			.catch((err) => {
				setHeaderMeta("");
				addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
			})
			.finally(() => {
				newSessionPromise.current = null;
			});
	}, [addSystemMessage, clearChat, createAndBindNewSession, fetchSessionStateFull, persistLastActiveSession]);

	const ensureSessionReady = useCallback(async (): Promise<boolean> => {
		if (newSessionPromise.current) {
			try {
				await newSessionPromise.current;
			} catch {
				return false;
			}
		}

		if (sessionBootRef.current) {
			try {
				await sessionBootRef.current;
			} catch {
				return false;
			}
		}

		if (!backendSessionPathRef.current && !activeSessionPathRef.current) {
			if (!ensureSessionCreatePromise.current) {
				ensureSessionCreatePromise.current = (async () => {
					try {
						await createAndBindNewSession();
						return true;
					} catch (err) {
						addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
						return false;
					} finally {
						ensureSessionCreatePromise.current = null;
					}
				})();
			}
			const created = await ensureSessionCreatePromise.current;
			if (!created) return false;
		}

		if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) {
			setHeaderMeta("正在切换会话...");
			if (sessionActivationPromise.current) {
				try {
					await sessionActivationPromise.current;
				} catch {
					setHeaderMeta("");
					return false;
				}
			} else {
				try {
					const activationPromise = activateSessionBackend(activeSessionPathRef.current);
					sessionActivationPromise.current = activationPromise;
					await activationPromise;
				} catch (err) {
					setHeaderMeta("");
					addSystemMessage(`切换会话失败: ${err instanceof Error ? err.message : String(err)}`);
					return false;
				} finally {
					sessionActivationPromise.current = null;
				}
			}
			setHeaderMeta("");
		}

		return true;
	}, [activateSessionBackend, addSystemMessage, createAndBindNewSession]);

	const handleSlashChatResponse = useCallback(
		async (response: Awaited<ReturnType<typeof chatApi.sendChat>>) => {
			if (response.action === "copy" && response.message) {
				resetStreamingState();
				try {
					await navigator.clipboard.writeText(response.message);
					addSystemMessage("已复制上一条助手消息到剪贴板");
				} catch {
					addSystemMessage(response.message);
				}
			} else if (response.message && response.action !== "reload_messages") {
				addSystemMessage(response.message);
			}

			if (response.action === "new_session") {
				const path = response.sessionFile?.trim();
				if (path) {
					await loadSession(path);
				} else {
					clearChat();
					await fetchSessionStateFull();
				}
			} else if (response.action === "reload_messages") {
				const path = activeSessionPathRef.current;
				if (path) {
					compactionReloadPendingRef.current = true;
					sessionCache.current.delete(path);
					await loadSession(path);
					compactionReloadPendingRef.current = false;
				}
			} else if (response.action === "reload_sessions") {
				await loadSessions();
				await fetchSessionStateFull();
			}
		},
		[
			addSystemMessage,
			clearChat,
			loadSessions,
			loadSession,
			fetchSessionStateFull,
			resetStreamingState,
			persistLastActiveSession,
		],
	);

	const sendMessage = useCallback(
		async (text: string, images?: ImageContent[], options?: { mode?: "prompt" | "steer" | "follow_up" }) => {
			const trimmed = text.trim();
			const hasImages = Array.isArray(images) && images.length > 0;
			const isSlash = trimmed.startsWith("/");
			const mode = options?.mode ?? "prompt";
			if (!trimmed && !hasImages) return;

			if (!(await ensureSessionReady())) return;

			if (isStreamingRef.current && !isSlash) {
				setMessages((prev) => [
					...prev,
					{
						id: nextMessageId(),
						role: "user",
						content: trimmed,
						...(hasImages ? { images } : {}),
					},
				]);
				try {
					if (mode === "follow_up") {
						await chatApi.sendFollowUp(trimmed, hasImages ? images : undefined);
					} else {
						await chatApi.sendSteer(trimmed, hasImages ? images : undefined);
					}
				} catch (err) {
					addSystemMessage(`发送失败: ${err instanceof Error ? err.message : String(err)}`);
				}
				return;
			}

			const skipOptimisticUser = isSlash;

			if (!skipOptimisticUser) {
				const userId = nextMessageId();
				optimisticUserIdRef.current = userId;
				setMessages((prev) => [
					...prev,
					{
						id: userId,
						role: "user",
						content: trimmed,
						...(hasImages ? { images } : {}),
					},
				]);
			}

			const persistPathAfterSend = () => {
				const persistPath = resolvePersistSessionPath(
					backendSessionPathRef.current,
					activeSessionPathRef.current,
				);
				if (persistPath) {
					persistLastActiveSession(persistPath);
				}
			};

			if (isSlash) {
				try {
					const response = await chatApi.sendChat(trimmed, hasImages ? images : undefined);
					await handleSlashChatResponse(response);
					persistPathAfterSend();
				} catch (err) {
					invalidateTurnEvents();
					const failedId = optimisticUserIdRef.current;
					optimisticUserIdRef.current = null;
					if (failedId) {
						setMessages((prev) => prev.filter((msg) => msg.id !== failedId));
					}
					addSystemMessage(`发送失败: ${err instanceof Error ? err.message : String(err)}`);
				}
				return;
			}

			void chatApi
				.sendChat(trimmed, hasImages ? images : undefined)
				.then((response) => {
					persistPathAfterSend();
					if (response.slash) {
						void handleSlashChatResponse(response);
					}
				})
				.catch((err) => {
					invalidateTurnEvents();
					const failedId = optimisticUserIdRef.current;
					optimisticUserIdRef.current = null;
					if (failedId) {
						setMessages((prev) => prev.filter((msg) => msg.id !== failedId));
					}
					addSystemMessage(`发送失败: ${err instanceof Error ? err.message : String(err)}`);
				});
		},
		[
			ensureSessionReady,
			addSystemMessage,
			persistLastActiveSession,
			handleSlashChatResponse,
			invalidateTurnEvents,
		],
	);

	const runBashCommand = useCallback(
		async (rawInput: string) => {
			const parsed = parseBashInput(rawInput);
			if (!parsed) return;

			if (!(await ensureSessionReady())) return;

			const bashMsgId = nextMessageId();
			activeBashMsgIdRef.current = bashMsgId;
			setMessages((prev) => [
				...prev,
				{ id: nextMessageId(), role: "user", content: parsed.display },
				{
					id: bashMsgId,
					role: "bash",
					content: "",
					bashCommand: parsed.command,
					bashExcludeFromContext: parsed.excludeFromContext,
					streaming: true,
				},
			]);

			try {
				const result = await chatApi.runBash(parsed.command, {
					excludeFromContext: parsed.excludeFromContext,
				});
				activeBashMsgIdRef.current = null;
				setMessages((prev) =>
					prev.map((msg) =>
						msg.id === bashMsgId
							? {
									...msg,
									content: formatBashOutput(result),
									bashExitCode: result.exitCode,
									streaming: false,
								}
							: msg,
					),
				);
				const persistPath = resolvePersistSessionPath(
					backendSessionPathRef.current,
					activeSessionPathRef.current,
				);
				if (persistPath) {
					sessionCache.current.delete(persistPath);
					void persistLastActiveSession(persistPath);
				}
			} catch (err) {
				activeBashMsgIdRef.current = null;
				setMessages((prev) =>
					prev.map((msg) =>
						msg.id === bashMsgId
							? {
									...msg,
									content: `执行失败: ${err instanceof Error ? err.message : String(err)}`,
									streaming: false,
								}
							: msg,
					),
				);
			}
		},
		[ensureSessionReady, persistLastActiveSession],
	);

	const abortStream = useCallback(async () => {
		if (!isStreamingRef.current && !isCompacting) return;
		try {
			await chatApi.abortChat();
		} catch {
			/* ignore */
		}
	}, [isCompacting]);

	const abortRetry = useCallback(async () => {
		try {
			await chatApi.abortRetry();
			setRetryState(null);
		} catch (err) {
			addSystemMessage(`取消重试失败: ${err instanceof Error ? err.message : String(err)}`);
		}
	}, [addSystemMessage]);

	const toggleToolsExpanded = useCallback(() => {
		setToolsExpanded((prev) => !prev);
	}, []);

	const respondExtensionDialog = useCallback((response: Record<string, unknown>) => {
		if (!extensionDialog) return;
		void chatApi.sendExtensionUiResponse(extensionDialog.id, response).catch(() => {});
		setExtensionDialog(null);
	}, [extensionDialog]);

	const dismissExtensionDialog = useCallback(() => {
		if (!extensionDialog) return;
		void chatApi
			.sendExtensionUiResponse(extensionDialog.id, { cancelled: true })
			.catch(() => {});
		setExtensionDialog(null);
	}, [extensionDialog]);

	const handleExtensionUiRequest = useCallback(
		(ev: Record<string, unknown>) => {
			const method = String(ev.method || "");
			if (method === "notify") {
				const notifyType = ev.notifyType === "error" ? "error" : ev.notifyType === "warning" ? "warning" : "info";
				const message = String(ev.message || "");
				if (!message || notifyType === "info") return;
				addSystemMessage(`[${notifyType}] ${message}`);
				return;
			}
			if (method === "setStatus") {
				const key = String(ev.statusKey || "status");
				const text = ev.statusText != null ? stripAnsi(String(ev.statusText)) : "";
				setExtensionStatuses((prev) => {
					const next = { ...prev };
					if (!text) delete next[key];
					else next[key] = text;
					return next;
				});
				return;
			}
			if (method === "setWidget") {
				const key = String(ev.widgetKey || "widget");
				const lines = Array.isArray(ev.widgetLines)
					? ev.widgetLines
							.filter((line): line is string => typeof line === "string")
							.map((line) => stripAnsi(line))
					: undefined;
				setExtensionWidgets((prev) => {
					const next = { ...prev };
					if (!lines?.length) delete next[key];
					else next[key] = lines;
					return next;
				});
				return;
			}
			if (method === "select" || method === "confirm" || method === "input" || method === "editor") {
				setExtensionDialog({
					id: String(ev.id || ""),
					method,
					title: String(ev.title || method),
					message: typeof ev.message === "string" ? ev.message : undefined,
					options: Array.isArray(ev.options)
						? ev.options.filter((option): option is string => typeof option === "string")
						: undefined,
					prefill: typeof ev.prefill === "string" ? ev.prefill : undefined,
				});
			}
		},
		[addSystemMessage],
	);

	const applyModelSelection = useCallback(
		async (key: string) => {
			if (!key || isApplyingModelSettings) return;
			const [provider, ...idParts] = key.split("/");
			const modelId = idParts.join("/");
			if (!provider || !modelId) return;

			setIsApplyingModelSettings(true);
			setHeaderMeta("正在切换模型");
			try {
				await chatApi.setModel(provider, modelId);
				await fetchSessionStateFull();
				setHeaderMeta("模型已切换");
				setTimeout(() => setHeaderMeta(""), 1000);
			} catch (err) {
				setHeaderMeta("");
				addSystemMessage(`切换模型失败: ${err instanceof Error ? err.message : String(err)}`);
				await fetchSessionStateFull();
			} finally {
				setIsApplyingModelSettings(false);
				await fetchSessionStateFull();
			}
		},
		[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
	);

	const applyThinkingSelection = useCallback(
		async (level: string) => {
			if (isApplyingModelSettings) return;
			setIsApplyingModelSettings(true);
			setHeaderMeta("正在设置推理强度");
			try {
				await chatApi.setThinkingLevel(level);
				await fetchSessionStateFull();
				setHeaderMeta("推理强度已设置");
				setTimeout(() => setHeaderMeta(""), 1000);
			} catch (err) {
				setHeaderMeta("");
				addSystemMessage(`设置推理强度失败: ${err instanceof Error ? err.message : String(err)}`);
				await fetchSessionStateFull();
			} finally {
				setIsApplyingModelSettings(false);
				await fetchSessionStateFull();
			}
		},
		[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
	);

	const processStreamEvent = useCallback(
		(ev: SseEvent) => {
			switch (ev.type) {
				case "agent_start":
					setIsStreaming(true);
					setConnected(true);
					streamingAssistantId.current = null;
					streamingThinkingId.current = null;
					scheduleSessionDashboardRefresh();
					break;

				case "agent_end":
					setIsStreaming(false);
					setConnected(true);
					setMessages((prev) => finalizeStreamingTurn(prev));
					streamingAssistantId.current = null;
					streamingThinkingId.current = null;
					if (backendSessionPathRef.current) {
						sessionCache.current.delete(backendSessionPathRef.current);
					}
					resetStreamingState();
					setTimeout(() => void loadSessions(), 1000);
					void fetchSessionStateFull();
					break;

				case "message_start": {
					const m = ev.message;
					if (m.role === "user") {
						const userText = extractContent(m.content);
						const userImages = extractImages(m.content);
						if (optimisticUserIdRef.current) {
							const optId = optimisticUserIdRef.current;
							optimisticUserIdRef.current = null;
							setMessages((prev) =>
								prev.map((msg) =>
									msg.id === optId
										? {
												...msg,
												content: userText || msg.content,
												images: userImages.length ? userImages : msg.images,
											}
										: msg,
								),
							);
							break;
						}
						setMessages((prev) => [
							...prev,
							{
								id: nextMessageId(),
								role: "user",
								content: userText,
								...(userImages.length ? { images: userImages } : {}),
							},
						]);
					}
					break;
				}

				case "message_update": {
					const m = ev.message;
					if (m.role === "assistant") {
						const thinking = extractThinking(m.content);
						const assistantText = extractContent(m.content);
						setMessages((prev) =>
							applyAssistantMessageUpdate(
								prev,
								thinking,
								assistantText,
								streamingThinkingId,
								streamingAssistantId,
							),
						);
					}
					break;
				}

				case "message_end": {
					const m = ev.message;
					if (m.role === "assistant") {
						const thinking = extractThinking(m.content);
						const assistantText = extractContent(m.content);
						const toolCalls = getToolCalls(m.content);
						setMessages((prev) =>
							finalizeAssistantMessageEnd(
								prev,
								thinking,
								assistantText,
								toolCalls,
								resolveTurnThinkingId(prev, streamingThinkingId.current),
								resolveTurnAssistantId(prev, streamingAssistantId.current),
							),
						);
						streamingAssistantId.current = null;
						streamingThinkingId.current = null;
						scheduleSessionDashboardRefresh();
					}
					break;
				}

				case "tool_execution_start": {
					const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
					const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
					setMessages((prev) => {
						const existingIdx = tid
							? prev.findIndex((msg) => msg.toolCallId === tid)
							: -1;
						if (existingIdx >= 0) {
							return prev.map((msg, i) =>
								i === existingIdx ? { ...msg, content: label } : msg,
							);
						}
						return [
							...prev,
							{
								id: tid ? `tool-${tid}` : nextMessageId(),
								role: "tool_call" as const,
								content: label,
								toolCallId: tid,
							},
						];
					});
					break;
				}

				case "tool_execution_update":
				case "tool_execution_end": {
					const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
					const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
					const full = buildToolCallUpdateText(ev, isError);
					setMessages((prev) => {
						let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
						if (idx < 0) {
							const toolCalls = prev.filter((m) => m.role === "tool_call");
							if (toolCalls.length > 0) {
								idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
							}
						}
						if (idx < 0) return prev;
						return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
					});
					break;
				}

				case "compaction_start":
					setIsCompacting(true);
					break;

				case "compaction_end": {
					setIsCompacting(false);
					if (ev.aborted) {
						if (ev.errorMessage) addSystemMessage(ev.errorMessage);
						break;
					}
					const activePath = activeSessionPathRef.current;
					const summaryText = ev.result?.summary?.trim();
					if (summaryText) {
						setMessages((prev) => [
							...prev,
							{
								id: nextMessageId(),
								role: "compaction_summary" as const,
								content: summaryText,
							},
						]);
					} else if (activePath && !compactionReloadPendingRef.current) {
						sessionCache.current.delete(activePath);
						void loadSession(activePath);
					}
					if (ev.willRetry) {
						addSystemMessage("压缩完成，正在重试当前请求…");
					}
					if (ev.errorMessage) {
						addSystemMessage(ev.errorMessage);
					} else if (ev.result?.tokensBefore) {
						addSystemMessage(
							`上下文已压缩（约 ${Math.round(ev.result.tokensBefore / 1000)}k tokens）`,
						);
					}
					break;
				}

				case "queue_update":
					setPendingSteering(Array.isArray(ev.steering) ? ev.steering.map(String) : []);
					setPendingFollowUp(Array.isArray(ev.followUp) ? ev.followUp.map(String) : []);
					break;

				case "auto_retry_start":
					setRetryState({
						active: true,
						attempt: ev.attempt,
						maxAttempts: ev.maxAttempts,
						delayMs: ev.delayMs,
						startedAt: Date.now(),
						errorMessage: ev.errorMessage,
					});
					break;

				case "auto_retry_end":
					setRetryState(null);
					if (ev.success === false && ev.finalError) {
						addSystemMessage(`自动重试失败: ${ev.finalError}`);
					}
					break;

				case "extension_error":
					addSystemMessage(
						`扩展错误${ev.extensionPath ? ` (${ev.extensionPath})` : ""}: ${ev.error || "unknown"}`,
					);
					break;

				case "bash_update": {
					const bashId = activeBashMsgIdRef.current;
					if (!bashId) break;
					const output = typeof ev.partialOutput === "string" ? ev.partialOutput : "";
					setMessages((prev) =>
						prev.map((msg) => (msg.id === bashId ? { ...msg, content: output, streaming: true } : msg)),
					);
					break;
				}
			}
		},
		[
			scheduleSessionDashboardRefresh,
			loadSessions,
			loadSession,
			fetchSessionStateFull,
			addSystemMessage,
			resetStreamingState,
		],
	);

	const handleSseEvent = useCallback(
		(ev: SseEvent) => {
			switch (ev.type) {
				case "connected":
					setConnected(true);
					if (ev.isStreaming !== undefined) {
						setIsStreaming(ev.isStreaming);
					}
					for (const replayEv of ev.replay ?? []) {
						if (replayEv.type === "extension_ui_request") {
							handleExtensionUiRequest(replayEv as Record<string, unknown>);
						} else {
							processStreamEvent(replayEv);
						}
					}
					void fetchSessionStateFull();
					break;

				case "prompt_rejected": {
					invalidateTurnEvents();
					const failedId = optimisticUserIdRef.current;
					optimisticUserIdRef.current = null;
					if (failedId) {
						setMessages((prev) => prev.filter((msg) => msg.id !== failedId));
					}
					addSystemMessage(`发送失败: ${ev.error}`);
					break;
				}

				case "extension_ui_request":
					handleExtensionUiRequest(ev as Record<string, unknown>);
					break;

				default:
					processStreamEvent(ev);
			}
		},
		[
			processStreamEvent,
			fetchSessionStateFull,
			invalidateTurnEvents,
			addSystemMessage,
			handleExtensionUiRequest,
		],
	);

	useEffect(() => {
		syncViewportHeight();
		const onResize = () => {
			syncViewportHeight();
			if (window.innerWidth > 768) setSidebarOpen(false);
		};
		window.addEventListener("resize", onResize);
		window.visualViewport?.addEventListener("resize", syncViewportHeight);
		window.visualViewport?.addEventListener("scroll", syncViewportHeight);
		window.addEventListener("orientationchange", syncViewportHeight);

		const bootstrap = {
			sessions: false,
			models: false,
			sessionState: false,
			sse: false,
		};

		const tryMarkBootstrapReady = () => {
			if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) {
				markRouteReady("chat");
			}
		};

		void chatApi
			.fetchModels()
			.then((data) => setAvailableModels(data.models || []))
			.catch((err) => {
				addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
			})
			.finally(() => {
				bootstrap.models = true;
				tryMarkBootstrapReady();
			});

		let eventSource: EventSource | null = null;
		let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
		let sseBootstrapTimer: ReturnType<typeof setTimeout> | null = null;

		const finishSseBootstrap = () => {
			if (bootstrap.sse) return;
			bootstrap.sse = true;
			tryMarkBootstrapReady();
		};

		sseBootstrapTimer = setTimeout(() => {
			if (!bootstrap.sse) {
				addSystemMessage("无法连接实时事件流，请确认 WebUI 已启动且穿透域名可访问");
				finishSseBootstrap();
			}
		}, 10_000);

		const connect = () => {
			if (eventSource) eventSource.close();
			eventSource = new EventSource(apiUrl("/api/events"));
			eventSource.onopen = () => {
				setConnected(true);
				finishSseBootstrap();
			};
			eventSource.onmessage = (e) => {
				try {
					handleSseEvent(JSON.parse(e.data) as SseEvent);
				} catch {
					/* ignore */
				}
			};
			eventSource.onerror = () => {
				setConnected(false);
				if (reconnectTimer) clearTimeout(reconnectTimer);
				reconnectTimer = setTimeout(connect, 2000);
			};
		};

		connect();

		void restoreSessionOnBoot()
			.catch(() => {})
			.finally(() => {
				bootstrap.sessions = true;
				bootstrap.sessionState = true;
				tryMarkBootstrapReady();
			});

		return () => {
			window.removeEventListener("resize", onResize);
			window.visualViewport?.removeEventListener("resize", syncViewportHeight);
			window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
			window.removeEventListener("orientationchange", syncViewportHeight);
			if (reconnectTimer) clearTimeout(reconnectTimer);

[Showing lines 1-1685 of 1743 (50.0KB limit). Use offset=1686 to continue.]


让我继续读取 ChatContext 的剩余部分，然后读取 backend 的路径解析和配置。


			if (sseBootstrapTimer) clearTimeout(sseBootstrapTimer);
			eventSource?.close();
			sharedSessionBootPromise = null;
		};
	}, [restoreSessionOnBoot, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady]);

	const value: ChatContextValue = {
		connected,
		isStreaming,
		messages,
		sessions,
		activeSessionPath,
		headerTitle,
		headerMeta,
		sidebarOpen,
		sessionState,
		availableModels,
		currentModelKey,
		thinkingLevel,
		availableThinkingLevels,
		isApplyingModelSettings,
		pendingSteering,
		pendingFollowUp,
		retryState,
		isCompacting,
		extensionWidgets,
		extensionStatuses,
		extensionDialog,
		toolsExpanded,
		toggleSidebar: () => setSidebarOpen((o) => !o),
		closeSidebar: () => setSidebarOpen(false),
		loadSessions,
		loadSession,
		deleteSession: deleteSessionHandler,
		renameSession: renameSessionHandler,
		toggleSessionPin: toggleSessionPinHandler,
		newSession,
		sendMessage,
		runBashCommand,
		abortStream,
		abortRetry,
		toggleToolsExpanded,
		respondExtensionDialog,
		dismissExtensionDialog,
		applyModelSelection,
		applyThinkingSelection,
		addSystemMessage,
	};

	return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}

export function useChatContext(): ChatContextValue {
	const ctx = useContext(ChatContext);
	if (!ctx) throw new Error("useChatContext must be used within ChatProvider");
	return ctx;
}


现在让我读取后端的关键配置和路径解析文件，以及数据库初始化。


import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const configDir = dirname(fileURLToPath(import.meta.url));
const backendDir = dirname(configDir);
const extensionRoot = resolve(backendDir, "..");

export interface WebUiPaths {
	extensionRoot: string;
	repoRoot: string;
	agentDir: string;
	publicDir: string;
	pidFile: string;
	sessionsDir: string;
	agentExtensionsDir: string;
	agentNpmNodeModules: string;
	agentSettingsFile: string;
	systemPromptFile: string;
	modelsConfigFile: string;
	mcpCacheFile: string;
	mcpConfigFile: string;
	piCliDist: string;
}

export function createWebUiPaths(): WebUiPaths {
	const repoRoot = process.cwd();
	const agentDir = resolve(process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent"));

	return {
		extensionRoot,
		repoRoot,
		agentDir,
		publicDir: join(extensionRoot, "frontend", "dist"),
		pidFile: join(extensionRoot, ".webui.pid"),
		sessionsDir: join(agentDir, "sessions"),
		agentExtensionsDir: join(agentDir, "extensions"),
		agentNpmNodeModules: join(agentDir, "npm", "node_modules"),
		agentSettingsFile: join(agentDir, "settings.json"),
		systemPromptFile: join(agentDir, "AGENTS.md"),
		modelsConfigFile: join(agentDir, "models.json"),
		mcpCacheFile: join(agentDir, "mcp-cache.json"),
		mcpConfigFile: join(agentDir, "mcp.json"),
		piCliDist: join(repoRoot, "packages", "coding-agent", "dist", "cli.js"),
	};
}

export type PiRpcLaunchMode = "dist" | "tsx";

export function resolvePiRpcLaunch(
	paths: WebUiPaths,
): { command: string; args: string[]; mode: PiRpcLaunchMode } {
	const rpcArgs = ["--mode", "rpc"];
	if (existsSync(paths.piCliDist)) {
		return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
	}

	const tsxCli = join(paths.repoRoot, "node_modules", "tsx", "dist", "cli.mjs");
	const piCliSrc = join(paths.repoRoot, "packages", "coding-agent", "src", "cli.ts");
	if (existsSync(tsxCli) && existsSync(piCliSrc)) {
		return { command: process.execPath, args: [tsxCli, piCliSrc, ...rpcArgs], mode: "tsx" };
	}

	const buildHint =
		process.platform === "win32"
			? "请先运行: npm run build 或 pi-built.bat"
			: "请先运行: npm run build 或 sproutclaw build";
	throw new Error(`[webui] pi 未找到: ${paths.piCliDist}。${buildHint}`);
}


export function parsePort(argv: string[] = process.argv.slice(2)): number {
	const idx = argv.indexOf("--port");
	const raw = idx !== -1 ? argv[idx + 1] : "19133";
	const port = parseInt(raw || "19133", 10);
	if (!Number.isFinite(port) || port < 1 || port > 65535) {
		throw new Error(`无效端口: ${raw}`);
	}
	return port;
}


import type { ServerResponse } from "node:http";
import type { WebUiPaths } from "../config/paths.ts";
import type { WebuiDbInfo } from "../db/index.ts";

export type SendCmd = (command: Record<string, unknown>) => Promise<any>;

export interface SubmitPromptOptions {
	message: string;
	images?: Array<{ type: "image"; data: string; mimeType: string }>;
	streamingBehavior?: "steer" | "followUp";
}

export interface RunSnapshot {
	isStreaming: boolean;
	sessionFile?: string;
	replay: Record<string, unknown>[];
}

export interface WebUiContext {
	config: {
		paths: WebUiPaths;
		port: number;
	};
	rpc: {
		sendCmd: SendCmd;
		submitPrompt: (options: SubmitPromptOptions) => void;
		sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
		getRunSnapshot: () => RunSnapshot;
		connectSseClient: (res: ServerResponse) => void;
		removeSseClient: (res: ServerResponse) => void;
	};
	db: WebuiDbInfo;
}