feat: update webui extensions, models, and agent config
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
25
.pi/agent/extensions/webui/backend/routes/extension-ui.ts
Normal file
25
.pi/agent/extensions/webui/backend/routes/extension-ui.ts
Normal 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;
|
||||
}
|
||||
@@ -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 || "同步当前会话名称失败");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
22
.pi/agent/extensions/webui/backend/utils/paths.ts
Normal file
22
.pi/agent/extensions/webui/backend/utils/paths.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.blockDim .header {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.blockDim .command,
|
||||
.blockDim .output {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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="发送"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
117
.pi/agent/extensions/webui/frontend/src/utils/historyMessages.ts
Normal file
117
.pi/agent/extensions/webui/frontend/src/utils/historyMessages.ts
Normal 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);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function deriveToolCallSummary(fullText: string): string {
|
||||
const raw = String(fullText || "").trim();
|
||||
if (!raw) return "tool";
|
||||
|
||||
@@ -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
7
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
6
packages/coding-agent/npm-shrinkwrap.json
generated
6
packages/coding-agent/npm-shrinkwrap.json
generated
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
77
pi-built.bat
Normal 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%
|
||||
|
||||
Reference in New Issue
Block a user