From d51a055d78c6335d54e87a8b51e31ff95176f1e2 Mon Sep 17 00:00:00 2001 From: shumengya Date: Wed, 10 Jun 2026 21:45:53 +0800 Subject: [PATCH] feat: update webui extensions, models, and agent config --- .../extensions/sproutclaw-setup/index.ts | 11 + .../extensions/webui/backend/config/paths.ts | 25 +- .../extensions/webui/backend/http/server.ts | 2 + .../extensions/webui/backend/http/static.ts | 7 +- .pi/agent/extensions/webui/backend/main.ts | 14 +- .../extensions/webui/backend/routes/chat.ts | 70 +- .../webui/backend/routes/extension-ui.ts | 25 + .../webui/backend/routes/sessions.ts | 7 +- .../webui/backend/routes/settings.ts | 19 + .../extensions/webui/backend/rpc/pi-client.ts | 57 +- .../webui/backend/services/sessions.ts | 11 +- .../extensions/webui/backend/types/context.ts | 2 + .../extensions/webui/backend/utils/paths.ts | 22 + .../extensions/webui/frontend/src/api/chat.ts | 33 +- .../webui/frontend/src/api/settings.ts | 6 +- .../chat/BashOutputBlock.module.css | 9 + .../src/components/chat/BashOutputBlock.tsx | 11 +- .../src/components/chat/ChatInput.tsx | 61 +- .../chat/CompactionSummaryBlock.module.css | 26 + .../chat/CompactionSummaryBlock.tsx | 15 + .../chat/ExtensionDialogModal.module.css | 80 + .../components/chat/ExtensionDialogModal.tsx | 78 + .../chat/ExtensionWidgetPanel.module.css | 25 + .../components/chat/ExtensionWidgetPanel.tsx | 21 + .../src/components/chat/MessageBubble.tsx | 63 +- .../components/chat/MessageList.module.css | 30 +- .../src/components/chat/MessageList.tsx | 26 +- .../chat/PendingQueueBar.module.css | 30 + .../src/components/chat/PendingQueueBar.tsx | 35 + .../components/chat/RunStatusBar.module.css | 47 + .../src/components/chat/RunStatusBar.tsx | 56 + .../chat/SessionContextBar.module.css | 76 +- .../src/components/chat/SessionContextBar.tsx | 52 +- .../components/chat/ToolCallBlock.module.css | 12 + .../src/components/chat/ToolCallBlock.tsx | 20 +- .../src/components/layout/Header.module.css | 162 +- .../frontend/src/components/layout/Header.tsx | 51 +- .../frontend/src/context/ChatContext.tsx | 326 ++- .../webui/frontend/src/routes/ChatPage.tsx | 35 + .../src/routes/SettingsPage.module.css | 43 + .../frontend/src/routes/SettingsPage.tsx | 82 +- .../webui/frontend/src/types/events.ts | 94 +- .../webui/frontend/src/types/message.ts | 19 +- .../frontend/src/utils/exportConversation.ts | 12 + .../webui/frontend/src/utils/format.ts | 16 + .../frontend/src/utils/historyMessages.ts | 117 + .../webui/frontend/src/utils/toolCall.ts | 26 + .pi/agent/extensions/webui/index.ts | 88 +- package-lock.json | 7 - packages/ai/src/image-models.generated.ts | 45 + packages/ai/src/models.generated.ts | 2527 +++++++++-------- packages/ai/test/context-overflow.test.ts | 8 +- packages/ai/test/empty.test.ts | 16 +- packages/ai/test/fireworks-models.test.ts | 2 +- packages/ai/test/image-tool-result.test.ts | 8 +- packages/ai/test/tokens.test.ts | 6 +- .../ai/test/tool-call-without-result.test.ts | 4 +- packages/ai/test/total-tokens.test.ts | 8 +- packages/ai/test/unicode-surrogate.test.ts | 12 +- packages/coding-agent/docs/rpc.md | 10 +- packages/coding-agent/npm-shrinkwrap.json | 6 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 10 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 2 +- pi-built.bat | 77 + 64 files changed, 3238 insertions(+), 1665 deletions(-) create mode 100644 .pi/agent/extensions/webui/backend/routes/extension-ui.ts create mode 100644 .pi/agent/extensions/webui/backend/utils/paths.ts create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/CompactionSummaryBlock.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/CompactionSummaryBlock.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/ExtensionDialogModal.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/ExtensionDialogModal.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/ExtensionWidgetPanel.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/ExtensionWidgetPanel.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/PendingQueueBar.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/PendingQueueBar.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/RunStatusBar.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/RunStatusBar.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/utils/historyMessages.ts create mode 100644 pi-built.bat diff --git a/.pi/agent/extensions/sproutclaw-setup/index.ts b/.pi/agent/extensions/sproutclaw-setup/index.ts index 600f089e..f440a90c 100644 --- a/.pi/agent/extensions/sproutclaw-setup/index.ts +++ b/.pi/agent/extensions/sproutclaw-setup/index.ts @@ -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) { diff --git a/.pi/agent/extensions/webui/backend/config/paths.ts b/.pi/agent/extensions/webui/backend/config/paths.ts index e85dc0d4..86ade9c4 100644 --- a/.pi/agent/extensions/webui/backend/config/paths.ts +++ b/.pi/agent/extensions/webui/backend/config/paths.ts @@ -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}`); } diff --git a/.pi/agent/extensions/webui/backend/http/server.ts b/.pi/agent/extensions/webui/backend/http/server.ts index 95d856a2..956e471e 100644 --- a/.pi/agent/extensions/webui/backend/http/server.ts +++ b/.pi/agent/extensions/webui/backend/http/server.ts @@ -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; diff --git a/.pi/agent/extensions/webui/backend/http/static.ts b/.pi/agent/extensions/webui/backend/http/static.ts index 971f84ee..40c11453 100644 --- a/.pi/agent/extensions/webui/backend/http/static.ts +++ b/.pi/agent/extensions/webui/backend/http/static.ts @@ -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 = { ".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; diff --git a/.pi/agent/extensions/webui/backend/main.ts b/.pi/agent/extensions/webui/backend/main.ts index 8d79a20b..7768abbd 100644 --- a/.pi/agent/extensions/webui/backend/main.ts +++ b/.pi/agent/extensions/webui/backend/main.ts @@ -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}`); }); diff --git a/.pi/agent/extensions/webui/backend/routes/chat.ts b/.pi/agent/extensions/webui/backend/routes/chat.ts index b483b1e1..9806cca0 100644 --- a/.pi/agent/extensions/webui/backend/routes/chat.ts +++ b/.pi/agent/extensions/webui/backend/routes/chat.ts @@ -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, + sendCmd: WebUiContext["rpc"]["sendCmd"], +): Promise> | 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; } diff --git a/.pi/agent/extensions/webui/backend/routes/extension-ui.ts b/.pi/agent/extensions/webui/backend/routes/extension-ui.ts new file mode 100644 index 00000000..2e84e1af --- /dev/null +++ b/.pi/agent/extensions/webui/backend/routes/extension-ui.ts @@ -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); + json(res, { ok: true }); + }) + .catch((err) => json(res, { error: err.message }, 500)); + + return true; +} diff --git a/.pi/agent/extensions/webui/backend/routes/sessions.ts b/.pi/agent/extensions/webui/backend/routes/sessions.ts index 06f14ced..77e63e48 100644 --- a/.pi/agent/extensions/webui/backend/routes/sessions.ts +++ b/.pi/agent/extensions/webui/backend/routes/sessions.ts @@ -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 || "同步当前会话名称失败"); } diff --git a/.pi/agent/extensions/webui/backend/routes/settings.ts b/.pi/agent/extensions/webui/backend/routes/settings.ts index a61bb8bd..feda8226 100644 --- a/.pi/agent/extensions/webui/backend/routes/settings.ts +++ b/.pi/agent/extensions/webui/backend/routes/settings.ts @@ -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; } diff --git a/.pi/agent/extensions/webui/backend/rpc/pi-client.ts b/.pi/agent/extensions/webui/backend/rpc/pi-client.ts index 5fb72c57..6ef0007f 100644 --- a/.pi/agent/extensions/webui/backend/rpc/pi-client.ts +++ b/.pi/agent/extensions/webui/backend/rpc/pi-client.ts @@ -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) => 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 = { + 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): 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), diff --git a/.pi/agent/extensions/webui/backend/services/sessions.ts b/.pi/agent/extensions/webui/backend/services/sessions.ts index b68e7423..4677e001 100644 --- a/.pi/agent/extensions/webui/backend/services/sessions.ts +++ b/.pi/agent/extensions/webui/backend/services/sessions.ts @@ -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; diff --git a/.pi/agent/extensions/webui/backend/types/context.ts b/.pi/agent/extensions/webui/backend/types/context.ts index 8ec90944..742bd6a6 100644 --- a/.pi/agent/extensions/webui/backend/types/context.ts +++ b/.pi/agent/extensions/webui/backend/types/context.ts @@ -7,6 +7,7 @@ export type SendCmd = (command: Record) => Promise; 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) => void; getRunSnapshot: () => RunSnapshot; connectSseClient: (res: ServerResponse) => void; removeSseClient: (res: ServerResponse) => void; diff --git a/.pi/agent/extensions/webui/backend/utils/paths.ts b/.pi/agent/extensions/webui/backend/utils/paths.ts new file mode 100644 index 00000000..5cd4720d --- /dev/null +++ b/.pi/agent/extensions/webui/backend/utils/paths.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/frontend/src/api/chat.ts b/.pi/agent/extensions/webui/frontend/src/api/chat.ts index eb8fd294..b60d8459 100644 --- a/.pi/agent/extensions/webui/frontend/src/api/chat.ts +++ b/.pi/agent/extensions/webui/frontend/src/api/chat.ts @@ -13,18 +13,43 @@ export interface ChatResponse { error?: string; } -export function sendChat(message: string, images?: ImageContent[]): Promise { - 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 { + return apiPost("/api/chat", { message, images, streamingBehavior: options?.streamingBehavior }); } -export function runBash(command: string): Promise { - 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 { + 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, +): Promise<{ ok: boolean }> { + return apiPost("/api/extension-ui-response", { id, response }); +} + export function fetchSessionState(): Promise { return apiGet("/api/session-state"); } diff --git a/.pi/agent/extensions/webui/frontend/src/api/settings.ts b/.pi/agent/extensions/webui/frontend/src/api/settings.ts index e9d3e605..cb58dac3 100644 --- a/.pi/agent/extensions/webui/frontend/src/api/settings.ts +++ b/.pi/agent/extensions/webui/frontend/src/api/settings.ts @@ -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 { return apiGet("/api/settings"); @@ -50,3 +50,7 @@ export function saveModelsConfig(modelsConfig: string): Promise<{ modelsConfigPa export function saveAvatars(avatars: AvatarSettings): Promise { return apiPost("/api/settings/avatars", avatars); } + +export function fetchEnvironment(): Promise { + return apiGet("/api/environment"); +} diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css index cfc2ffa2..4e1b3bb5 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css @@ -2,6 +2,15 @@ margin: 0; } +.blockDim .header { + opacity: 0.75; +} + +.blockDim .command, +.blockDim .output { + color: #6b7280; +} + .header { display: flex; align-items: center; diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx index 2296ebf8..9a0719c2 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx @@ -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 ( -
+