feat: update webui extensions, models, and agent config
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled

This commit is contained in:
2026-06-10 21:45:53 +08:00
parent cf5edd6394
commit d51a055d78
64 changed files with 3238 additions and 1665 deletions

View File

@@ -77,6 +77,17 @@ function installCommand(name: string, script: string): boolean {
}
export default function (pi: ExtensionAPI) {
if (process.platform === "win32") {
// On Windows, /usr/local/bin doesn't exist. Use pi-built.bat from the repo root instead.
pi.registerCommand("install-commands", {
description: "N/A on Windows: use pi-built.bat from the repo root",
handler: async (_args, ctx) => {
ctx.ui.notify("Windows 下无需安装全局命令,请直接使用项目根目录的 pi-built.bat", "info");
},
});
return;
}
for (const [name, launcher] of Object.entries(COMMAND_LAUNCHERS)) {
const created = installCommand(name, commandScript(launcher));
if (created) {

View File

@@ -45,12 +45,25 @@ export function createWebUiPaths(): WebUiPaths {
};
}
export function resolvePiRpcLaunch(paths: WebUiPaths): { command: string; args: string[]; mode: "dist" } {
export type PiRpcLaunchMode = "dist" | "tsx";
export function resolvePiRpcLaunch(
paths: WebUiPaths,
): { command: string; args: string[]; mode: PiRpcLaunchMode } {
const rpcArgs = ["--mode", "rpc"];
if (!existsSync(paths.piCliDist)) {
throw new Error(
`[webui] 构建版 sproutclaw 未找到: ${paths.piCliDist}。请先运行: sproutclaw build`,
);
if (existsSync(paths.piCliDist)) {
return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
}
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}`);
}

View File

@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { applyCorsHeaders } from "./cors.ts";
import { serveStatic } from "./static.ts";
import { handleChatRoute } from "../routes/chat.ts";
import { handleExtensionUiRoute } from "../routes/extension-ui.ts";
import { handleCommandsRoute } from "../routes/commands.ts";
import { handleModelsRoute } from "../routes/models.ts";
import { handleSessionsRoute } from "../routes/sessions.ts";
@@ -24,6 +25,7 @@ export function createWebUiServer(ctx: WebUiContext) {
}
if (handleChatRoute(req, res, ctx, pathname)) return;
if (handleExtensionUiRoute(req, res, ctx, pathname)) return;
if (handleSessionsRoute(req, res, ctx, pathname)) return;
if (handleModelsRoute(req, res, ctx, pathname)) return;
if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;

View File

@@ -1,7 +1,8 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { gzipSync } from "node:zlib";
import type { ServerResponse, IncomingMessage } from "node:http";
import { isPathInsideRoot } from "../utils/paths.ts";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
@@ -40,7 +41,9 @@ export function serveStatic(
): void {
const file = urlPath === "/" ? "/index.html" : urlPath;
const full = join(publicDir, file);
if (!full.startsWith(publicDir)) {
const resolvedPublic = resolve(publicDir);
const resolvedFull = resolve(full);
if (!isPathInsideRoot(resolvedPublic, resolvedFull) && resolvedFull !== resolvedPublic) {
res.writeHead(403);
res.end("Forbidden");
return;

View File

@@ -13,6 +13,7 @@
*/
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { arch, hostname, platform, release, type } from "node:os";
import { parsePort } from "./config/cli.ts";
import { createWebUiPaths } from "./config/paths.ts";
import { closeWebuiDatabase, initWebuiDatabase } from "./db/index.ts";
@@ -47,18 +48,27 @@ function shutdown(exitCode = 0): never {
const webuiDb = initWebuiDatabase(paths.extensionRoot);
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
console.log(`[webui] 运行环境: Node.js ${process.version} | ${type()} ${release()} (${platform()}/${arch()}) | 主机: ${hostname()}`);
process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
writePidFile();
const piClient = createPiClient(paths, () => shutdown(1));
const piClient = createPiClient(paths, (code) => {
if (code !== 0 && code !== null) {
console.error(`[webui] pi RPC 进程异常退出 (code=${code})WebUI 即将关闭`);
setTimeout(() => shutdown(1), 500);
return;
}
shutdown(1);
});
const ctx: WebUiContext = {
config: { paths, port },
rpc: {
sendCmd: piClient.sendCmd,
submitPrompt: piClient.submitPrompt,
sendExtensionUiResponse: piClient.sendExtensionUiResponse,
getRunSnapshot: piClient.getRunSnapshot,
connectSseClient: piClient.connectSseClient,
removeSseClient: piClient.removeSseClient,
@@ -79,5 +89,5 @@ server.on("error", (err: NodeJS.ErrnoException) => {
server.listen(port, "0.0.0.0", () => {
console.log(`[webui] HTTP 服务已启动: http://localhost:${port}`);
console.log(`[webui] 局域网访问: http://smallmengya:${port}`);
console.log(`[webui] 局域网访问: http://${hostname()}:${port}`);
});

View File

@@ -4,6 +4,17 @@ 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,
@@ -14,32 +25,66 @@ export function handleChatRoute(
if (req.method === "POST" && pathname === "/api/chat") {
void readBody(req)
.then(async ({ message, images }) => {
.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`);
if (!imgs?.length && msg.trim().startsWith("/")) {
const slash = await dispatchSlashCommand(msg, sendCmd);
if (slash?.handled) {
return json(res, { ok: true, slash: true, ...slash });
}
const slash = await trySlashDispatch(msg, imgs, sendCmd);
if (slash) {
return json(res, { ok: true, slash: true, ...slash });
}
submitPrompt({ message: msg, images: imgs });
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 }) => {
.then(async ({ command, excludeFromContext }) => {
const cmd = typeof command === "string" ? command.trim() : "";
if (!cmd) throw new Error("命令不能为空");
const result = await sendCmd({ type: "bash", command: cmd });
const result = await sendCmd({
type: "bash",
command: cmd,
excludeFromContext: excludeFromContext === true,
});
if (!result.success) throw new Error(result.error || "命令执行失败");
json(res, result.data);
})
@@ -72,5 +117,12 @@ export function handleChatRoute(
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;
}

View File

@@ -0,0 +1,25 @@
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;
}

View File

@@ -9,6 +9,7 @@ import {
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(
@@ -103,7 +104,11 @@ export function handleSessionsRoute(
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 === sessionPath) {
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 || "同步当前会话名称失败");
}

View File

@@ -1,4 +1,5 @@
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";
@@ -202,5 +203,23 @@ export function handleSettingsRoute(
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;
}

View File

@@ -6,6 +6,7 @@ import type { RunSnapshot, SendCmd, SubmitPromptOptions } from "../types/context
export interface PiClient {
sendCmd: SendCmd;
submitPrompt: (options: SubmitPromptOptions) => void;
sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
getRunSnapshot: () => RunSnapshot;
connectSseClient: (res: ServerResponse) => void;
removeSseClient: (res: ServerResponse) => void;
@@ -23,14 +24,32 @@ const BUFFERED_EVENT_TYPES = new Set([
"tool_execution_end",
"compaction_start",
"compaction_end",
"queue_update",
"auto_retry_start",
"auto_retry_end",
"bash_update",
]);
const DEFAULT_CMD_TIMEOUT_MS = 60_000;
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_000;
const PI_STDERR_BUFFER_MAX = 8192;
function formatSpawnArgs(args: string[]): string {
return args.map((arg) => JSON.stringify(arg)).join(" ");
}
function summarizeStderr(buffer: string): string {
const trimmed = buffer.trim();
if (!trimmed) return "(无 stderr 输出)";
const lines = trimmed.split(/\r?\n/).filter(Boolean);
return lines.slice(-8).join("\n");
}
export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
const piLaunch = resolvePiRpcLaunch(paths);
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
console.log(
`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${formatSpawnArgs(piLaunch.args)}`,
);
const pi = spawn(piLaunch.command, piLaunch.args, {
cwd: paths.repoRoot,
@@ -41,9 +60,20 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
},
});
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
// Keep stdin pipe open; closing it on Windows can trigger RPC shutdown via stdin "end".
pi.stdin.write("");
let stderrBuffer = "";
pi.stderr.on("data", (data: Buffer) => {
const chunk = data.toString();
stderrBuffer = (stderrBuffer + chunk).slice(-PI_STDERR_BUFFER_MAX);
process.stderr.write(`[pi] ${data}`);
});
pi.on("exit", (code) => {
console.log(`[webui] pi 退出, code=${code}`);
if (code !== 0 && code !== null) {
console.error(`[webui] pi RPC 启动失败 (code=${code}):\n${summarizeStderr(stderrBuffer)}`);
}
onExit(code);
});
@@ -157,13 +187,16 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
function submitPrompt(options: SubmitPromptOptions): void {
const id = `req_${++reqId}`;
const line =
JSON.stringify({
type: "prompt",
message: options.message,
images: options.images,
id,
}) + "\n";
const payload: Record<string, unknown> = {
type: "prompt",
message: options.message,
images: options.images,
id,
};
if (options.streamingBehavior) {
payload.streamingBehavior = options.streamingBehavior;
}
const line = JSON.stringify(payload) + "\n";
registerPending(
id,
{
@@ -180,6 +213,11 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
pi.stdin.write(line);
}
function sendExtensionUiResponse(id: string, response: Record<string, unknown>): void {
const line = JSON.stringify({ type: "extension_ui_response", id, ...response }) + "\n";
pi.stdin.write(line);
}
function connectSseClient(res: ServerResponse): void {
const snapshot = getRunSnapshot();
res.write(
@@ -195,6 +233,7 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
return {
sendCmd,
submitPrompt,
sendExtensionUiResponse,
getRunSnapshot,
connectSseClient,
removeSseClient: (res) => sseClients.delete(res),

View File

@@ -9,6 +9,7 @@ import {
} from "node:fs";
import { join, resolve } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
import { isPathInsideRoot } from "../utils/paths.ts";
import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts";
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
@@ -140,9 +141,15 @@ export function readSessionMessages(filePath: string): unknown[] {
}
export function resolveSessionFile(paths: WebUiPaths, filePath: string): string {
const resolved = resolve(filePath);
if (typeof filePath !== "string" || !filePath.trim()) {
throw new Error("无效会话路径");
}
const sessionsRoot = resolve(paths.sessionsDir);
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
const resolved = resolve(filePath.trim());
if (!resolved.toLowerCase().endsWith(".jsonl")) {
throw new Error("无效会话路径");
}
if (!isPathInsideRoot(sessionsRoot, resolved)) {
throw new Error("无效会话路径");
}
return resolved;

View File

@@ -7,6 +7,7 @@ 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 {
@@ -23,6 +24,7 @@ export interface WebUiContext {
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;

View File

@@ -0,0 +1,22 @@
import { relative, resolve } from "node:path";
/** True when `candidate` resolves to a path under `rootDir` (not the root dir itself). */
export function isPathInsideRoot(rootDir: string, candidate: string): boolean {
const root = resolve(rootDir);
const target = resolve(candidate);
const rel = relative(root, target);
if (rel === "" || rel === ".") return false;
if (rel.startsWith("..")) return false;
// Cross-drive relative paths on Windows are absolute (e.g. D:\other\...)
if (/^[A-Za-z]:[\\/]/.test(rel)) return false;
return true;
}
export function isSameResolvedPath(a: string, b: string): boolean {
const left = resolve(a);
const right = resolve(b);
if (process.platform === "win32") {
return left.toLowerCase() === right.toLowerCase();
}
return left === right;
}

View File

@@ -13,18 +13,43 @@ export interface ChatResponse {
error?: string;
}
export function sendChat(message: string, images?: ImageContent[]): Promise<ChatResponse> {
return apiPost("/api/chat", { message, images });
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 runBash(command: string): Promise<BashResult> {
return apiPost("/api/bash", { command });
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");
}

View File

@@ -1,5 +1,5 @@
import { apiGet, apiPost } from "./client";
import type { AvatarSettings, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
import type { AvatarSettings, EnvironmentInfo, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
export function fetchSettings(): Promise<SettingsData> {
return apiGet("/api/settings");
@@ -50,3 +50,7 @@ export function saveModelsConfig(modelsConfig: string): Promise<{ modelsConfigPa
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
return apiPost("/api/settings/avatars", avatars);
}
export function fetchEnvironment(): Promise<EnvironmentInfo> {
return apiGet("/api/environment");
}

View File

@@ -2,6 +2,15 @@
margin: 0;
}
.blockDim .header {
opacity: 0.75;
}
.blockDim .command,
.blockDim .output {
color: #6b7280;
}
.header {
display: flex;
align-items: center;

View File

@@ -5,14 +5,21 @@ interface BashOutputBlockProps {
content: string;
exitCode?: number;
streaming?: boolean;
excludeFromContext?: boolean;
}
export function BashOutputBlock({ command, content, exitCode, streaming }: BashOutputBlockProps) {
export function BashOutputBlock({
command,
content,
exitCode,
streaming,
excludeFromContext,
}: BashOutputBlockProps) {
const hasOutput = Boolean(content.trim());
const showExit = exitCode !== undefined && exitCode !== 0 && !streaming;
return (
<div className={styles.block}>
<div className={`${styles.block} ${excludeFromContext ? styles.blockDim : ""}`}>
<div className={styles.header}>
<span className={styles.icon} aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">

View File

@@ -21,7 +21,14 @@ import { SlashCommandMenu } from "./SlashCommandMenu";
import styles from "./ChatInput.module.css";
export function ChatInput() {
const { isStreaming, sendMessage, runBashCommand, addSystemMessage } = useChatContext();
const {
isStreaming,
sendMessage,
runBashCommand,
addSystemMessage,
abortStream,
toggleToolsExpanded,
} = useChatContext();
const [value, setValue] = useState("");
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
const [dragOver, setDragOver] = useState(false);
@@ -47,6 +54,22 @@ export function ChatInput() {
};
}, [addSystemMessage]);
useEffect(() => {
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (!inputRef.current || document.activeElement !== inputRef.current) return;
if (e.key === "Escape" && isStreaming) {
e.preventDefault();
void abortStream();
}
if (e.key === "o" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
toggleToolsExpanded();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isStreaming, abortStream, toggleToolsExpanded]);
const slashContext = useMemo(() => parseSlashCommandInput(value), [value]);
const filteredSlashCommands = useMemo(
() => (slashContext ? filterSlashCommands(slashCommands, slashContext.query) : []),
@@ -58,11 +81,7 @@ export function ChatInput() {
setSelectedSlashIndex(0);
}, [value, slashMenuOpen]);
const isBashInput = Boolean(value.trim() && parseBashInput(value));
const isSlashInput = value.trimStart().startsWith("/");
const canSendDuringStream = isBashInput || isSlashInput;
const canSend =
(value.trim().length > 0 || pendingImages.length > 0) && (!isStreaming || canSendDuringStream);
const canSend = value.trim().length > 0 || pendingImages.length > 0;
const applySlashSelection = (command: SlashCommand) => {
const nextValue = applySlashCompletion(value, command.name);
@@ -104,10 +123,9 @@ export function ChatInput() {
setPendingImages((prev) => prev.filter((_, i) => i !== index));
};
const handleSend = async () => {
const handleSend = async (mode: "prompt" | "steer" | "follow_up" = "prompt") => {
const text = value.trim();
const isBash = Boolean(text && parseBashInput(text));
if ((!text && pendingImages.length === 0) || (isStreaming && !isBash && !text.startsWith("/"))) return;
if (!text && pendingImages.length === 0) return;
const images = pendingImages.length ? pendingImages : undefined;
setValue("");
@@ -117,7 +135,8 @@ export function ChatInput() {
if (text.startsWith("!") && parseBashInput(text)) {
await runBashCommand(text);
} else {
await sendMessage(text, images);
const sendMode = isStreaming && mode === "prompt" ? "steer" : mode;
await sendMessage(text, images, { mode: sendMode });
}
if (touchLike) inputRef.current?.blur();
@@ -162,9 +181,15 @@ export function ChatInput() {
}
}
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void handleSend("follow_up");
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
void handleSend(isStreaming ? "steer" : "prompt");
}
};
@@ -172,7 +197,7 @@ export function ChatInput() {
const native = e.nativeEvent as InputEvent;
if (touchLike && native.inputType === "insertLineBreak") {
e.preventDefault();
void handleSend();
void handleSend(isStreaming ? "steer" : "prompt");
}
};
@@ -244,11 +269,7 @@ export function ChatInput() {
<div className={styles.attachments}>
{pendingImages.map((img, index) => (
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
<img
className={styles.attachmentThumb}
src={imageToDataUrl(img)}
alt=""
/>
<img className={styles.attachmentThumb} src={imageToDataUrl(img)} alt="" />
<button
type="button"
className={styles.attachmentRemove}
@@ -283,7 +304,7 @@ export function ChatInput() {
type="button"
className={styles.attachBtn}
onClick={() => fileInputRef.current?.click()}
disabled={isStreaming || pendingImages.length >= MAX_CHAT_IMAGES}
disabled={pendingImages.length >= MAX_CHAT_IMAGES}
title="上传图片"
aria-label="上传图片"
>
@@ -302,7 +323,7 @@ export function ChatInput() {
onInput={handleInput}
onKeyDown={handleKeyDown}
onBeforeInput={handleBeforeInput}
placeholder="输入消息…"
placeholder={isStreaming ? "Enter 改方向Shift+Enter 排队后续…" : "输入消息… (/ 命令, ! bash)"}
aria-label="消息输入框"
enterKeyHint="send"
inputMode="text"
@@ -310,7 +331,7 @@ export function ChatInput() {
<button
className={styles.sendBtn}
type="button"
onClick={() => void handleSend()}
onClick={() => void handleSend(isStreaming ? "steer" : "prompt")}
disabled={!canSend}
aria-label="发送"
>

View File

@@ -0,0 +1,26 @@
.summary {
margin: 0;
}
.toggle {
cursor: pointer;
font-weight: 600;
color: var(--text-secondary, #4b5563);
list-style: none;
}
.toggle::-webkit-details-marker {
display: none;
}
.body {
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e5e7eb;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
color: var(--text-primary, #111827);
}

View File

@@ -0,0 +1,15 @@
import styles from "./CompactionSummaryBlock.module.css";
interface CompactionSummaryBlockProps {
content: string;
label?: string;
}
export function CompactionSummaryBlock({ content, label = "上下文已压缩" }: CompactionSummaryBlockProps) {
return (
<details className={styles.summary}>
<summary className={styles.toggle}>{label}</summary>
<div className={styles.body}>{content}</div>
</details>
);
}

View File

@@ -0,0 +1,80 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.modal {
width: min(420px, 100%);
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.15);
}
.title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.message {
font-size: 14px;
color: var(--text-secondary, #4b5563);
margin-bottom: 12px;
white-space: pre-wrap;
}
.input {
width: 100%;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 12px;
font: inherit;
}
.options {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.optionBtn {
text-align: left;
border: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 8px;
padding: 8px 10px;
cursor: pointer;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
border-radius: 8px;
padding: 6px 12px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
}
.btnPrimary {
border-radius: 8px;
padding: 6px 12px;
border: 1px solid #4f46e5;
background: #4f46e5;
color: #fff;
cursor: pointer;
}

View File

@@ -0,0 +1,78 @@
import { useState } from "react";
import type { ExtensionDialogState } from "../../types/events";
import styles from "./ExtensionDialogModal.module.css";
interface ExtensionDialogModalProps {
dialog: ExtensionDialogState;
onSubmit: (response: Record<string, unknown>) => void;
onDismiss: () => void;
}
export function ExtensionDialogModal({ dialog, onSubmit, onDismiss }: ExtensionDialogModalProps) {
const [value, setValue] = useState(dialog.prefill ?? "");
const submitValue = (payload: Record<string, unknown>) => {
onSubmit(payload);
};
return (
<div className={styles.overlay} role="presentation" onClick={onDismiss}>
<div className={styles.modal} role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className={styles.title}>{dialog.title}</div>
{dialog.message ? <div className={styles.message}>{dialog.message}</div> : null}
{dialog.method === "select" && dialog.options ? (
<div className={styles.options}>
{dialog.options.map((option) => (
<button
key={option}
type="button"
className={styles.optionBtn}
onClick={() => submitValue({ value: option })}
>
{option}
</button>
))}
<button type="button" className={styles.optionBtn} onClick={() => submitValue({ cancelled: true })}>
</button>
</div>
) : null}
{dialog.method === "confirm" ? (
<div className={styles.actions}>
<button type="button" className={styles.btn} onClick={() => submitValue({ cancelled: true })}>
</button>
<button type="button" className={styles.btnPrimary} onClick={() => submitValue({ confirmed: true })}>
</button>
</div>
) : null}
{dialog.method === "input" || dialog.method === "editor" ? (
<>
<textarea
className={styles.input}
rows={dialog.method === "editor" ? 8 : 3}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<div className={styles.actions}>
<button type="button" className={styles.btn} onClick={() => submitValue({ cancelled: true })}>
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={() => submitValue({ value })}
>
</button>
</div>
</>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
.panel {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--toolbar-border, #e8ecf0);
background: #fff;
}
.widget {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--text-secondary, #4b5563);
white-space: pre-wrap;
word-break: break-word;
}
.widgetKey {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary, #9ca3af);
margin-bottom: 4px;
}

View File

@@ -0,0 +1,21 @@
import styles from "./ExtensionWidgetPanel.module.css";
interface ExtensionWidgetPanelProps {
widgets: Record<string, string[]>;
}
export function ExtensionWidgetPanel({ widgets }: ExtensionWidgetPanelProps) {
const entries = Object.entries(widgets).filter(([, lines]) => lines.length > 0);
if (entries.length === 0) return null;
return (
<div className={styles.panel}>
{entries.map(([key, lines]) => (
<div key={key} className={styles.widget}>
<div className={styles.widgetKey}>{key}</div>
{lines.join("\n")}
</div>
))}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
} from "../../utils/exportConversation";
import { renderMarkdown } from "../../utils/markdown";
import { imageToDataUrl } from "../../utils/images";
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
import { ToolCallBlock } from "./ToolCallBlock";
import { ThinkingBlock } from "./ThinkingBlock";
import { BashOutputBlock } from "./BashOutputBlock";
@@ -15,8 +16,8 @@ import styles from "./MessageList.module.css";
interface MessageBubbleProps {
message: ChatMessage;
showAgentAvatar?: boolean;
agentAvatarSpacer?: boolean;
agentGutter?: "avatar" | "spacer" | "none";
toolsExpanded?: boolean;
}
function ChatAvatar({ src, className }: { src: string; className: string }) {
@@ -36,6 +37,17 @@ function ChatAvatar({ src, className }: { src: string; className: string }) {
);
}
function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avatarUrl: string }) {
if (mode === "spacer") {
return <div className={styles.assistantAvatarSpacer} aria-hidden="true" />;
}
return (
<div className={styles.assistantAvatarSlot}>
<ChatAvatar src={avatarUrl} className={styles.assistantAvatar} />
</div>
);
}
function AssistantActions({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const markdown = singleAssistantMarkdown(content);
@@ -78,19 +90,19 @@ function AssistantActions({ content }: { content: string }) {
export function MessageBubble({
message,
showAgentAvatar = false,
agentAvatarSpacer = false,
agentGutter = "none",
toolsExpanded = false,
}: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? (
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
) : null;
if (message.role === "thinking") {
return (
<div className={styles.assistantRow}>
{showAgentAvatar ? (
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
) : agentAvatarSpacer ? (
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
) : null}
{gutter}
<div className={`${styles.msg} ${styles.thinking}`}>
<ThinkingBlock content={message.content} streaming={message.streaming} />
</div>
@@ -101,13 +113,9 @@ export function MessageBubble({
if (message.role === "tool_call") {
return (
<div className={styles.assistantRow}>
{showAgentAvatar ? (
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
) : agentAvatarSpacer ? (
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
) : null}
{gutter}
<div className={`${styles.msg} ${styles.toolCall}`}>
<ToolCallBlock content={message.content} />
<ToolCallBlock content={message.content} forceOpen={toolsExpanded} />
</div>
</div>
);
@@ -116,23 +124,36 @@ export function MessageBubble({
if (message.role === "bash") {
return (
<div className={styles.assistantRow}>
{showAgentAvatar ? (
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
) : agentAvatarSpacer ? (
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
) : null}
{gutter}
<div className={`${styles.msg} ${styles.bash}`}>
<BashOutputBlock
command={message.bashCommand}
content={message.content}
exitCode={message.bashExitCode}
streaming={message.streaming}
excludeFromContext={message.bashExcludeFromContext}
/>
</div>
</div>
);
}
if (message.role === "compaction_summary") {
return (
<div className={`${styles.msg} ${styles.system}`}>
<CompactionSummaryBlock content={message.content} />
</div>
);
}
if (message.role === "branch_summary") {
return (
<div className={`${styles.msg} ${styles.system}`}>
<CompactionSummaryBlock content={message.content} label="分支摘要" />
</div>
);
}
const classNames = [styles.msg];
if (message.role === "user") classNames.push(styles.user);
if (message.role === "assistant") classNames.push(styles.assistant);
@@ -143,7 +164,7 @@ export function MessageBubble({
const showActions = !message.streaming && message.content.trim().length > 0;
return (
<div className={styles.assistantRow}>
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
{gutter}
<div className={classNames.join(" ")}>
{showActions ? <AssistantActions content={message.content} /> : null}
<div

View File

@@ -125,6 +125,11 @@
border-radius: 999px;
}
.assistantAvatarSlot {
width: 32px;
flex-shrink: 0;
}
.assistantAvatarSpacer {
width: 32px;
flex-shrink: 0;
@@ -205,10 +210,12 @@
line-height: 1.46;
color: #666;
padding: 0;
margin: 0 0 4px;
background: #fafafa;
border-radius: 10px;
border: 1px solid #eee;
margin: 0;
overflow: hidden;
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.thinking {
@@ -218,10 +225,12 @@
font-size: 16px;
line-height: 1.46;
padding: 0;
margin: 0 0 4px;
margin: 0;
overflow: hidden;
background: #faf8fc;
border-radius: 10px;
border: 1px solid #ece6f5;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.bash {
@@ -376,12 +385,6 @@
max-width: 66.6667%;
box-sizing: border-box;
}
.assistantRow .toolCall {
max-width: none;
width: auto;
box-sizing: border-box;
}
}
@media (max-width: 768px) {
@@ -402,7 +405,8 @@
max-width: 100%;
}
.assistantAvatarSpacer {
.assistantAvatarSpacer,
.assistantAvatarSlot {
width: 28px;
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { useChatContext } from "../../context/ChatContext";
import type { MessageRole } from "../../types/message";
import { MessageBubble } from "./MessageBubble";
import styles from "./MessageList.module.css";
@@ -13,8 +14,12 @@ function scrollToBottom(el: HTMLElement): void {
el.scrollTop = el.scrollHeight;
}
function isAgentSideRole(role: MessageRole): boolean {
return role === "assistant" || role === "thinking" || role === "tool_call" || role === "bash";
}
export function MessageList() {
const { messages } = useChatContext();
const { messages, toolsExpanded } = useChatContext();
const chatRef = useRef<HTMLDivElement>(null);
const stickToBottomRef = useRef(true);
const prevMessageCountRef = useRef(0);
@@ -73,25 +78,16 @@ export function MessageList() {
<div className={styles.chat} ref={chatRef}>
{messages.map((msg, index) => {
const prev = messages[index - 1];
const prevIsAgentSide =
prev?.role === "assistant" ||
prev?.role === "thinking" ||
prev?.role === "tool_call" ||
prev?.role === "bash";
const showAgentAvatar =
msg.role === "assistant" ||
msg.role === "thinking" ||
((msg.role === "tool_call" || msg.role === "bash") && !prevIsAgentSide);
const agentAvatarSpacer =
(msg.role === "tool_call" || msg.role === "bash" || msg.role === "thinking") &&
prevIsAgentSide;
const prevIsAgentSide = prev ? isAgentSideRole(prev.role) : false;
const isAgentSide = isAgentSideRole(msg.role);
const agentGutter = isAgentSide ? (prevIsAgentSide ? "spacer" : "avatar") : "none";
return (
<MessageBubble
key={msg.id}
message={msg}
showAgentAvatar={showAgentAvatar}
agentAvatarSpacer={agentAvatarSpacer}
agentGutter={agentGutter}
toolsExpanded={toolsExpanded}
/>
);
})}

View File

@@ -0,0 +1,30 @@
.pendingBar {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--toolbar-border, #e8ecf0);
background: var(--surface-muted, #f3f4f6);
font-size: 12px;
color: var(--text-secondary, #4b5563);
}
.queueGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.queueLabel {
font-weight: 600;
color: var(--text-primary, #111827);
}
.queueItem {
padding: 4px 8px;
border-radius: 6px;
background: #fff;
border: 1px solid #e5e7eb;
white-space: pre-wrap;
word-break: break-word;
}

View File

@@ -0,0 +1,35 @@
import styles from "./PendingQueueBar.module.css";
interface PendingQueueBarProps {
steering: string[];
followUp: string[];
}
export function PendingQueueBar({ steering, followUp }: PendingQueueBarProps) {
if (steering.length === 0 && followUp.length === 0) return null;
return (
<div className={styles.pendingBar}>
{steering.length > 0 ? (
<div className={styles.queueGroup}>
<span className={styles.queueLabel}>Steering</span>
{steering.map((item, i) => (
<div key={`steer-${i}`} className={styles.queueItem}>
{item}
</div>
))}
</div>
) : null}
{followUp.length > 0 ? (
<div className={styles.queueGroup}>
<span className={styles.queueLabel}>Follow-up</span>
{followUp.map((item, i) => (
<div key={`follow-${i}`} className={styles.queueItem}>
{item}
</div>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,47 @@
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 12px;
border-top: 1px solid var(--toolbar-border, #e8ecf0);
background: var(--toolbar-bg, #f8fafc);
font-size: 12px;
color: var(--text-secondary, #4b5563);
}
.statusText {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6366f1;
animation: pulse 1.2s ease-in-out infinite;
}
.actions {
display: flex;
gap: 8px;
}
.cancelBtn {
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
}
.cancelBtn:hover {
background: #f9fafb;
}
.retryMeta {
color: var(--text-tertiary, #9ca3af);
}

View File

@@ -0,0 +1,56 @@
import type { RetryState } from "../../types/events";
import styles from "./RunStatusBar.module.css";
interface RunStatusBarProps {
isStreaming: boolean;
isCompacting: boolean;
retryState: RetryState | null;
onAbort: () => void;
onAbortRetry: () => void;
}
export function RunStatusBar({
isStreaming,
isCompacting,
retryState,
onAbort,
onAbortRetry,
}: RunStatusBarProps) {
const retryActive = retryState?.active;
if (!isStreaming && !isCompacting && !retryActive) return null;
let label = "";
if (retryActive) {
const attempt = retryState?.attempt ?? 1;
const max = retryState?.maxAttempts ?? attempt;
label = `自动重试中 (${attempt}/${max})`;
} else if (isCompacting) {
label = "整理上下文中…";
} else {
label = "生成中…";
}
return (
<div className={styles.bar}>
<div className={styles.statusText}>
<span className={styles.dot} aria-hidden="true" />
<span>{label}</span>
{retryActive && retryState?.errorMessage ? (
<span className={styles.retryMeta}>{retryState.errorMessage.slice(0, 80)}</span>
) : null}
</div>
<div className={styles.actions}>
{retryActive ? (
<button type="button" className={styles.cancelBtn} onClick={onAbortRetry}>
</button>
) : null}
{isStreaming || isCompacting ? (
<button type="button" className={styles.cancelBtn} onClick={onAbort}>
</button>
) : null}
</div>
</div>
);
}

View File

@@ -1,81 +1,27 @@
.bar {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
align-items: center;
justify-content: flex-end;
min-width: 0;
flex: 1 1 auto;
}
.barCompact {
flex: 0 0 auto;
width: 100%;
align-items: stretch;
}
.compactLine {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.compactLine::-webkit-scrollbar {
display: none;
}
.compactStats {
flex: 0 0 auto;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-tertiary);
white-space: nowrap;
}
.chips {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
max-width: 100%;
}
.chip,
.ctxChip {
display: inline-flex;
align-items: center;
height: 22px;
height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.chip {
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.ctxChip {
flex-shrink: 0;
color: #1e40af;
background: rgba(37, 99, 235, 0.08);
border: 1px solid rgba(37, 99, 235, 0.12);
}
.barCompact .ctxChip {
flex: 0 0 auto;
height: 20px;
padding: 0 7px;
font-size: 10px;
}
.ctxWarn {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
@@ -88,18 +34,12 @@
border-color: rgba(239, 68, 68, 0.16);
}
.status {
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
}
@media (max-width: 768px) {
.barCompact {
padding: 0 2px;
.bar {
justify-content: flex-start;
}
.compactLine {
gap: 6px;
.ctxChip {
height: 22px;
}
}

View File

@@ -2,11 +2,7 @@ import { useChatContext } from "../../context/ChatContext";
import { formatFooterTokens } from "../../utils/format";
import styles from "./SessionContextBar.module.css";
interface SessionContextBarProps {
compact?: boolean;
}
export function SessionContextBar({ compact = false }: SessionContextBarProps) {
export function SessionContextBar() {
const { sessionState, isStreaming } = useChatContext();
const data = sessionState;
@@ -14,63 +10,33 @@ export function SessionContextBar({ compact = false }: SessionContextBarProps) {
return null;
}
const tok = data.stats.tokens || {};
const chips: string[] = [];
if (tok.input) chips.push(`${formatFooterTokens(tok.input)}`);
if (tok.output) chips.push(`${formatFooterTokens(tok.output)}`);
if (tok.cacheRead) chips.push(`R${formatFooterTokens(tok.cacheRead)}`);
if (tok.cacheWrite) chips.push(`W${formatFooterTokens(tok.cacheWrite)}`);
const costNum = Number(data.stats.cost ?? 0);
chips.push(`$${costNum.toFixed(3)}`);
const liveStreaming = isStreaming || data.isStreaming;
if (liveStreaming || data.isCompacting) {
return null;
}
const cx = data.stats.contextUsage;
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
if (!cw) return null;
const pctRaw = cx?.percent;
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
const pctNum = pctRaw != null ? Number(pctRaw) : null;
const autoInd = data.autoCompactionEnabled ? " auto" : "";
const ctxTxt =
pctStr === "?" && cw
pctStr === "?"
? `?/${formatFooterTokens(cw)}${autoInd}`
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
const liveStreaming = isStreaming || data.isStreaming;
let status = "";
if (!compact) {
if (data.isCompacting) status = "整理上下文";
else if (liveStreaming) status = "生成中";
else if ((data.turnIndex ?? 0) > 0) status = `Turn ${data.turnIndex}`;
}
let ctxClass = styles.ctxChip;
if (pctNum != null) {
if (pctNum > 90) ctxClass = `${styles.ctxChip} ${styles.ctxDanger}`;
else if (pctNum > 70) ctxClass = `${styles.ctxChip} ${styles.ctxWarn}`;
}
if (compact) {
return (
<div className={`${styles.bar} ${styles.barCompact}`}>
<div className={styles.compactLine}>
<span className={styles.compactStats}>{chips.join(" ")}</span>
<span className={ctxClass}>{ctxTxt}</span>
</div>
</div>
);
}
return (
<div className={styles.bar}>
<div className={styles.chips}>
{chips.map((chip) => (
<span key={chip} className={styles.chip}>
{chip}
</span>
))}
<span className={ctxClass}>{ctxTxt}</span>
</div>
{status ? <span className={styles.status}>{status}</span> : null}
<span className={ctxClass} title="上下文占用">{ctxTxt}</span>
</div>
);
}

View File

@@ -56,3 +56,15 @@
font-size: inherit;
line-height: inherit;
}
.detailPre :global(.diffAdd) {
color: #15803d;
}
.detailPre :global(.diffDel) {
color: #b91c1c;
}
.detailPre :global(.diffMeta) {
color: #6b7280;
}

View File

@@ -1,18 +1,30 @@
import { deriveToolCallSummary } from "../../utils/toolCall";
import { deriveToolCallSummary, formatToolBodyHtml } from "../../utils/toolCall";
import styles from "./ToolCallBlock.module.css";
interface ToolCallBlockProps {
content: string;
forceOpen?: boolean;
}
export function ToolCallBlock({ content }: ToolCallBlockProps) {
export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
const bodyStart = content.indexOf("\n");
const body = bodyStart >= 0 ? content.slice(bodyStart + 1) : "";
const hasDiff = body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
return (
<details className={styles.collapsible}>
<details className={styles.collapsible} open={forceOpen}>
<summary className={styles.summary}>
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
</summary>
<div className={styles.detail}>
<pre className={styles.detailPre}>{content}</pre>
{hasDiff ? (
<pre
className={styles.detailPre}
dangerouslySetInnerHTML={{ __html: formatToolBodyHtml(body || content) }}
/>
) : (
<pre className={styles.detailPre}>{body || content}</pre>
)}
</div>
</details>
);

View File

@@ -1,8 +1,8 @@
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
flex-direction: column;
gap: 8px;
padding: 10px 14px 8px;
border-bottom: 1px solid var(--header-border);
flex-shrink: 0;
background: var(--header-bg);
@@ -10,11 +10,10 @@
box-shadow: var(--header-shadow);
}
.titleGroup {
.topRow {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
}
@@ -37,8 +36,16 @@
color: var(--text-primary);
}
.titleBlock {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
.title {
flex: 1;
margin: 0;
min-width: 0;
font-size: 15px;
font-weight: 650;
@@ -50,40 +57,38 @@
}
.meta {
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.titleActions {
display: none;
.topActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.toolbar {
margin-left: auto;
.bottomRow {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
max-width: min(72%, 760px);
min-width: 0;
padding: 6px 8px 6px 10px;
padding: 6px 8px;
border: 1px solid var(--toolbar-border);
border-radius: 14px;
border-radius: 12px;
background: var(--toolbar-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.controls {
display: flex;
align-items: center;
align-items: flex-end;
gap: 8px;
min-width: 0;
flex-shrink: 0;
}
.field {
@@ -140,14 +145,14 @@
}
.selectModel {
min-width: 168px;
max-width: min(320px, 42vw);
min-width: 140px;
max-width: min(280px, 34vw);
font-family: var(--font-mono, ui-monospace, monospace);
}
.selectCompact {
min-width: 72px;
max-width: 96px;
min-width: 68px;
max-width: 88px;
}
.select:focus {
@@ -161,28 +166,19 @@
cursor: not-allowed;
}
.toolbarActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.statsMobile {
display: none;
}
.statsDesktop {
.statsWrap {
display: flex;
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
}
.toolbarActions::before {
.statsWrap::before {
content: "";
width: 1px;
align-self: stretch;
min-height: 30px;
min-height: 24px;
margin-right: 2px;
background: linear-gradient(
180deg,
transparent 0%,
@@ -190,90 +186,47 @@
rgba(15, 23, 42, 0.08) 82%,
transparent 100%
);
flex-shrink: 0;
}
.abortBtn {
.toolsToggleBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 30px;
height: 30px;
min-width: 30px;
padding: 0 10px;
background: rgba(254, 242, 242, 0.95);
border: 1px solid rgba(252, 165, 165, 0.65);
background: var(--surface-muted);
border: 1px solid var(--toolbar-border);
border-radius: 9px;
color: #dc2626;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
}
.abortBtn:hover {
background: #fee2e2;
}
@media (max-width: 960px) {
.toolbar {
max-width: min(78%, 620px);
}
.select {
max-width: 180px;
}
.toolsToggleBtn:hover {
background: #fff;
color: var(--text-primary);
}
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 8px 10px 7px;
}
.titleGroup {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
grid-template-areas: "menu title actions";
gap: 6px;
align-items: center;
}
.menuBtn {
display: flex;
grid-area: menu;
width: 34px;
height: 34px;
border-radius: 9px;
}
.title {
grid-area: title;
font-size: 14px;
}
.meta {
display: none;
}
.titleActions {
display: flex;
grid-area: actions;
}
.toolbar {
margin-left: 0;
max-width: none;
width: 100%;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
.bottomRow {
flex-direction: column;
align-items: stretch;
gap: 4px;
gap: 6px;
padding: 6px;
}
.controls {
@@ -283,10 +236,6 @@
width: 100%;
}
.field {
gap: 0;
}
.fieldLabel {
position: absolute;
width: 1px;
@@ -306,36 +255,15 @@
font-size: 12px;
}
.selectModel,
.selectCompact {
min-width: 0;
max-width: none;
}
.toolbarActions {
.statsWrap::before {
display: none;
}
.statsDesktop {
display: none;
}
.statsMobile {
display: block;
width: 100%;
}
.toolbarActions::before {
display: none;
}
.abortText {
display: none;
}
.abortBtn {
width: 32px;
padding: 0;
}
}
@media (max-width: 420px) {

View File

@@ -83,28 +83,40 @@ function ModelControls({
);
}
function AbortButton({ onClick }: { onClick: () => void }) {
function ToolsToggleButton({
expanded,
onClick,
}: {
expanded: boolean;
onClick: () => void;
}) {
return (
<button type="button" className={styles.abortBtn} onClick={onClick} title="中止回复">
<button
type="button"
className={styles.toolsToggleBtn}
onClick={onClick}
title={expanded ? "折叠全部 tool (Ctrl+O)" : "展开全部 tool (Ctrl+O)"}
aria-label={expanded ? "折叠全部 tool" : "展开全部 tool"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="6" y="6" width="12" height="12" rx="2" />
<polyline points="6 9 12 15 18 9" />
</svg>
<span className={styles.abortText}></span>
</button>
);
}
export function Header() {
const {
isStreaming,
headerTitle,
headerMeta,
toggleSidebar,
abortStream,
toggleToolsExpanded,
toolsExpanded,
availableModels,
currentModelKey,
thinkingLevel,
availableThinkingLevels,
isStreaming,
isApplyingModelSettings,
applyModelSelection,
applyThinkingSelection,
@@ -125,34 +137,29 @@ export function Header() {
return (
<header className={styles.header}>
<div className={styles.titleGroup}>
<div className={styles.topRow}>
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
<h1 className={styles.title} title={headerTitle}>
{headerTitle}
</h1>
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
<div className={styles.titleActions}>
<div className={styles.titleBlock}>
<h1 className={styles.title} title={headerTitle}>
{headerTitle}
</h1>
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
</div>
<div className={styles.topActions}>
<ToolsToggleButton expanded={toolsExpanded} onClick={toggleToolsExpanded} />
<ExportMenu compact />
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
</div>
</div>
<div className={styles.toolbar}>
<div className={styles.bottomRow}>
<ModelControls {...controlProps} />
<div className={styles.statsDesktop}>
<div className={styles.statsWrap}>
<SessionContextBar />
</div>
<div className={styles.statsMobile}>
<SessionContextBar compact />
</div>
<div className={styles.toolbarActions}>
<ExportMenu />
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
</div>
</div>
</header>
);

View File

@@ -13,11 +13,12 @@ import * as chatApi from "../api/chat";
import * as sessionsApi from "../api/sessions";
import { apiUrl } from "../api/base";
import { useBootstrap } from "./BootstrapContext";
import type { SseEvent } from "../types/events";
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 } from "../utils/format";
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
import { formatBashOutput, parseBashInput } from "../utils/bash";
import { buildHistoryMessages } from "../utils/historyMessages";
import {
extractContent,
extractImages,
@@ -48,6 +49,14 @@ interface ChatContextValue {
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>;
@@ -56,9 +65,17 @@ interface ChatContextValue {
renameSession: (path: string, currentTitle: string) => Promise<void>;
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
newSession: () => void;
sendMessage: (text: string, images?: ImageContent[]) => Promise<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;
@@ -96,66 +113,7 @@ function getAvailableThinkingLevels(model: ModelInfo | undefined): ThinkingLevel
}
function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
const result: ChatMessage[] = [];
for (const m of payload.messages || []) {
if (m.role === "user") {
const text = extractContent(m.content);
const images = extractImages(m.content);
if (text || images.length) {
result.push({
id: nextMessageId(),
role: "user",
content: text,
...(images.length ? { images } : {}),
});
}
} else if (m.role === "assistant") {
if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === "thinking") {
const thinking = (block.thinking ?? "").trim();
if (thinking) {
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
}
} else if (block.type === "text") {
const text = (block.text ?? "").trim();
if (text) {
const last = result[result.length - 1];
if (last?.role === "assistant") {
last.content += text;
} else {
result.push({ id: nextMessageId(), role: "assistant", content: text });
}
}
} else if (block.type === "toolCall") {
const tc = block as ToolCallBlock;
const tid = tc.toolCallId || tc.id;
result.push({
id: tid ? `tool-${tid}` : nextMessageId(),
role: "tool_call",
content: formatToolCall(tc),
toolCallId: tid ? String(tid) : undefined,
});
}
}
} else {
const text = extractContent(m.content);
if (text) {
result.push({ id: nextMessageId(), role: "assistant", content: text });
}
for (const tc of getToolCalls(m.content)) {
const tid = tc.toolCallId || tc.id;
result.push({
id: tid ? `tool-${tid}` : nextMessageId(),
role: "tool_call",
content: formatToolCall(tc),
toolCallId: tid ? String(tid) : undefined,
});
}
}
}
}
return normalizeChatMessages(result);
return normalizeChatMessages(buildHistoryMessages(payload));
}
function findStreamingThinkingId(messages: ChatMessage[]): string | null {
@@ -509,6 +467,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
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);
@@ -519,6 +485,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
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);
@@ -605,6 +572,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
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;
@@ -742,7 +710,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
setHeaderTitle(formatSessionTitle(headerLabel));
setMessages(historyToMessages(payload));
setHeaderMeta("");
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();
@@ -752,7 +731,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
}
},
[addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents],
[addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents, activateSessionBackend, fetchSessionStateFull],
);
const restoreSessionOnBoot = useCallback(() => {
@@ -1003,15 +982,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
if (response.action === "new_session") {
clearChat();
const path = response.sessionFile?.trim();
if (path) {
setActiveSessionPath(path);
setBackendSessionPath(path);
persistLastActiveSession(path);
await loadSessions();
await loadSession(path);
} else {
clearChat();
await fetchSessionStateFull();
}
await fetchSessionStateFull();
} else if (response.action === "reload_messages") {
const path = activeSessionPathRef.current;
if (path) {
@@ -1037,14 +1014,37 @@ export function ChatProvider({ children }: { children: ReactNode }) {
);
const sendMessage = useCallback(
async (text: string, images?: ImageContent[]) => {
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("/");
if ((!trimmed && !hasImages) || (isStreamingRef.current && !isSlash)) return;
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) {
@@ -1123,6 +1123,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!(await ensureSessionReady())) return;
const bashMsgId = nextMessageId();
activeBashMsgIdRef.current = bashMsgId;
setMessages((prev) => [
...prev,
{ id: nextMessageId(), role: "user", content: parsed.display },
@@ -1131,12 +1132,16 @@ export function ChatProvider({ children }: { children: ReactNode }) {
role: "bash",
content: "",
bashCommand: parsed.command,
bashExcludeFromContext: parsed.excludeFromContext,
streaming: true,
},
]);
try {
const result = await chatApi.runBash(parsed.command);
const result = await chatApi.runBash(parsed.command, {
excludeFromContext: parsed.excludeFromContext,
});
activeBashMsgIdRef.current = null;
setMessages((prev) =>
prev.map((msg) =>
msg.id === bashMsgId
@@ -1158,6 +1163,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
void persistLastActiveSession(persistPath);
}
} catch (err) {
activeBashMsgIdRef.current = null;
setMessages((prev) =>
prev.map((msg) =>
msg.id === bashMsgId
@@ -1175,14 +1181,93 @@ export function ChatProvider({ children }: { children: ReactNode }) {
);
const abortStream = useCallback(async () => {
if (!isStreamingRef.current) return;
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;
@@ -1372,27 +1457,37 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (idx < 0) return prev;
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
});
if (ev.type === "tool_execution_end" && ev.isError) {
addSystemMessage(`工具 ${ev.toolName} 执行出错`);
}
break;
}
case "compaction_start":
setHeaderMeta("压缩上下文中…");
setIsCompacting(true);
break;
case "compaction_end": {
setHeaderMeta("");
setIsCompacting(false);
if (ev.aborted) {
if (ev.errorMessage) addSystemMessage(ev.errorMessage);
break;
}
const activePath = activeSessionPathRef.current;
if (activePath && !compactionReloadPendingRef.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) {
@@ -1402,6 +1497,45 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
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;
}
}
},
[
@@ -1423,7 +1557,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setIsStreaming(ev.isStreaming);
}
for (const replayEv of ev.replay ?? []) {
processStreamEvent(replayEv);
if (replayEv.type === "extension_ui_request") {
handleExtensionUiRequest(replayEv as Record<string, unknown>);
} else {
processStreamEvent(replayEv);
}
}
void fetchSessionStateFull();
break;
@@ -1439,11 +1577,21 @@ export function ChatProvider({ children }: { children: ReactNode }) {
break;
}
case "extension_ui_request":
handleExtensionUiRequest(ev as Record<string, unknown>);
break;
default:
processStreamEvent(ev);
}
},
[processStreamEvent, fetchSessionStateFull, invalidateTurnEvents, addSystemMessage],
[
processStreamEvent,
fetchSessionStateFull,
invalidateTurnEvents,
addSystemMessage,
handleExtensionUiRequest,
],
);
useEffect(() => {
@@ -1556,6 +1704,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
thinkingLevel,
availableThinkingLevels,
isApplyingModelSettings,
pendingSteering,
pendingFollowUp,
retryState,
isCompacting,
extensionWidgets,
extensionStatuses,
extensionDialog,
toolsExpanded,
toggleSidebar: () => setSidebarOpen((o) => !o),
closeSidebar: () => setSidebarOpen(false),
loadSessions,
@@ -1567,6 +1723,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
sendMessage,
runBashCommand,
abortStream,
abortRetry,
toggleToolsExpanded,
respondExtensionDialog,
dismissExtensionDialog,
applyModelSelection,
applyThinkingSelection,
addSystemMessage,

View File

@@ -1,13 +1,48 @@
import { ChatInput } from "../components/chat/ChatInput";
import { ExtensionDialogModal } from "../components/chat/ExtensionDialogModal";
import { ExtensionWidgetPanel } from "../components/chat/ExtensionWidgetPanel";
import { MessageList } from "../components/chat/MessageList";
import { PendingQueueBar } from "../components/chat/PendingQueueBar";
import { RunStatusBar } from "../components/chat/RunStatusBar";
import { Header } from "../components/layout/Header";
import { useChatContext } from "../context/ChatContext";
export function ChatPage() {
const {
pendingSteering,
pendingFollowUp,
isStreaming,
isCompacting,
retryState,
extensionWidgets,
extensionDialog,
abortStream,
abortRetry,
respondExtensionDialog,
dismissExtensionDialog,
} = useChatContext();
return (
<>
<Header />
<MessageList />
<ExtensionWidgetPanel widgets={extensionWidgets} />
<PendingQueueBar steering={pendingSteering} followUp={pendingFollowUp} />
<RunStatusBar
isStreaming={isStreaming}
isCompacting={isCompacting}
retryState={retryState}
onAbort={() => void abortStream()}
onAbortRetry={() => void abortRetry()}
/>
<ChatInput />
{extensionDialog ? (
<ExtensionDialogModal
dialog={extensionDialog}
onSubmit={respondExtensionDialog}
onDismiss={dismissExtensionDialog}
/>
) : null}
</>
);
}

View File

@@ -417,6 +417,49 @@
}
}
.envGrid {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid #edf0f4;
border-radius: 8px;
overflow: hidden;
}
.envRow {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 14px;
border-bottom: 1px solid #f3f4f6;
}
.envRow:last-child {
border-bottom: none;
}
.envRow:nth-child(even) {
background: #fafbfc;
}
.envLabel {
flex-shrink: 0;
width: 100px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
}
.envValue {
flex: 1;
min-width: 0;
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
font-size: 12px;
color: #111827;
word-break: break-all;
background: none;
}
@media (max-width: 480px) {
.extensionGroupList {
grid-template-columns: 1fr;

View File

@@ -3,11 +3,11 @@ import { Link } from "react-router-dom";
import * as settingsApi from "../api/settings";
import { useAvatars } from "../context/AvatarContext";
import { useBootstrap } from "../context/BootstrapContext";
import type { ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { McpToolInfo } from "../types/events";
import styles from "./SettingsPage.module.css";
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other"];
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "env"];
function isNpmSkill(skill: SkillInfo): boolean {
if (skill.toggleable === false) return true;
@@ -267,6 +267,8 @@ export function SettingsPage() {
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
const [loadingEnv, setLoadingEnv] = useState(false);
useEffect(() => {
let initial: SettingsPaneId = "prompt";
@@ -336,6 +338,24 @@ export function SettingsPage() {
}
};
const loadEnvInfo = async () => {
setLoadingEnv(true);
try {
const data = await settingsApi.fetchEnvironment();
setEnvInfo(data);
} catch (err) {
setStatus(`加载环境信息失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoadingEnv(false);
}
};
useEffect(() => {
if (activePane === "env" && !envInfo && !loadingEnv) {
void loadEnvInfo();
}
}, [activePane]);
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
setTogglingSkillPath(path);
setStatus(enabled ? "正在启用 skill…" : "正在禁用 skill…");
@@ -530,6 +550,7 @@ export function SettingsPage() {
["mcp", "MCP工具"],
["extensions", "插件扩展"],
["other", "其他设置"],
["env", "环境信息"],
] as const
).map(([id, label]) => (
<button
@@ -923,6 +944,63 @@ export function SettingsPage() {
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "env" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "env"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>Node.js · · </p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={loadingEnv}
onClick={() => void loadEnvInfo()}
>
</button>
</div>
<div className={styles.formBody}>
{loadingEnv ? (
<div className={styles.empty}>...</div>
) : !envInfo ? (
<div className={styles.empty}></div>
) : (
<div className={styles.envGrid}>
{(
[
["Node.js 版本", envInfo.nodeVersion],
["平台", envInfo.platform],
["架构", envInfo.arch],
["系统类型", envInfo.osType],
["系统版本", envInfo.osRelease],
["主机名", envInfo.hostname],
["进程 PID", envInfo.pid !== undefined ? String(envInfo.pid) : undefined],
["运行时长", envInfo.uptime !== undefined ? envInfo.uptime + " 秒" : undefined],
["总内存", envInfo.totalMemMb !== undefined ? envInfo.totalMemMb + " MB" : undefined],
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
["Node 路径", envInfo.execPath],
["工作目录", envInfo.cwd],
] as [string, string | undefined][]
)
.filter(([, v]) => v !== undefined && v !== "")
.map(([label, value]) => (
<div key={label} className={styles.envRow}>
<span className={styles.envLabel}>{label}</span>
<code className={styles.envValue}>{value}</code>
</div>
))}
</div>
)}
</div>
{statusText && activePane === "env" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
</div>
</div>
</div>

View File

@@ -55,9 +55,60 @@ export interface CompactionEndEvent {
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
@@ -69,7 +120,13 @@ export type StreamSseEvent =
| ToolExecutionUpdateEvent
| ToolExecutionEndEvent
| CompactionStartEvent
| CompactionEndEvent;
| CompactionEndEvent
| QueueUpdateEvent
| AutoRetryStartEvent
| AutoRetryEndEvent
| ExtensionErrorEvent
| BashUpdateEvent
| ExtensionUiRequestEvent;
export interface ConnectedEvent {
type: "connected";
@@ -155,4 +212,37 @@ export interface AvatarSettings {
agentAvatarUrl: string;
}
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other";
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;
}

View File

@@ -1,4 +1,12 @@
export type MessageRole = "user" | "assistant" | "system" | "tool_call" | "bash" | "thinking";
export type MessageRole =
| "user"
| "assistant"
| "system"
| "tool_call"
| "bash"
| "thinking"
| "compaction_summary"
| "branch_summary";
export interface ContentBlock {
type: string;
@@ -24,6 +32,14 @@ export interface ImageContent {
export interface RpcMessage {
role: string;
content?: string | ContentBlock[];
command?: string;
output?: string;
exitCode?: number;
cancelled?: boolean;
truncated?: boolean;
excludeFromContext?: boolean;
summary?: string;
toolCallId?: string;
}
export interface ToolCallBlock {
@@ -45,6 +61,7 @@ export interface ChatMessage {
toolCallId?: string;
bashCommand?: string;
bashExitCode?: number;
bashExcludeFromContext?: boolean;
streaming?: boolean;
}

View File

@@ -15,6 +15,12 @@ function roleLabel(role: ChatMessage["role"]): string {
return "系统";
case "bash":
return "Shell";
case "compaction_summary":
return "压缩摘要";
case "branch_summary":
return "分支摘要";
default:
return "消息";
}
}
@@ -46,6 +52,12 @@ export function messagesToMarkdown(title: string, messages: ChatMessage[]): stri
lines.push(`## Shell (${cmd})`, "", "```", m.content, "```", "");
break;
}
case "compaction_summary":
lines.push("## 压缩摘要", "", m.content, "");
break;
case "branch_summary":
lines.push("## 分支摘要", "", m.content, "");
break;
}
}
return `${lines.join("\n").trimEnd()}\n`;

View File

@@ -8,6 +8,22 @@ export function formatFooterTokens(count: number | undefined): string {
return `${Math.round(n / 1_000_000)}M`;
}
/** Strip TUI/terminal ANSI and orphaned CSI color codes for web display. */
export function stripAnsi(text: string): string {
return text
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "")
.replace(/\[(?:\d{1,3};)*\d{0,3}m/g, "")
.replace(/\s+/g, " ")
.trim();
}
export function truncateText(text: string, maxLen: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxLen) return trimmed;
return `${trimmed.slice(0, Math.max(0, maxLen - 1))}`;
}
export function formatTimeAgo(isoStr: string | undefined): string {
if (!isoStr) return "";
const d = new Date(isoStr);

View File

@@ -0,0 +1,117 @@
import type { ChatMessage, SessionHistoryPayload, ToolCallBlock } from "../types/message";
import { nextMessageId } from "./format";
import {
extractContent,
extractImages,
extractThinking,
formatToolCall,
formatToolResult,
getToolCalls,
} from "./toolCall";
function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
if (Array.isArray(m.content)) {
for (const block of m.content) {
const b = block as { type?: string; thinking?: string; text?: string };
if (b.type === "thinking") {
const thinking = (b.thinking ?? "").trim();
if (thinking) {
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
}
} else if (b.type === "text") {
const text = (b.text ?? "").trim();
if (text) {
const last = result[result.length - 1];
if (last?.role === "assistant") {
last.content += text;
} else {
result.push({ id: nextMessageId(), role: "assistant", content: text });
}
}
} else if (b.type === "toolCall") {
const tc = block as ToolCallBlock;
const tid = tc.toolCallId || tc.id;
result.push({
id: tid ? `tool-${tid}` : nextMessageId(),
role: "tool_call",
content: formatToolCall(tc),
toolCallId: tid ? String(tid) : undefined,
});
}
}
return;
}
const text = extractContent(m.content as string | undefined);
if (text) {
result.push({ id: nextMessageId(), role: "assistant", content: text });
}
for (const tc of getToolCalls(m.content as string | undefined)) {
const tid = tc.toolCallId || tc.id;
result.push({
id: tid ? `tool-${tid}` : nextMessageId(),
role: "tool_call",
content: formatToolCall(tc),
toolCallId: tid ? String(tid) : undefined,
});
}
}
export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessage[] {
const result: ChatMessage[] = [];
for (const m of payload.messages || []) {
if (m.role === "user") {
const text = extractContent(m.content);
const images = extractImages(m.content);
if (text || images.length) {
result.push({
id: nextMessageId(),
role: "user",
content: text,
...(images.length ? { images } : {}),
});
}
} else if (m.role === "assistant") {
appendAssistantBlocks(m, result);
} else if (m.role === "toolResult" || m.role === "tool") {
const text = formatToolResult({ content: m.content });
if (text.trim()) {
result.push({
id: m.toolCallId ? `tool-${m.toolCallId}` : nextMessageId(),
role: "tool_call",
content: `✅ tool\n${text}`,
toolCallId: m.toolCallId ? String(m.toolCallId) : undefined,
});
}
} else if (m.role === "bashExecution") {
const output = typeof m.output === "string" ? m.output : "";
result.push({
id: nextMessageId(),
role: "user",
content: m.excludeFromContext ? `!!${m.command}` : `!${m.command}`,
});
result.push({
id: nextMessageId(),
role: "bash",
content: output,
bashCommand: m.command,
bashExitCode: m.exitCode,
bashExcludeFromContext: m.excludeFromContext,
});
} else if (m.role === "compactionSummary") {
const summary = typeof m.summary === "string" ? m.summary : extractContent(m.content);
if (summary.trim()) {
result.push({ id: nextMessageId(), role: "compaction_summary", content: summary });
}
} else if (m.role === "branchSummary") {
const summary = typeof m.summary === "string" ? m.summary : extractContent(m.content);
if (summary.trim()) {
result.push({ id: nextMessageId(), role: "branch_summary", content: summary });
}
}
}
return result;
}
export function extractThinkingFromMessage(m: { content?: string | unknown[] }): string {
return extractThinking(m.content as string | undefined);
}

View File

@@ -65,6 +65,32 @@ export function formatToolResult(tr: { content?: unknown }): string {
return text.length > 800 ? `${text.slice(0, 800)}\n… (已截断)` : text;
}
export function formatToolBodyHtml(content: string): string {
const lines = content.split("\n");
let inDiff = false;
const htmlLines = lines.map((line) => {
if (/^(\+\+\+|---|@@)/.test(line.trim())) {
inDiff = true;
return `<span class="diffMeta">${escapeHtml(line)}</span>`;
}
if (inDiff && line.startsWith("+")) {
return `<span class="diffAdd">${escapeHtml(line)}</span>`;
}
if (inDiff && line.startsWith("-")) {
return `<span class="diffDel">${escapeHtml(line)}</span>`;
}
return escapeHtml(line);
});
return htmlLines.join("\n");
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function deriveToolCallSummary(fullText: string): string {
const raw = String(fullText || "").trim();
if (!raw) return "tool";

View File

@@ -32,13 +32,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const PID_FILE = join(__dirname, ".webui.pid");
const DIST_DIR = join(__dirname, "frontend", "dist");
const FRONTEND_DIR = join(__dirname, "frontend");
const WEBUI_STARTUP_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 10_000;
let serverProcess: ChildProcess | null = null;
let serverPort = DEFAULT_WEBUI_PORT;
let systemdConfig: SystemdServiceConfig | null = null;
function findTsx(): string {
// 从扩展所在目录向上找 repo 根目录
// On Linux/Mac, use the tsx shebang script directly
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "node_modules", ".bin", "tsx");
@@ -47,10 +48,23 @@ function findTsx(): string {
if (parent === dir) break;
dir = parent;
}
// fallback
return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
}
function findTsxMjs(): string {
// On Windows, spawn node + tsx/dist/cli.mjs directly to avoid the cmd.exe wrapper
// that shell:true introduces and which causes process lifecycle / probe timing issues
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return join(__dirname, "..", "..", "..", "node_modules", "tsx", "dist", "cli.mjs");
}
function findRepoRoot(): string {
let dir = __dirname;
for (let i = 0; i < 12; i++) {
@@ -106,7 +120,7 @@ function isProcessAlive(pid: number): boolean {
}
}
async function waitForStartupByProbe(port: number, timeoutMs = 10000): Promise<void> {
async function waitForStartupByProbe(port: number, timeoutMs = WEBUI_STARTUP_TIMEOUT_MS): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if ((await probePort(port)) === "running") return;
@@ -150,7 +164,7 @@ function waitForStartup(port: number, child: ChildProcess): Promise<void> {
});
};
timeout = setTimeout(() => finish(new Error("webui 启动超时")), 10000);
timeout = setTimeout(() => finish(new Error("webui 启动超时")), WEBUI_STARTUP_TIMEOUT_MS);
setTimeout(check, 300);
});
}
@@ -174,19 +188,26 @@ function probePort(port: number): Promise<"free" | "running" | "occupied"> {
}
function findNpm(): string {
const local = join(dirname(process.execPath), "npm");
if (existsSync(local)) return local;
// On Windows prefer npm.cmd; the bare 'npm' is a bash script that Windows can't execute
const candidates = process.platform === "win32" ? ["npm.cmd", "npm"] : ["npm"];
for (const name of candidates) {
const local = join(dirname(process.execPath), name);
if (existsSync(local)) return local;
}
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "node_modules", ".bin", "npm");
if (existsSync(candidate)) return candidate;
for (const name of candidates) {
const candidate = join(dir, "node_modules", ".bin", name);
if (existsSync(candidate)) return candidate;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return "npm";
return candidates[0];
}
function runNpm(
@@ -197,6 +218,7 @@ function runNpm(
cwd,
stdio: "inherit",
env: process.env,
shell: process.platform === "win32",
});
if (result.error) {
return { ok: false, status: result.status, error: result.error.message };
@@ -286,7 +308,6 @@ async function startServer(port: number, ctx: any): Promise<void> {
}
const repoRoot = findRepoRoot();
const tsxBin = findTsx();
const serverFile = join(__dirname, "backend", "main.ts");
if (!existsSync(serverFile)) {
@@ -294,7 +315,15 @@ async function startServer(port: number, ctx: any): Promise<void> {
return;
}
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
// On Windows, spawn node + tsx/dist/cli.mjs directly instead of tsx.cmd via shell:true.
// The cmd.exe wrapper (shell:true) makes serverProcess point to cmd.exe, not the real
// server process, breaking exit-code detection and causing startup probe timeouts.
const [spawnCmd, spawnArgs] =
process.platform === "win32"
? [process.execPath, [findTsxMjs(), serverFile, "--port", String(port)]]
: [findTsx(), [serverFile, "--port", String(port)]];
serverProcess = spawn(spawnCmd, spawnArgs, {
cwd: repoRoot,
stdio: ["ignore", "inherit", "inherit"],
env: {
@@ -315,6 +344,10 @@ async function startServer(port: number, ctx: any): Promise<void> {
try {
await waitForStartup(port, serverProcess);
} catch (err: unknown) {
// Kill the orphaned backend process so the port is freed for the next attempt
if (serverProcess && serverProcess.exitCode === null) {
killProcessTree(serverProcess.pid!);
}
clearPidFile();
serverProcess = null;
const message = err instanceof Error ? err.message : String(err);
@@ -331,7 +364,7 @@ function notifyLanAddresses(ctx: any, port: number): void {
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === "IPv4" && !net.internal) {
if ((net.family === "IPv4" || net.family === 4) && !net.internal) {
ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info");
}
}
@@ -340,6 +373,19 @@ function notifyLanAddresses(ctx: any, port: number): void {
function findPidsByPort(port: number): number[] {
try {
if (process.platform === "win32") {
const out = execSync(`netstat -ano`, { encoding: "utf8" });
const pids = new Set<number>();
for (const line of out.split(/\r?\n/)) {
if (!line.includes(`:${port} `)) continue;
const match = line.trim().match(/(\d+)\s*$/);
if (match) {
const pid = parseInt(match[1], 10);
if (Number.isFinite(pid) && pid > 0) pids.add(pid);
}
}
return [...pids];
}
const out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" });
const pids = new Set<number>();
for (const match of out.matchAll(/pid=(\d+)/g)) {
@@ -354,7 +400,11 @@ function findPidsByPort(port: number): number[] {
function killProcessTree(pid: number): void {
try {
process.kill(pid, "SIGTERM");
if (process.platform === "win32") {
spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { encoding: "utf8" });
} else {
process.kill(pid, "SIGTERM");
}
} catch {
/* ignore */
}
@@ -408,11 +458,12 @@ async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Pr
return;
}
if (serverProcess) {
serverProcess.kill("SIGTERM");
if (serverProcess?.pid) {
killProcessTree(serverProcess.pid);
serverProcess = null;
serverPort = 0;
clearPidFile();
await waitForPortFree(port);
ctx.ui.notify("网页服务已停止", "info");
return;
}
@@ -420,7 +471,7 @@ async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Pr
const pid = readPidFile();
if (pid && isProcessAlive(pid)) {
try {
process.kill(pid, "SIGTERM");
killProcessTree(pid);
await waitForPortFree(port);
clearPidFile();
ctx.ui.notify(`网页服务已停止PID ${pid}`, "info");
@@ -479,11 +530,6 @@ export default function (pi: ExtensionAPI) {
if (ensureSystemdService(systemdConfig, serverPort || DEFAULT_WEBUI_PORT)) {
console.log(`[webui] 已注册 systemd 保活服务: ${WEBUI_SERVICE_NAME}`);
} else {
const reason = getSystemdDisabledReason();
if (reason) {
console.warn(`[webui] systemd 保活不可用,回退进程内管理: ${reason}`);
}
}
pi.registerCommand("webui", {

7
package-lock.json generated
View File

@@ -3719,7 +3719,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -5015,7 +5014,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5084,7 +5082,6 @@
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -5159,7 +5156,6 @@
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5760,7 +5756,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5774,7 +5769,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -5978,7 +5972,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -125,6 +125,21 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5": {
id: "microsoft/mai-image-2.5",
name: "Microsoft: MAI-Image-2.5",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 5,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image": {
id: "openai/gpt-5-image",
name: "OpenAI: GPT-5 Image",
@@ -425,6 +440,36 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-fast": {
id: "sourceful/riverflow-v2.5-fast",
name: "Sourceful: Riverflow V2.5 Fast",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-pro": {
id: "sourceful/riverflow-v2.5-pro",
name: "Sourceful: Riverflow V2.5 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"x-ai/grok-imagine-image-quality": {
id: "x-ai/grok-imagine-image-quality",
name: "xAI: Grok Imagine Image Quality",

File diff suppressed because it is too large Load Diff

View File

@@ -126,9 +126,9 @@ describe("Context overflow error handling", () => {
describe("GitHub Copilot (OAuth)", () => {
// OpenAI model via Copilot
it.skipIf(!githubCopilotToken)(
"gpt-4o - should detect overflow via isContextOverflow",
"claude-sonnet-4.6 - should detect overflow via isContextOverflow",
async () => {
const model = getModel("github-copilot", "gpt-4o");
const model = getModel("github-copilot", "claude-sonnet-4.6");
const result = await testContextOverflow(model, githubCopilotToken!);
logResult(result);
@@ -297,8 +297,8 @@ describe("Context overflow error handling", () => {
// =============================================================================
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => {
it("qwen-3-235b - should detect overflow via isContextOverflow", async () => {
const model = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507");
it("gpt-oss-120b - should detect overflow via isContextOverflow", async () => {
const model = getModel("cerebras", "gpt-oss-120b");
const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!);
logResult(result);

View File

@@ -630,37 +630,37 @@ describe("AI Providers Empty Message Tests", () => {
describe("GitHub Copilot Provider Empty Messages", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle empty content array",
"claude-sonnet-4.6 - should handle empty content array",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle empty string content",
"claude-sonnet-4.6 - should handle empty string content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle whitespace-only content",
"claude-sonnet-4.6 - should handle whitespace-only content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle empty assistant message in conversation",
"claude-sonnet-4.6 - should handle empty assistant message in conversation",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
},
);

View File

@@ -38,7 +38,7 @@ describe("Fireworks models", () => {
});
it("registers the Fire Pass turbo router model", () => {
const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p5-turbo");
const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p6-turbo");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");

View File

@@ -443,19 +443,19 @@ describe("Tool Results with Images", () => {
describe("GitHub Copilot Provider", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle tool result with only image",
"claude-sonnet-4.6 - should handle tool result with only image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle tool result with text and image",
"claude-sonnet-4.6 - should handle tool result with text and image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
},
);

View File

@@ -149,7 +149,7 @@ describe("Token Statistics on Abort", () => {
});
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider", () => {
const llm = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507");
const llm = getModel("cerebras", "gpt-oss-120b");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
@@ -289,10 +289,10 @@ describe("Token Statistics on Abort", () => {
describe("GitHub Copilot Provider", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should include token stats when aborted mid-stream",
"claude-sonnet-4.6 - should include token stats when aborted mid-stream",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testTokensOnAbort(llm, { apiKey: githubCopilotToken });
},
);

View File

@@ -297,10 +297,10 @@ describe("Tool Call Without Result Tests", () => {
describe("GitHub Copilot Provider", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should filter out tool calls without corresponding tool results",
"claude-sonnet-4.6 - should filter out tool calls without corresponding tool results",
{ retry: 3, timeout: 30000 },
async () => {
const model = getModel("github-copilot", "gpt-4o");
const model = getModel("github-copilot", "claude-sonnet-4.6");
await testToolCallWithoutResult(model, { apiKey: githubCopilotToken });
},
);

View File

@@ -666,10 +666,10 @@ describe("totalTokens field", () => {
);
it(
"google/gemini-2.0-flash-001 - should return totalTokens equal to sum of components",
"google/gemini-2.5-flash - should return totalTokens equal to sum of components",
{ retry: 3, timeout: 60000 },
async () => {
const llm = getModel("openrouter", "google/gemini-2.0-flash-001");
const llm = getModel("openrouter", "google/gemini-2.5-flash");
console.log(`\nOpenRouter / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY });
@@ -706,10 +706,10 @@ describe("totalTokens field", () => {
describe("GitHub Copilot (OAuth)", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should return totalTokens equal to sum of components",
"claude-sonnet-4.6 - should return totalTokens equal to sum of components",
{ retry: 3, timeout: 60000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
console.log(`\nGitHub Copilot / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: githubCopilotToken });

View File

@@ -396,28 +396,28 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
describe("GitHub Copilot Provider Unicode Handling", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle emoji in tool results",
"claude-sonnet-4.6 - should handle emoji in tool results",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmojiInToolResults(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle real-world LinkedIn comment data with emoji",
"claude-sonnet-4.6 - should handle real-world LinkedIn comment data with emoji",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle unpaired high surrogate (0xD83D) in tool results",
"claude-sonnet-4.6 - should handle unpaired high surrogate (0xD83D) in tool results",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken });
},
);

View File

@@ -426,7 +426,15 @@ Response:
Execute a shell command and add output to conversation context.
```json
{"type": "bash", "command": "ls -la"}
{"type": "bash", "command": "ls -la", "excludeFromContext": false}
```
Optional `excludeFromContext` (default `false`): when `true`, the command output is shown in the UI but excluded from the LLM context on the next prompt (equivalent to `!!cmd` in the TUI).
During execution, `bash_update` events stream partial stdout:
```json
{"type": "bash_update", "command": "ls -la", "partialOutput": "total 48\n"}
```
Response:

View File

@@ -1321,8 +1321,7 @@
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
},
"peer": true
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
@@ -1775,8 +1774,7 @@
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
},
"peer": true
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",

View File

@@ -546,7 +546,15 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
// =================================================================
case "bash": {
const result = await session.executeBash(command.command);
let partialOutput = "";
const result = await session.executeBash(
command.command,
(chunk) => {
partialOutput += chunk;
output({ type: "bash_update", command: command.command, partialOutput });
},
{ excludeFromContext: command.excludeFromContext },
);
return success(id, "bash", result);
}

View File

@@ -50,7 +50,7 @@ export type RpcCommand =
| { id?: string; type: "abort_retry" }
// Bash
| { id?: string; type: "bash"; command: string }
| { id?: string; type: "bash"; command: string; excludeFromContext?: boolean }
| { id?: string; type: "abort_bash" }
// Session

77
pi-built.bat Normal file
View File

@@ -0,0 +1,77 @@
@echo off
setlocal EnableDelayedExpansion
set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
set "PI_CLI_DIST=%SCRIPT_DIR%\packages\coding-agent\dist\cli.js"
if not defined PI_CODING_AGENT_DIR (
set "PI_CODING_AGENT_DIR=%SCRIPT_DIR%\.pi\agent"
)
set "NO_ENV=false"
set "ARGS="
:loop
if "%~1"=="" goto endloop
if "%~1"=="--no-env" (
set "NO_ENV=true"
) else (
if "%ARGS%"=="" (
set "ARGS=%1"
) else (
set "ARGS=%ARGS% %1"
)
)
shift
goto loop
:endloop
if "%NO_ENV%"=="true" (
set "ANTHROPIC_API_KEY="
set "ANTHROPIC_OAUTH_TOKEN="
set "OPENAI_API_KEY="
set "GEMINI_API_KEY="
set "GROQ_API_KEY="
set "CEREBRAS_API_KEY="
set "XAI_API_KEY="
set "OPENROUTER_API_KEY="
set "ZAI_API_KEY="
set "MISTRAL_API_KEY="
set "MINIMAX_API_KEY="
set "MINIMAX_CN_API_KEY="
set "AI_GATEWAY_API_KEY="
set "OPENCODE_API_KEY="
set "COPILOT_GITHUB_TOKEN="
set "GH_TOKEN="
set "GITHUB_TOKEN="
set "HF_TOKEN="
set "GOOGLE_APPLICATION_CREDENTIALS="
set "GOOGLE_CLOUD_PROJECT="
set "GCLOUD_PROJECT="
set "GOOGLE_CLOUD_LOCATION="
set "AWS_PROFILE="
set "AWS_ACCESS_KEY_ID="
set "AWS_SECRET_ACCESS_KEY="
set "AWS_SESSION_TOKEN="
set "AWS_REGION="
set "AWS_DEFAULT_REGION="
set "AWS_BEARER_TOKEN_BEDROCK="
set "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI="
set "AWS_CONTAINER_CREDENTIALS_FULL_URI="
set "AWS_WEB_IDENTITY_TOKEN_FILE="
set "AZURE_OPENAI_API_KEY="
set "AZURE_OPENAI_BASE_URL="
set "AZURE_OPENAI_RESOURCE_NAME="
echo Running without API keys...
)
if not exist "%PI_CLI_DIST%" (
echo Build output not found: %PI_CLI_DIST% >&2
echo Run from repo root: npm run build >&2
exit /b 1
)
node "%PI_CLI_DIST%" %ARGS%