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) {
|
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)) {
|
for (const [name, launcher] of Object.entries(COMMAND_LAUNCHERS)) {
|
||||||
const created = installCommand(name, commandScript(launcher));
|
const created = installCommand(name, commandScript(launcher));
|
||||||
if (created) {
|
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"];
|
const rpcArgs = ["--mode", "rpc"];
|
||||||
if (!existsSync(paths.piCliDist)) {
|
if (existsSync(paths.piCliDist)) {
|
||||||
throw new Error(
|
return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
|
||||||
`[webui] 构建版 sproutclaw 未找到: ${paths.piCliDist}。请先运行: sproutclaw build`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
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 { applyCorsHeaders } from "./cors.ts";
|
||||||
import { serveStatic } from "./static.ts";
|
import { serveStatic } from "./static.ts";
|
||||||
import { handleChatRoute } from "../routes/chat.ts";
|
import { handleChatRoute } from "../routes/chat.ts";
|
||||||
|
import { handleExtensionUiRoute } from "../routes/extension-ui.ts";
|
||||||
import { handleCommandsRoute } from "../routes/commands.ts";
|
import { handleCommandsRoute } from "../routes/commands.ts";
|
||||||
import { handleModelsRoute } from "../routes/models.ts";
|
import { handleModelsRoute } from "../routes/models.ts";
|
||||||
import { handleSessionsRoute } from "../routes/sessions.ts";
|
import { handleSessionsRoute } from "../routes/sessions.ts";
|
||||||
@@ -24,6 +25,7 @@ export function createWebUiServer(ctx: WebUiContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (handleChatRoute(req, res, ctx, pathname)) return;
|
if (handleChatRoute(req, res, ctx, pathname)) return;
|
||||||
|
if (handleExtensionUiRoute(req, res, ctx, pathname)) return;
|
||||||
if (handleSessionsRoute(req, res, ctx, pathname)) return;
|
if (handleSessionsRoute(req, res, ctx, pathname)) return;
|
||||||
if (handleModelsRoute(req, res, ctx, pathname)) return;
|
if (handleModelsRoute(req, res, ctx, pathname)) return;
|
||||||
if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
|
if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { gzipSync } from "node:zlib";
|
import { gzipSync } from "node:zlib";
|
||||||
import type { ServerResponse, IncomingMessage } from "node:http";
|
import type { ServerResponse, IncomingMessage } from "node:http";
|
||||||
|
import { isPathInsideRoot } from "../utils/paths.ts";
|
||||||
|
|
||||||
const MIME: Record<string, string> = {
|
const MIME: Record<string, string> = {
|
||||||
".html": "text/html; charset=utf-8",
|
".html": "text/html; charset=utf-8",
|
||||||
@@ -40,7 +41,9 @@ export function serveStatic(
|
|||||||
): void {
|
): void {
|
||||||
const file = urlPath === "/" ? "/index.html" : urlPath;
|
const file = urlPath === "/" ? "/index.html" : urlPath;
|
||||||
const full = join(publicDir, file);
|
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.writeHead(403);
|
||||||
res.end("Forbidden");
|
res.end("Forbidden");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { arch, hostname, platform, release, type } from "node:os";
|
||||||
import { parsePort } from "./config/cli.ts";
|
import { parsePort } from "./config/cli.ts";
|
||||||
import { createWebUiPaths } from "./config/paths.ts";
|
import { createWebUiPaths } from "./config/paths.ts";
|
||||||
import { closeWebuiDatabase, initWebuiDatabase } from "./db/index.ts";
|
import { closeWebuiDatabase, initWebuiDatabase } from "./db/index.ts";
|
||||||
@@ -47,18 +48,27 @@ function shutdown(exitCode = 0): never {
|
|||||||
|
|
||||||
const webuiDb = initWebuiDatabase(paths.extensionRoot);
|
const webuiDb = initWebuiDatabase(paths.extensionRoot);
|
||||||
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
|
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
|
||||||
|
console.log(`[webui] 运行环境: Node.js ${process.version} | ${type()} ${release()} (${platform()}/${arch()}) | 主机: ${hostname()}`);
|
||||||
|
|
||||||
process.on("SIGTERM", () => shutdown(0));
|
process.on("SIGTERM", () => shutdown(0));
|
||||||
process.on("SIGINT", () => shutdown(0));
|
process.on("SIGINT", () => shutdown(0));
|
||||||
writePidFile();
|
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 = {
|
const ctx: WebUiContext = {
|
||||||
config: { paths, port },
|
config: { paths, port },
|
||||||
rpc: {
|
rpc: {
|
||||||
sendCmd: piClient.sendCmd,
|
sendCmd: piClient.sendCmd,
|
||||||
submitPrompt: piClient.submitPrompt,
|
submitPrompt: piClient.submitPrompt,
|
||||||
|
sendExtensionUiResponse: piClient.sendExtensionUiResponse,
|
||||||
getRunSnapshot: piClient.getRunSnapshot,
|
getRunSnapshot: piClient.getRunSnapshot,
|
||||||
connectSseClient: piClient.connectSseClient,
|
connectSseClient: piClient.connectSseClient,
|
||||||
removeSseClient: piClient.removeSseClient,
|
removeSseClient: piClient.removeSseClient,
|
||||||
@@ -79,5 +89,5 @@ server.on("error", (err: NodeJS.ErrnoException) => {
|
|||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
server.listen(port, "0.0.0.0", () => {
|
||||||
console.log(`[webui] HTTP 服务已启动: http://localhost:${port}`);
|
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 type { WebUiContext } from "../types/context.ts";
|
||||||
import { json, readBody } from "../http/request.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(
|
export function handleChatRoute(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -14,32 +25,66 @@ export function handleChatRoute(
|
|||||||
|
|
||||||
if (req.method === "POST" && pathname === "/api/chat") {
|
if (req.method === "POST" && pathname === "/api/chat") {
|
||||||
void readBody(req)
|
void readBody(req)
|
||||||
.then(async ({ message, images }) => {
|
.then(async ({ message, images, streamingBehavior }) => {
|
||||||
const msg = typeof message === "string" ? message : "";
|
const msg = typeof message === "string" ? message : "";
|
||||||
const imgs = normalizeChatImages(images);
|
const imgs = normalizeChatImages(images);
|
||||||
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
|
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) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
|
||||||
|
|
||||||
if (!imgs?.length && msg.trim().startsWith("/")) {
|
const slash = await trySlashDispatch(msg, imgs, sendCmd);
|
||||||
const slash = await dispatchSlashCommand(msg, sendCmd);
|
if (slash) {
|
||||||
if (slash?.handled) {
|
return json(res, { ok: true, slash: true, ...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);
|
return json(res, { ok: true, accepted: true }, 202);
|
||||||
})
|
})
|
||||||
.catch((err) => json(res, { error: err.message }, 500));
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
return true;
|
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") {
|
if (req.method === "POST" && pathname === "/api/bash") {
|
||||||
void readBody(req)
|
void readBody(req)
|
||||||
.then(async ({ command }) => {
|
.then(async ({ command, excludeFromContext }) => {
|
||||||
const cmd = typeof command === "string" ? command.trim() : "";
|
const cmd = typeof command === "string" ? command.trim() : "";
|
||||||
if (!cmd) throw new Error("命令不能为空");
|
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 || "命令执行失败");
|
if (!result.success) throw new Error(result.error || "命令执行失败");
|
||||||
json(res, result.data);
|
json(res, result.data);
|
||||||
})
|
})
|
||||||
@@ -72,5 +117,12 @@ export function handleChatRoute(
|
|||||||
return true;
|
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;
|
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,
|
resolveSessionFile,
|
||||||
} from "../services/sessions.ts";
|
} from "../services/sessions.ts";
|
||||||
import type { WebUiContext } from "../types/context.ts";
|
import type { WebUiContext } from "../types/context.ts";
|
||||||
|
import { isSameResolvedPath } from "../utils/paths.ts";
|
||||||
import { json, readBody } from "../http/request.ts";
|
import { json, readBody } from "../http/request.ts";
|
||||||
|
|
||||||
export function handleSessionsRoute(
|
export function handleSessionsRoute(
|
||||||
@@ -103,7 +104,11 @@ export function handleSessionsRoute(
|
|||||||
const savedName = appendSessionName(paths, sp, typeof name === "string" ? name : "");
|
const savedName = appendSessionName(paths, sp, typeof name === "string" ? name : "");
|
||||||
const sessionPath = resolveSessionFile(paths, sp);
|
const sessionPath = resolveSessionFile(paths, sp);
|
||||||
const state = await sendCmd({ type: "get_state" });
|
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 });
|
const rename = await sendCmd({ type: "set_session_name", name: savedName });
|
||||||
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
|
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
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 { readWebuiAvatarSettings, writeWebuiAvatarSettings } from "../db/index.ts";
|
||||||
import { setExtensionEnabled } from "../settings/extension-settings.ts";
|
import { setExtensionEnabled } from "../settings/extension-settings.ts";
|
||||||
import { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from "../settings/mcp-settings.ts";
|
import { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from "../settings/mcp-settings.ts";
|
||||||
@@ -202,5 +203,23 @@ export function handleSettingsRoute(
|
|||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { RunSnapshot, SendCmd, SubmitPromptOptions } from "../types/context
|
|||||||
export interface PiClient {
|
export interface PiClient {
|
||||||
sendCmd: SendCmd;
|
sendCmd: SendCmd;
|
||||||
submitPrompt: (options: SubmitPromptOptions) => void;
|
submitPrompt: (options: SubmitPromptOptions) => void;
|
||||||
|
sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
|
||||||
getRunSnapshot: () => RunSnapshot;
|
getRunSnapshot: () => RunSnapshot;
|
||||||
connectSseClient: (res: ServerResponse) => void;
|
connectSseClient: (res: ServerResponse) => void;
|
||||||
removeSseClient: (res: ServerResponse) => void;
|
removeSseClient: (res: ServerResponse) => void;
|
||||||
@@ -23,14 +24,32 @@ const BUFFERED_EVENT_TYPES = new Set([
|
|||||||
"tool_execution_end",
|
"tool_execution_end",
|
||||||
"compaction_start",
|
"compaction_start",
|
||||||
"compaction_end",
|
"compaction_end",
|
||||||
|
"queue_update",
|
||||||
|
"auto_retry_start",
|
||||||
|
"auto_retry_end",
|
||||||
|
"bash_update",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const DEFAULT_CMD_TIMEOUT_MS = 60_000;
|
const DEFAULT_CMD_TIMEOUT_MS = 60_000;
|
||||||
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_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 {
|
export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
|
||||||
const piLaunch = resolvePiRpcLaunch(paths);
|
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, {
|
const pi = spawn(piLaunch.command, piLaunch.args, {
|
||||||
cwd: paths.repoRoot,
|
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) => {
|
pi.on("exit", (code) => {
|
||||||
console.log(`[webui] pi 退出, code=${code}`);
|
console.log(`[webui] pi 退出, code=${code}`);
|
||||||
|
if (code !== 0 && code !== null) {
|
||||||
|
console.error(`[webui] pi RPC 启动失败 (code=${code}):\n${summarizeStderr(stderrBuffer)}`);
|
||||||
|
}
|
||||||
onExit(code);
|
onExit(code);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,13 +187,16 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
|
|||||||
|
|
||||||
function submitPrompt(options: SubmitPromptOptions): void {
|
function submitPrompt(options: SubmitPromptOptions): void {
|
||||||
const id = `req_${++reqId}`;
|
const id = `req_${++reqId}`;
|
||||||
const line =
|
const payload: Record<string, unknown> = {
|
||||||
JSON.stringify({
|
type: "prompt",
|
||||||
type: "prompt",
|
message: options.message,
|
||||||
message: options.message,
|
images: options.images,
|
||||||
images: options.images,
|
id,
|
||||||
id,
|
};
|
||||||
}) + "\n";
|
if (options.streamingBehavior) {
|
||||||
|
payload.streamingBehavior = options.streamingBehavior;
|
||||||
|
}
|
||||||
|
const line = JSON.stringify(payload) + "\n";
|
||||||
registerPending(
|
registerPending(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
@@ -180,6 +213,11 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
|
|||||||
pi.stdin.write(line);
|
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 {
|
function connectSseClient(res: ServerResponse): void {
|
||||||
const snapshot = getRunSnapshot();
|
const snapshot = getRunSnapshot();
|
||||||
res.write(
|
res.write(
|
||||||
@@ -195,6 +233,7 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
|
|||||||
return {
|
return {
|
||||||
sendCmd,
|
sendCmd,
|
||||||
submitPrompt,
|
submitPrompt,
|
||||||
|
sendExtensionUiResponse,
|
||||||
getRunSnapshot,
|
getRunSnapshot,
|
||||||
connectSseClient,
|
connectSseClient,
|
||||||
removeSseClient: (res) => sseClients.delete(res),
|
removeSseClient: (res) => sseClients.delete(res),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import type { WebUiPaths } from "../config/paths.ts";
|
import type { WebUiPaths } from "../config/paths.ts";
|
||||||
|
import { isPathInsideRoot } from "../utils/paths.ts";
|
||||||
import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts";
|
import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts";
|
||||||
|
|
||||||
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
|
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 {
|
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);
|
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("无效会话路径");
|
throw new Error("无效会话路径");
|
||||||
}
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type SendCmd = (command: Record<string, unknown>) => Promise<any>;
|
|||||||
export interface SubmitPromptOptions {
|
export interface SubmitPromptOptions {
|
||||||
message: string;
|
message: string;
|
||||||
images?: Array<{ type: "image"; data: string; mimeType: string }>;
|
images?: Array<{ type: "image"; data: string; mimeType: string }>;
|
||||||
|
streamingBehavior?: "steer" | "followUp";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunSnapshot {
|
export interface RunSnapshot {
|
||||||
@@ -23,6 +24,7 @@ export interface WebUiContext {
|
|||||||
rpc: {
|
rpc: {
|
||||||
sendCmd: SendCmd;
|
sendCmd: SendCmd;
|
||||||
submitPrompt: (options: SubmitPromptOptions) => void;
|
submitPrompt: (options: SubmitPromptOptions) => void;
|
||||||
|
sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
|
||||||
getRunSnapshot: () => RunSnapshot;
|
getRunSnapshot: () => RunSnapshot;
|
||||||
connectSseClient: (res: ServerResponse) => void;
|
connectSseClient: (res: ServerResponse) => void;
|
||||||
removeSseClient: (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;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendChat(message: string, images?: ImageContent[]): Promise<ChatResponse> {
|
export type SendMode = "prompt" | "steer" | "follow_up";
|
||||||
return apiPost("/api/chat", { message, images });
|
|
||||||
|
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> {
|
export function sendSteer(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||||
return apiPost("/api/bash", { command });
|
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 }> {
|
export function abortChat(): Promise<{ ok: boolean }> {
|
||||||
return apiPost("/api/abort");
|
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> {
|
export function fetchSessionState(): Promise<SessionState> {
|
||||||
return apiGet("/api/session-state");
|
return apiGet("/api/session-state");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiGet, apiPost } from "./client";
|
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> {
|
export function fetchSettings(): Promise<SettingsData> {
|
||||||
return apiGet("/api/settings");
|
return apiGet("/api/settings");
|
||||||
@@ -50,3 +50,7 @@ export function saveModelsConfig(modelsConfig: string): Promise<{ modelsConfigPa
|
|||||||
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
|
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
|
||||||
return apiPost("/api/settings/avatars", avatars);
|
return apiPost("/api/settings/avatars", avatars);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchEnvironment(): Promise<EnvironmentInfo> {
|
||||||
|
return apiGet("/api/environment");
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blockDim .header {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockDim .command,
|
||||||
|
.blockDim .output {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -5,14 +5,21 @@ interface BashOutputBlockProps {
|
|||||||
content: string;
|
content: string;
|
||||||
exitCode?: number;
|
exitCode?: number;
|
||||||
streaming?: boolean;
|
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 hasOutput = Boolean(content.trim());
|
||||||
const showExit = exitCode !== undefined && exitCode !== 0 && !streaming;
|
const showExit = exitCode !== undefined && exitCode !== 0 && !streaming;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.block}>
|
<div className={`${styles.block} ${excludeFromContext ? styles.blockDim : ""}`}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.icon} aria-hidden="true">
|
<span className={styles.icon} aria-hidden="true">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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";
|
import styles from "./ChatInput.module.css";
|
||||||
|
|
||||||
export function ChatInput() {
|
export function ChatInput() {
|
||||||
const { isStreaming, sendMessage, runBashCommand, addSystemMessage } = useChatContext();
|
const {
|
||||||
|
isStreaming,
|
||||||
|
sendMessage,
|
||||||
|
runBashCommand,
|
||||||
|
addSystemMessage,
|
||||||
|
abortStream,
|
||||||
|
toggleToolsExpanded,
|
||||||
|
} = useChatContext();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
@@ -47,6 +54,22 @@ export function ChatInput() {
|
|||||||
};
|
};
|
||||||
}, [addSystemMessage]);
|
}, [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 slashContext = useMemo(() => parseSlashCommandInput(value), [value]);
|
||||||
const filteredSlashCommands = useMemo(
|
const filteredSlashCommands = useMemo(
|
||||||
() => (slashContext ? filterSlashCommands(slashCommands, slashContext.query) : []),
|
() => (slashContext ? filterSlashCommands(slashCommands, slashContext.query) : []),
|
||||||
@@ -58,11 +81,7 @@ export function ChatInput() {
|
|||||||
setSelectedSlashIndex(0);
|
setSelectedSlashIndex(0);
|
||||||
}, [value, slashMenuOpen]);
|
}, [value, slashMenuOpen]);
|
||||||
|
|
||||||
const isBashInput = Boolean(value.trim() && parseBashInput(value));
|
const canSend = value.trim().length > 0 || pendingImages.length > 0;
|
||||||
const isSlashInput = value.trimStart().startsWith("/");
|
|
||||||
const canSendDuringStream = isBashInput || isSlashInput;
|
|
||||||
const canSend =
|
|
||||||
(value.trim().length > 0 || pendingImages.length > 0) && (!isStreaming || canSendDuringStream);
|
|
||||||
|
|
||||||
const applySlashSelection = (command: SlashCommand) => {
|
const applySlashSelection = (command: SlashCommand) => {
|
||||||
const nextValue = applySlashCompletion(value, command.name);
|
const nextValue = applySlashCompletion(value, command.name);
|
||||||
@@ -104,10 +123,9 @@ export function ChatInput() {
|
|||||||
setPendingImages((prev) => prev.filter((_, i) => i !== index));
|
setPendingImages((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async (mode: "prompt" | "steer" | "follow_up" = "prompt") => {
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
const isBash = Boolean(text && parseBashInput(text));
|
if (!text && pendingImages.length === 0) return;
|
||||||
if ((!text && pendingImages.length === 0) || (isStreaming && !isBash && !text.startsWith("/"))) return;
|
|
||||||
|
|
||||||
const images = pendingImages.length ? pendingImages : undefined;
|
const images = pendingImages.length ? pendingImages : undefined;
|
||||||
setValue("");
|
setValue("");
|
||||||
@@ -117,7 +135,8 @@ export function ChatInput() {
|
|||||||
if (text.startsWith("!") && parseBashInput(text)) {
|
if (text.startsWith("!") && parseBashInput(text)) {
|
||||||
await runBashCommand(text);
|
await runBashCommand(text);
|
||||||
} else {
|
} else {
|
||||||
await sendMessage(text, images);
|
const sendMode = isStreaming && mode === "prompt" ? "steer" : mode;
|
||||||
|
await sendMessage(text, images, { mode: sendMode });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (touchLike) inputRef.current?.blur();
|
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) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void handleSend();
|
void handleSend(isStreaming ? "steer" : "prompt");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,7 +197,7 @@ export function ChatInput() {
|
|||||||
const native = e.nativeEvent as InputEvent;
|
const native = e.nativeEvent as InputEvent;
|
||||||
if (touchLike && native.inputType === "insertLineBreak") {
|
if (touchLike && native.inputType === "insertLineBreak") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void handleSend();
|
void handleSend(isStreaming ? "steer" : "prompt");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,11 +269,7 @@ export function ChatInput() {
|
|||||||
<div className={styles.attachments}>
|
<div className={styles.attachments}>
|
||||||
{pendingImages.map((img, index) => (
|
{pendingImages.map((img, index) => (
|
||||||
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
|
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
|
||||||
<img
|
<img className={styles.attachmentThumb} src={imageToDataUrl(img)} alt="" />
|
||||||
className={styles.attachmentThumb}
|
|
||||||
src={imageToDataUrl(img)}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.attachmentRemove}
|
className={styles.attachmentRemove}
|
||||||
@@ -283,7 +304,7 @@ export function ChatInput() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.attachBtn}
|
className={styles.attachBtn}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isStreaming || pendingImages.length >= MAX_CHAT_IMAGES}
|
disabled={pendingImages.length >= MAX_CHAT_IMAGES}
|
||||||
title="上传图片"
|
title="上传图片"
|
||||||
aria-label="上传图片"
|
aria-label="上传图片"
|
||||||
>
|
>
|
||||||
@@ -302,7 +323,7 @@ export function ChatInput() {
|
|||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBeforeInput={handleBeforeInput}
|
onBeforeInput={handleBeforeInput}
|
||||||
placeholder="输入消息…"
|
placeholder={isStreaming ? "Enter 改方向,Shift+Enter 排队后续…" : "输入消息… (/ 命令, ! bash)"}
|
||||||
aria-label="消息输入框"
|
aria-label="消息输入框"
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
inputMode="text"
|
inputMode="text"
|
||||||
@@ -310,7 +331,7 @@ export function ChatInput() {
|
|||||||
<button
|
<button
|
||||||
className={styles.sendBtn}
|
className={styles.sendBtn}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleSend()}
|
onClick={() => void handleSend(isStreaming ? "steer" : "prompt")}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
aria-label="发送"
|
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";
|
} from "../../utils/exportConversation";
|
||||||
import { renderMarkdown } from "../../utils/markdown";
|
import { renderMarkdown } from "../../utils/markdown";
|
||||||
import { imageToDataUrl } from "../../utils/images";
|
import { imageToDataUrl } from "../../utils/images";
|
||||||
|
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
|
||||||
import { ToolCallBlock } from "./ToolCallBlock";
|
import { ToolCallBlock } from "./ToolCallBlock";
|
||||||
import { ThinkingBlock } from "./ThinkingBlock";
|
import { ThinkingBlock } from "./ThinkingBlock";
|
||||||
import { BashOutputBlock } from "./BashOutputBlock";
|
import { BashOutputBlock } from "./BashOutputBlock";
|
||||||
@@ -15,8 +16,8 @@ import styles from "./MessageList.module.css";
|
|||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
showAgentAvatar?: boolean;
|
agentGutter?: "avatar" | "spacer" | "none";
|
||||||
agentAvatarSpacer?: boolean;
|
toolsExpanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatAvatar({ src, className }: { src: string; className: string }) {
|
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 }) {
|
function AssistantActions({ content }: { content: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const markdown = singleAssistantMarkdown(content);
|
const markdown = singleAssistantMarkdown(content);
|
||||||
@@ -78,19 +90,19 @@ function AssistantActions({ content }: { content: string }) {
|
|||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
showAgentAvatar = false,
|
agentGutter = "none",
|
||||||
agentAvatarSpacer = false,
|
toolsExpanded = false,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||||
|
const gutter =
|
||||||
|
agentGutter === "avatar" || agentGutter === "spacer" ? (
|
||||||
|
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (message.role === "thinking") {
|
if (message.role === "thinking") {
|
||||||
return (
|
return (
|
||||||
<div className={styles.assistantRow}>
|
<div className={styles.assistantRow}>
|
||||||
{showAgentAvatar ? (
|
{gutter}
|
||||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
|
||||||
) : agentAvatarSpacer ? (
|
|
||||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
|
||||||
) : null}
|
|
||||||
<div className={`${styles.msg} ${styles.thinking}`}>
|
<div className={`${styles.msg} ${styles.thinking}`}>
|
||||||
<ThinkingBlock content={message.content} streaming={message.streaming} />
|
<ThinkingBlock content={message.content} streaming={message.streaming} />
|
||||||
</div>
|
</div>
|
||||||
@@ -101,13 +113,9 @@ export function MessageBubble({
|
|||||||
if (message.role === "tool_call") {
|
if (message.role === "tool_call") {
|
||||||
return (
|
return (
|
||||||
<div className={styles.assistantRow}>
|
<div className={styles.assistantRow}>
|
||||||
{showAgentAvatar ? (
|
{gutter}
|
||||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
|
||||||
) : agentAvatarSpacer ? (
|
|
||||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
|
||||||
) : null}
|
|
||||||
<div className={`${styles.msg} ${styles.toolCall}`}>
|
<div className={`${styles.msg} ${styles.toolCall}`}>
|
||||||
<ToolCallBlock content={message.content} />
|
<ToolCallBlock content={message.content} forceOpen={toolsExpanded} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -116,23 +124,36 @@ export function MessageBubble({
|
|||||||
if (message.role === "bash") {
|
if (message.role === "bash") {
|
||||||
return (
|
return (
|
||||||
<div className={styles.assistantRow}>
|
<div className={styles.assistantRow}>
|
||||||
{showAgentAvatar ? (
|
{gutter}
|
||||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
|
||||||
) : agentAvatarSpacer ? (
|
|
||||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
|
||||||
) : null}
|
|
||||||
<div className={`${styles.msg} ${styles.bash}`}>
|
<div className={`${styles.msg} ${styles.bash}`}>
|
||||||
<BashOutputBlock
|
<BashOutputBlock
|
||||||
command={message.bashCommand}
|
command={message.bashCommand}
|
||||||
content={message.content}
|
content={message.content}
|
||||||
exitCode={message.bashExitCode}
|
exitCode={message.bashExitCode}
|
||||||
streaming={message.streaming}
|
streaming={message.streaming}
|
||||||
|
excludeFromContext={message.bashExcludeFromContext}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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];
|
const classNames = [styles.msg];
|
||||||
if (message.role === "user") classNames.push(styles.user);
|
if (message.role === "user") classNames.push(styles.user);
|
||||||
if (message.role === "assistant") classNames.push(styles.assistant);
|
if (message.role === "assistant") classNames.push(styles.assistant);
|
||||||
@@ -143,7 +164,7 @@ export function MessageBubble({
|
|||||||
const showActions = !message.streaming && message.content.trim().length > 0;
|
const showActions = !message.streaming && message.content.trim().length > 0;
|
||||||
return (
|
return (
|
||||||
<div className={styles.assistantRow}>
|
<div className={styles.assistantRow}>
|
||||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
{gutter}
|
||||||
<div className={classNames.join(" ")}>
|
<div className={classNames.join(" ")}>
|
||||||
{showActions ? <AssistantActions content={message.content} /> : null}
|
{showActions ? <AssistantActions content={message.content} /> : null}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -125,6 +125,11 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistantAvatarSlot {
|
||||||
|
width: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.assistantAvatarSpacer {
|
.assistantAvatarSpacer {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -205,10 +210,12 @@
|
|||||||
line-height: 1.46;
|
line-height: 1.46;
|
||||||
color: #666;
|
color: #666;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 0 4px;
|
margin: 0;
|
||||||
background: #fafafa;
|
overflow: hidden;
|
||||||
border-radius: 10px;
|
background: #fff;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking {
|
.thinking {
|
||||||
@@ -218,10 +225,12 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.46;
|
line-height: 1.46;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 0 4px;
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: #faf8fc;
|
background: #faf8fc;
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #ece6f5;
|
border: 1px solid #ece6f5;
|
||||||
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bash {
|
.bash {
|
||||||
@@ -376,12 +385,6 @@
|
|||||||
max-width: 66.6667%;
|
max-width: 66.6667%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistantRow .toolCall {
|
|
||||||
max-width: none;
|
|
||||||
width: auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -402,7 +405,8 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistantAvatarSpacer {
|
.assistantAvatarSpacer,
|
||||||
|
.assistantAvatarSlot {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useChatContext } from "../../context/ChatContext";
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import type { MessageRole } from "../../types/message";
|
||||||
import { MessageBubble } from "./MessageBubble";
|
import { MessageBubble } from "./MessageBubble";
|
||||||
import styles from "./MessageList.module.css";
|
import styles from "./MessageList.module.css";
|
||||||
|
|
||||||
@@ -13,8 +14,12 @@ function scrollToBottom(el: HTMLElement): void {
|
|||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAgentSideRole(role: MessageRole): boolean {
|
||||||
|
return role === "assistant" || role === "thinking" || role === "tool_call" || role === "bash";
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageList() {
|
export function MessageList() {
|
||||||
const { messages } = useChatContext();
|
const { messages, toolsExpanded } = useChatContext();
|
||||||
const chatRef = useRef<HTMLDivElement>(null);
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
const stickToBottomRef = useRef(true);
|
const stickToBottomRef = useRef(true);
|
||||||
const prevMessageCountRef = useRef(0);
|
const prevMessageCountRef = useRef(0);
|
||||||
@@ -73,25 +78,16 @@ export function MessageList() {
|
|||||||
<div className={styles.chat} ref={chatRef}>
|
<div className={styles.chat} ref={chatRef}>
|
||||||
{messages.map((msg, index) => {
|
{messages.map((msg, index) => {
|
||||||
const prev = messages[index - 1];
|
const prev = messages[index - 1];
|
||||||
const prevIsAgentSide =
|
const prevIsAgentSide = prev ? isAgentSideRole(prev.role) : false;
|
||||||
prev?.role === "assistant" ||
|
const isAgentSide = isAgentSideRole(msg.role);
|
||||||
prev?.role === "thinking" ||
|
const agentGutter = isAgentSide ? (prevIsAgentSide ? "spacer" : "avatar") : "none";
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg}
|
message={msg}
|
||||||
showAgentAvatar={showAgentAvatar}
|
agentGutter={agentGutter}
|
||||||
agentAvatarSpacer={agentAvatarSpacer}
|
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 {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
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 {
|
.ctxChip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 22px;
|
height: 24px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
|
||||||
.chip {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctxChip {
|
|
||||||
color: #1e40af;
|
color: #1e40af;
|
||||||
background: rgba(37, 99, 235, 0.08);
|
background: rgba(37, 99, 235, 0.08);
|
||||||
border: 1px solid rgba(37, 99, 235, 0.12);
|
border: 1px solid rgba(37, 99, 235, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barCompact .ctxChip {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0 7px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctxWarn {
|
.ctxWarn {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
background: rgba(245, 158, 11, 0.12);
|
background: rgba(245, 158, 11, 0.12);
|
||||||
@@ -88,18 +34,12 @@
|
|||||||
border-color: rgba(239, 68, 68, 0.16);
|
border-color: rgba(239, 68, 68, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.barCompact {
|
.bar {
|
||||||
padding: 0 2px;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactLine {
|
.ctxChip {
|
||||||
gap: 6px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { useChatContext } from "../../context/ChatContext";
|
|||||||
import { formatFooterTokens } from "../../utils/format";
|
import { formatFooterTokens } from "../../utils/format";
|
||||||
import styles from "./SessionContextBar.module.css";
|
import styles from "./SessionContextBar.module.css";
|
||||||
|
|
||||||
interface SessionContextBarProps {
|
export function SessionContextBar() {
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionContextBar({ compact = false }: SessionContextBarProps) {
|
|
||||||
const { sessionState, isStreaming } = useChatContext();
|
const { sessionState, isStreaming } = useChatContext();
|
||||||
const data = sessionState;
|
const data = sessionState;
|
||||||
|
|
||||||
@@ -14,63 +10,33 @@ export function SessionContextBar({ compact = false }: SessionContextBarProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tok = data.stats.tokens || {};
|
const liveStreaming = isStreaming || data.isStreaming;
|
||||||
const chips: string[] = [];
|
if (liveStreaming || data.isCompacting) {
|
||||||
if (tok.input) chips.push(`↑${formatFooterTokens(tok.input)}`);
|
return null;
|
||||||
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 cx = data.stats.contextUsage;
|
const cx = data.stats.contextUsage;
|
||||||
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
||||||
|
if (!cw) return null;
|
||||||
|
|
||||||
const pctRaw = cx?.percent;
|
const pctRaw = cx?.percent;
|
||||||
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
|
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
|
||||||
const pctNum = pctRaw != null ? Number(pctRaw) : null;
|
const pctNum = pctRaw != null ? Number(pctRaw) : null;
|
||||||
const autoInd = data.autoCompactionEnabled ? " auto" : "";
|
const autoInd = data.autoCompactionEnabled ? " auto" : "";
|
||||||
const ctxTxt =
|
const ctxTxt =
|
||||||
pctStr === "?" && cw
|
pctStr === "?"
|
||||||
? `?/${formatFooterTokens(cw)}${autoInd}`
|
? `?/${formatFooterTokens(cw)}${autoInd}`
|
||||||
: `${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;
|
let ctxClass = styles.ctxChip;
|
||||||
if (pctNum != null) {
|
if (pctNum != null) {
|
||||||
if (pctNum > 90) ctxClass = `${styles.ctxChip} ${styles.ctxDanger}`;
|
if (pctNum > 90) ctxClass = `${styles.ctxChip} ${styles.ctxDanger}`;
|
||||||
else if (pctNum > 70) ctxClass = `${styles.ctxChip} ${styles.ctxWarn}`;
|
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 (
|
return (
|
||||||
<div className={styles.bar}>
|
<div className={styles.bar}>
|
||||||
<div className={styles.chips}>
|
<span className={ctxClass} title="上下文占用">{ctxTxt}</span>
|
||||||
{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}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,3 +56,15 @@
|
|||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
line-height: 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";
|
import styles from "./ToolCallBlock.module.css";
|
||||||
|
|
||||||
interface ToolCallBlockProps {
|
interface ToolCallBlockProps {
|
||||||
content: string;
|
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 (
|
return (
|
||||||
<details className={styles.collapsible}>
|
<details className={styles.collapsible} open={forceOpen}>
|
||||||
<summary className={styles.summary}>
|
<summary className={styles.summary}>
|
||||||
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
|
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className={styles.detail}>
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 10px 16px;
|
padding: 10px 14px 8px;
|
||||||
border-bottom: 1px solid var(--header-border);
|
border-bottom: 1px solid var(--header-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
@@ -10,11 +10,10 @@
|
|||||||
box-shadow: var(--header-shadow);
|
box-shadow: var(--header-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleGroup {
|
.topRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +36,16 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
flex: 1;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -50,40 +57,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleActions {
|
.topActions {
|
||||||
display: none;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.bottomRow {
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-shrink: 0;
|
|
||||||
max-width: min(72%, 760px);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 6px 8px 6px 10px;
|
padding: 6px 8px;
|
||||||
border: 1px solid var(--toolbar-border);
|
border: 1px solid var(--toolbar-border);
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
background: var(--toolbar-bg);
|
background: var(--toolbar-bg);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -140,14 +145,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selectModel {
|
.selectModel {
|
||||||
min-width: 168px;
|
min-width: 140px;
|
||||||
max-width: min(320px, 42vw);
|
max-width: min(280px, 34vw);
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectCompact {
|
.selectCompact {
|
||||||
min-width: 72px;
|
min-width: 68px;
|
||||||
max-width: 96px;
|
max-width: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:focus {
|
.select:focus {
|
||||||
@@ -161,28 +166,19 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarActions {
|
.statsWrap {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statsMobile {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statsDesktop {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarActions::before {
|
.statsWrap::before {
|
||||||
content: "";
|
content: "";
|
||||||
width: 1px;
|
width: 1px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
min-height: 30px;
|
min-height: 24px;
|
||||||
|
margin-right: 2px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
@@ -190,90 +186,47 @@
|
|||||||
rgba(15, 23, 42, 0.08) 82%,
|
rgba(15, 23, 42, 0.08) 82%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.abortBtn {
|
.toolsToggleBtn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 5px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
min-width: 30px;
|
background: var(--surface-muted);
|
||||||
padding: 0 10px;
|
border: 1px solid var(--toolbar-border);
|
||||||
background: rgba(254, 242, 242, 0.95);
|
|
||||||
border: 1px solid rgba(252, 165, 165, 0.65);
|
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #dc2626;
|
color: var(--text-secondary);
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.abortBtn:hover {
|
.toolsToggleBtn:hover {
|
||||||
background: #fee2e2;
|
background: #fff;
|
||||||
}
|
color: var(--text-primary);
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.toolbar {
|
|
||||||
max-width: min(78%, 620px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select {
|
|
||||||
max-width: 180px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 10px 7px;
|
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 {
|
.menuBtn {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-area: menu;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 9px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
grid-area: title;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.bottomRow {
|
||||||
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;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -283,10 +236,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldLabel {
|
.fieldLabel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -306,36 +255,15 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selectModel,
|
||||||
.selectCompact {
|
.selectCompact {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarActions {
|
.statsWrap::before {
|
||||||
display: none;
|
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) {
|
@media (max-width: 420px) {
|
||||||
|
|||||||
@@ -83,28 +83,40 @@ function ModelControls({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AbortButton({ onClick }: { onClick: () => void }) {
|
function ToolsToggleButton({
|
||||||
|
expanded,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
expanded: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
return (
|
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">
|
<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>
|
</svg>
|
||||||
<span className={styles.abortText}>中止</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const {
|
const {
|
||||||
isStreaming,
|
|
||||||
headerTitle,
|
headerTitle,
|
||||||
headerMeta,
|
headerMeta,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
abortStream,
|
toggleToolsExpanded,
|
||||||
|
toolsExpanded,
|
||||||
availableModels,
|
availableModels,
|
||||||
currentModelKey,
|
currentModelKey,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
availableThinkingLevels,
|
availableThinkingLevels,
|
||||||
|
isStreaming,
|
||||||
isApplyingModelSettings,
|
isApplyingModelSettings,
|
||||||
applyModelSelection,
|
applyModelSelection,
|
||||||
applyThinkingSelection,
|
applyThinkingSelection,
|
||||||
@@ -125,34 +137,29 @@ export function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.titleGroup}>
|
<div className={styles.topRow}>
|
||||||
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
<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">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1 className={styles.title} title={headerTitle}>
|
<div className={styles.titleBlock}>
|
||||||
{headerTitle}
|
<h1 className={styles.title} title={headerTitle}>
|
||||||
</h1>
|
{headerTitle}
|
||||||
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
|
</h1>
|
||||||
<div className={styles.titleActions}>
|
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.topActions}>
|
||||||
|
<ToolsToggleButton expanded={toolsExpanded} onClick={toggleToolsExpanded} />
|
||||||
<ExportMenu compact />
|
<ExportMenu compact />
|
||||||
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.bottomRow}>
|
||||||
<ModelControls {...controlProps} />
|
<ModelControls {...controlProps} />
|
||||||
<div className={styles.statsDesktop}>
|
<div className={styles.statsWrap}>
|
||||||
<SessionContextBar />
|
<SessionContextBar />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsMobile}>
|
|
||||||
<SessionContextBar compact />
|
|
||||||
</div>
|
|
||||||
<div className={styles.toolbarActions}>
|
|
||||||
<ExportMenu />
|
|
||||||
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import * as chatApi from "../api/chat";
|
|||||||
import * as sessionsApi from "../api/sessions";
|
import * as sessionsApi from "../api/sessions";
|
||||||
import { apiUrl } from "../api/base";
|
import { apiUrl } from "../api/base";
|
||||||
import { useBootstrap } from "./BootstrapContext";
|
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 { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
|
||||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
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 { formatBashOutput, parseBashInput } from "../utils/bash";
|
||||||
|
import { buildHistoryMessages } from "../utils/historyMessages";
|
||||||
import {
|
import {
|
||||||
extractContent,
|
extractContent,
|
||||||
extractImages,
|
extractImages,
|
||||||
@@ -48,6 +49,14 @@ interface ChatContextValue {
|
|||||||
thinkingLevel: string;
|
thinkingLevel: string;
|
||||||
availableThinkingLevels: ThinkingLevel[];
|
availableThinkingLevels: ThinkingLevel[];
|
||||||
isApplyingModelSettings: boolean;
|
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;
|
toggleSidebar: () => void;
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
loadSessions: () => Promise<void>;
|
loadSessions: () => Promise<void>;
|
||||||
@@ -56,9 +65,17 @@ interface ChatContextValue {
|
|||||||
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
||||||
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
||||||
newSession: () => 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>;
|
runBashCommand: (text: string) => Promise<void>;
|
||||||
abortStream: () => Promise<void>;
|
abortStream: () => Promise<void>;
|
||||||
|
abortRetry: () => Promise<void>;
|
||||||
|
toggleToolsExpanded: () => void;
|
||||||
|
respondExtensionDialog: (response: Record<string, unknown>) => void;
|
||||||
|
dismissExtensionDialog: () => void;
|
||||||
applyModelSelection: (key: string) => Promise<void>;
|
applyModelSelection: (key: string) => Promise<void>;
|
||||||
applyThinkingSelection: (level: string) => Promise<void>;
|
applyThinkingSelection: (level: string) => Promise<void>;
|
||||||
addSystemMessage: (text: string) => void;
|
addSystemMessage: (text: string) => void;
|
||||||
@@ -96,66 +113,7 @@ function getAvailableThinkingLevels(model: ModelInfo | undefined): ThinkingLevel
|
|||||||
}
|
}
|
||||||
|
|
||||||
function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
|
function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
|
||||||
const result: ChatMessage[] = [];
|
return normalizeChatMessages(buildHistoryMessages(payload));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findStreamingThinkingId(messages: ChatMessage[]): string | null {
|
function findStreamingThinkingId(messages: ChatMessage[]): string | null {
|
||||||
@@ -509,6 +467,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const [currentModelKey, setCurrentModelKey] = useState("");
|
const [currentModelKey, setCurrentModelKey] = useState("");
|
||||||
const [thinkingLevel, setThinkingLevel] = useState("off");
|
const [thinkingLevel, setThinkingLevel] = useState("off");
|
||||||
const [isApplyingModelSettings, setIsApplyingModelSettings] = useState(false);
|
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 sessionCache = useRef(new Map<string, SessionHistoryPayload>());
|
||||||
const sessionActivationPromise = useRef<Promise<SessionState | null> | null>(null);
|
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 streamingThinkingId = useRef<string | null>(null);
|
||||||
const optimisticUserIdRef = useRef<string | null>(null);
|
const optimisticUserIdRef = useRef<string | null>(null);
|
||||||
const compactionReloadPendingRef = useRef(false);
|
const compactionReloadPendingRef = useRef(false);
|
||||||
|
const activeBashMsgIdRef = useRef<string | null>(null);
|
||||||
const sessionDashboardRaf = useRef<number | null>(null);
|
const sessionDashboardRaf = useRef<number | null>(null);
|
||||||
const backendSessionPathRef = useRef(backendSessionPath);
|
const backendSessionPathRef = useRef(backendSessionPath);
|
||||||
const activeSessionPathRef = useRef(activeSessionPath);
|
const activeSessionPathRef = useRef(activeSessionPath);
|
||||||
@@ -605,6 +572,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (data.model) setCurrentModelKey(modelKey(data.model));
|
if (data.model) setCurrentModelKey(modelKey(data.model));
|
||||||
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||||
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
||||||
|
if (data.isCompacting !== undefined) setIsCompacting(data.isCompacting);
|
||||||
|
|
||||||
if (!activeSessionPathRef.current) {
|
if (!activeSessionPathRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -742,7 +710,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
setHeaderTitle(formatSessionTitle(headerLabel));
|
setHeaderTitle(formatSessionTitle(headerLabel));
|
||||||
setMessages(historyToMessages(payload));
|
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);
|
void persistLastActiveSession(path);
|
||||||
if (!options?.skipDashboardRefresh) {
|
if (!options?.skipDashboardRefresh) {
|
||||||
scheduleSessionDashboardRefresh();
|
scheduleSessionDashboardRefresh();
|
||||||
@@ -752,7 +731,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents],
|
[addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents, activateSessionBackend, fetchSessionStateFull],
|
||||||
);
|
);
|
||||||
|
|
||||||
const restoreSessionOnBoot = useCallback(() => {
|
const restoreSessionOnBoot = useCallback(() => {
|
||||||
@@ -1003,15 +982,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.action === "new_session") {
|
if (response.action === "new_session") {
|
||||||
clearChat();
|
|
||||||
const path = response.sessionFile?.trim();
|
const path = response.sessionFile?.trim();
|
||||||
if (path) {
|
if (path) {
|
||||||
setActiveSessionPath(path);
|
await loadSession(path);
|
||||||
setBackendSessionPath(path);
|
} else {
|
||||||
persistLastActiveSession(path);
|
clearChat();
|
||||||
await loadSessions();
|
await fetchSessionStateFull();
|
||||||
}
|
}
|
||||||
await fetchSessionStateFull();
|
|
||||||
} else if (response.action === "reload_messages") {
|
} else if (response.action === "reload_messages") {
|
||||||
const path = activeSessionPathRef.current;
|
const path = activeSessionPathRef.current;
|
||||||
if (path) {
|
if (path) {
|
||||||
@@ -1037,14 +1014,37 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (text: string, images?: ImageContent[]) => {
|
async (text: string, images?: ImageContent[], options?: { mode?: "prompt" | "steer" | "follow_up" }) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const hasImages = Array.isArray(images) && images.length > 0;
|
const hasImages = Array.isArray(images) && images.length > 0;
|
||||||
const isSlash = trimmed.startsWith("/");
|
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 (!(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;
|
const skipOptimisticUser = isSlash;
|
||||||
|
|
||||||
if (!skipOptimisticUser) {
|
if (!skipOptimisticUser) {
|
||||||
@@ -1123,6 +1123,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!(await ensureSessionReady())) return;
|
if (!(await ensureSessionReady())) return;
|
||||||
|
|
||||||
const bashMsgId = nextMessageId();
|
const bashMsgId = nextMessageId();
|
||||||
|
activeBashMsgIdRef.current = bashMsgId;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: nextMessageId(), role: "user", content: parsed.display },
|
{ id: nextMessageId(), role: "user", content: parsed.display },
|
||||||
@@ -1131,12 +1132,16 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
role: "bash",
|
role: "bash",
|
||||||
content: "",
|
content: "",
|
||||||
bashCommand: parsed.command,
|
bashCommand: parsed.command,
|
||||||
|
bashExcludeFromContext: parsed.excludeFromContext,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatApi.runBash(parsed.command);
|
const result = await chatApi.runBash(parsed.command, {
|
||||||
|
excludeFromContext: parsed.excludeFromContext,
|
||||||
|
});
|
||||||
|
activeBashMsgIdRef.current = null;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.id === bashMsgId
|
msg.id === bashMsgId
|
||||||
@@ -1158,6 +1163,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
void persistLastActiveSession(persistPath);
|
void persistLastActiveSession(persistPath);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
activeBashMsgIdRef.current = null;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.id === bashMsgId
|
msg.id === bashMsgId
|
||||||
@@ -1175,14 +1181,93 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const abortStream = useCallback(async () => {
|
const abortStream = useCallback(async () => {
|
||||||
if (!isStreamingRef.current) return;
|
if (!isStreamingRef.current && !isCompacting) return;
|
||||||
try {
|
try {
|
||||||
await chatApi.abortChat();
|
await chatApi.abortChat();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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(
|
const applyModelSelection = useCallback(
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
if (!key || isApplyingModelSettings) return;
|
if (!key || isApplyingModelSettings) return;
|
||||||
@@ -1372,27 +1457,37 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (idx < 0) return prev;
|
if (idx < 0) return prev;
|
||||||
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
|
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
|
||||||
});
|
});
|
||||||
if (ev.type === "tool_execution_end" && ev.isError) {
|
|
||||||
addSystemMessage(`工具 ${ev.toolName} 执行出错`);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "compaction_start":
|
case "compaction_start":
|
||||||
setHeaderMeta("压缩上下文中…");
|
setIsCompacting(true);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "compaction_end": {
|
case "compaction_end": {
|
||||||
setHeaderMeta("");
|
setIsCompacting(false);
|
||||||
if (ev.aborted) {
|
if (ev.aborted) {
|
||||||
if (ev.errorMessage) addSystemMessage(ev.errorMessage);
|
if (ev.errorMessage) addSystemMessage(ev.errorMessage);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const activePath = activeSessionPathRef.current;
|
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);
|
sessionCache.current.delete(activePath);
|
||||||
void loadSession(activePath);
|
void loadSession(activePath);
|
||||||
}
|
}
|
||||||
|
if (ev.willRetry) {
|
||||||
|
addSystemMessage("压缩完成,正在重试当前请求…");
|
||||||
|
}
|
||||||
if (ev.errorMessage) {
|
if (ev.errorMessage) {
|
||||||
addSystemMessage(ev.errorMessage);
|
addSystemMessage(ev.errorMessage);
|
||||||
} else if (ev.result?.tokensBefore) {
|
} else if (ev.result?.tokensBefore) {
|
||||||
@@ -1402,6 +1497,45 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
break;
|
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);
|
setIsStreaming(ev.isStreaming);
|
||||||
}
|
}
|
||||||
for (const replayEv of ev.replay ?? []) {
|
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();
|
void fetchSessionStateFull();
|
||||||
break;
|
break;
|
||||||
@@ -1439,11 +1577,21 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "extension_ui_request":
|
||||||
|
handleExtensionUiRequest(ev as Record<string, unknown>);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
processStreamEvent(ev);
|
processStreamEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[processStreamEvent, fetchSessionStateFull, invalidateTurnEvents, addSystemMessage],
|
[
|
||||||
|
processStreamEvent,
|
||||||
|
fetchSessionStateFull,
|
||||||
|
invalidateTurnEvents,
|
||||||
|
addSystemMessage,
|
||||||
|
handleExtensionUiRequest,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1556,6 +1704,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
availableThinkingLevels,
|
availableThinkingLevels,
|
||||||
isApplyingModelSettings,
|
isApplyingModelSettings,
|
||||||
|
pendingSteering,
|
||||||
|
pendingFollowUp,
|
||||||
|
retryState,
|
||||||
|
isCompacting,
|
||||||
|
extensionWidgets,
|
||||||
|
extensionStatuses,
|
||||||
|
extensionDialog,
|
||||||
|
toolsExpanded,
|
||||||
toggleSidebar: () => setSidebarOpen((o) => !o),
|
toggleSidebar: () => setSidebarOpen((o) => !o),
|
||||||
closeSidebar: () => setSidebarOpen(false),
|
closeSidebar: () => setSidebarOpen(false),
|
||||||
loadSessions,
|
loadSessions,
|
||||||
@@ -1567,6 +1723,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
runBashCommand,
|
runBashCommand,
|
||||||
abortStream,
|
abortStream,
|
||||||
|
abortRetry,
|
||||||
|
toggleToolsExpanded,
|
||||||
|
respondExtensionDialog,
|
||||||
|
dismissExtensionDialog,
|
||||||
applyModelSelection,
|
applyModelSelection,
|
||||||
applyThinkingSelection,
|
applyThinkingSelection,
|
||||||
addSystemMessage,
|
addSystemMessage,
|
||||||
|
|||||||
@@ -1,13 +1,48 @@
|
|||||||
import { ChatInput } from "../components/chat/ChatInput";
|
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 { MessageList } from "../components/chat/MessageList";
|
||||||
|
import { PendingQueueBar } from "../components/chat/PendingQueueBar";
|
||||||
|
import { RunStatusBar } from "../components/chat/RunStatusBar";
|
||||||
import { Header } from "../components/layout/Header";
|
import { Header } from "../components/layout/Header";
|
||||||
|
import { useChatContext } from "../context/ChatContext";
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
|
const {
|
||||||
|
pendingSteering,
|
||||||
|
pendingFollowUp,
|
||||||
|
isStreaming,
|
||||||
|
isCompacting,
|
||||||
|
retryState,
|
||||||
|
extensionWidgets,
|
||||||
|
extensionDialog,
|
||||||
|
abortStream,
|
||||||
|
abortRetry,
|
||||||
|
respondExtensionDialog,
|
||||||
|
dismissExtensionDialog,
|
||||||
|
} = useChatContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<MessageList />
|
<MessageList />
|
||||||
|
<ExtensionWidgetPanel widgets={extensionWidgets} />
|
||||||
|
<PendingQueueBar steering={pendingSteering} followUp={pendingFollowUp} />
|
||||||
|
<RunStatusBar
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isCompacting={isCompacting}
|
||||||
|
retryState={retryState}
|
||||||
|
onAbort={() => void abortStream()}
|
||||||
|
onAbortRetry={() => void abortRetry()}
|
||||||
|
/>
|
||||||
<ChatInput />
|
<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) {
|
@media (max-width: 480px) {
|
||||||
.extensionGroupList {
|
.extensionGroupList {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { Link } from "react-router-dom";
|
|||||||
import * as settingsApi from "../api/settings";
|
import * as settingsApi from "../api/settings";
|
||||||
import { useAvatars } from "../context/AvatarContext";
|
import { useAvatars } from "../context/AvatarContext";
|
||||||
import { useBootstrap } from "../context/BootstrapContext";
|
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 type { McpToolInfo } from "../types/events";
|
||||||
import styles from "./SettingsPage.module.css";
|
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 {
|
function isNpmSkill(skill: SkillInfo): boolean {
|
||||||
if (skill.toggleable === false) return true;
|
if (skill.toggleable === false) return true;
|
||||||
@@ -267,6 +267,8 @@ export function SettingsPage() {
|
|||||||
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
|
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
|
||||||
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
|
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
|
||||||
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
||||||
|
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
|
||||||
|
const [loadingEnv, setLoadingEnv] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let initial: SettingsPaneId = "prompt";
|
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) => {
|
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
|
||||||
setTogglingSkillPath(path);
|
setTogglingSkillPath(path);
|
||||||
setStatus(enabled ? "正在启用 skill…" : "正在禁用 skill…");
|
setStatus(enabled ? "正在启用 skill…" : "正在禁用 skill…");
|
||||||
@@ -530,6 +550,7 @@ export function SettingsPage() {
|
|||||||
["mcp", "MCP工具"],
|
["mcp", "MCP工具"],
|
||||||
["extensions", "插件扩展"],
|
["extensions", "插件扩展"],
|
||||||
["other", "其他设置"],
|
["other", "其他设置"],
|
||||||
|
["env", "环境信息"],
|
||||||
] as const
|
] as const
|
||||||
).map(([id, label]) => (
|
).map(([id, label]) => (
|
||||||
<button
|
<button
|
||||||
@@ -923,6 +944,63 @@ export function SettingsPage() {
|
|||||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,9 +55,60 @@ export interface CompactionEndEvent {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
aborted?: boolean;
|
aborted?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
willRetry?: boolean;
|
||||||
result?: { tokensBefore?: number; summary?: string };
|
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). */
|
/** Agent stream events replayed on SSE reconnect (excludes connected / prompt_rejected). */
|
||||||
export type StreamSseEvent =
|
export type StreamSseEvent =
|
||||||
| AgentStartEvent
|
| AgentStartEvent
|
||||||
@@ -69,7 +120,13 @@ export type StreamSseEvent =
|
|||||||
| ToolExecutionUpdateEvent
|
| ToolExecutionUpdateEvent
|
||||||
| ToolExecutionEndEvent
|
| ToolExecutionEndEvent
|
||||||
| CompactionStartEvent
|
| CompactionStartEvent
|
||||||
| CompactionEndEvent;
|
| CompactionEndEvent
|
||||||
|
| QueueUpdateEvent
|
||||||
|
| AutoRetryStartEvent
|
||||||
|
| AutoRetryEndEvent
|
||||||
|
| ExtensionErrorEvent
|
||||||
|
| BashUpdateEvent
|
||||||
|
| ExtensionUiRequestEvent;
|
||||||
|
|
||||||
export interface ConnectedEvent {
|
export interface ConnectedEvent {
|
||||||
type: "connected";
|
type: "connected";
|
||||||
@@ -155,4 +212,37 @@ export interface AvatarSettings {
|
|||||||
agentAvatarUrl: string;
|
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 {
|
export interface ContentBlock {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -24,6 +32,14 @@ export interface ImageContent {
|
|||||||
export interface RpcMessage {
|
export interface RpcMessage {
|
||||||
role: string;
|
role: string;
|
||||||
content?: string | ContentBlock[];
|
content?: string | ContentBlock[];
|
||||||
|
command?: string;
|
||||||
|
output?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
cancelled?: boolean;
|
||||||
|
truncated?: boolean;
|
||||||
|
excludeFromContext?: boolean;
|
||||||
|
summary?: string;
|
||||||
|
toolCallId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallBlock {
|
export interface ToolCallBlock {
|
||||||
@@ -45,6 +61,7 @@ export interface ChatMessage {
|
|||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
bashCommand?: string;
|
bashCommand?: string;
|
||||||
bashExitCode?: number;
|
bashExitCode?: number;
|
||||||
|
bashExcludeFromContext?: boolean;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ function roleLabel(role: ChatMessage["role"]): string {
|
|||||||
return "系统";
|
return "系统";
|
||||||
case "bash":
|
case "bash":
|
||||||
return "Shell";
|
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, "```", "");
|
lines.push(`## Shell (${cmd})`, "", "```", m.content, "```", "");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "compaction_summary":
|
||||||
|
lines.push("## 压缩摘要", "", m.content, "");
|
||||||
|
break;
|
||||||
|
case "branch_summary":
|
||||||
|
lines.push("## 分支摘要", "", m.content, "");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${lines.join("\n").trimEnd()}\n`;
|
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`;
|
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 {
|
export function formatTimeAgo(isoStr: string | undefined): string {
|
||||||
if (!isoStr) return "";
|
if (!isoStr) return "";
|
||||||
const d = new Date(isoStr);
|
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;
|
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 {
|
export function deriveToolCallSummary(fullText: string): string {
|
||||||
const raw = String(fullText || "").trim();
|
const raw = String(fullText || "").trim();
|
||||||
if (!raw) return "tool";
|
if (!raw) return "tool";
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const PID_FILE = join(__dirname, ".webui.pid");
|
const PID_FILE = join(__dirname, ".webui.pid");
|
||||||
const DIST_DIR = join(__dirname, "frontend", "dist");
|
const DIST_DIR = join(__dirname, "frontend", "dist");
|
||||||
const FRONTEND_DIR = join(__dirname, "frontend");
|
const FRONTEND_DIR = join(__dirname, "frontend");
|
||||||
|
const WEBUI_STARTUP_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 10_000;
|
||||||
|
|
||||||
let serverProcess: ChildProcess | null = null;
|
let serverProcess: ChildProcess | null = null;
|
||||||
let serverPort = DEFAULT_WEBUI_PORT;
|
let serverPort = DEFAULT_WEBUI_PORT;
|
||||||
let systemdConfig: SystemdServiceConfig | null = null;
|
let systemdConfig: SystemdServiceConfig | null = null;
|
||||||
|
|
||||||
function findTsx(): string {
|
function findTsx(): string {
|
||||||
// 从扩展所在目录向上找 repo 根目录
|
// On Linux/Mac, use the tsx shebang script directly
|
||||||
let dir = __dirname;
|
let dir = __dirname;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const candidate = join(dir, "node_modules", ".bin", "tsx");
|
const candidate = join(dir, "node_modules", ".bin", "tsx");
|
||||||
@@ -47,10 +48,23 @@ function findTsx(): string {
|
|||||||
if (parent === dir) break;
|
if (parent === dir) break;
|
||||||
dir = parent;
|
dir = parent;
|
||||||
}
|
}
|
||||||
// fallback
|
|
||||||
return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
|
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 {
|
function findRepoRoot(): string {
|
||||||
let dir = __dirname;
|
let dir = __dirname;
|
||||||
for (let i = 0; i < 12; i++) {
|
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;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if ((await probePort(port)) === "running") return;
|
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);
|
setTimeout(check, 300);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,19 +188,26 @@ function probePort(port: number): Promise<"free" | "running" | "occupied"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findNpm(): string {
|
function findNpm(): string {
|
||||||
const local = join(dirname(process.execPath), "npm");
|
// On Windows prefer npm.cmd; the bare 'npm' is a bash script that Windows can't execute
|
||||||
if (existsSync(local)) return local;
|
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;
|
let dir = __dirname;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const candidate = join(dir, "node_modules", ".bin", "npm");
|
for (const name of candidates) {
|
||||||
if (existsSync(candidate)) return candidate;
|
const candidate = join(dir, "node_modules", ".bin", name);
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
const parent = dirname(dir);
|
const parent = dirname(dir);
|
||||||
if (parent === dir) break;
|
if (parent === dir) break;
|
||||||
dir = parent;
|
dir = parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "npm";
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function runNpm(
|
function runNpm(
|
||||||
@@ -197,6 +218,7 @@ function runNpm(
|
|||||||
cwd,
|
cwd,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: process.env,
|
env: process.env,
|
||||||
|
shell: process.platform === "win32",
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return { ok: false, status: result.status, error: result.error.message };
|
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 repoRoot = findRepoRoot();
|
||||||
const tsxBin = findTsx();
|
|
||||||
const serverFile = join(__dirname, "backend", "main.ts");
|
const serverFile = join(__dirname, "backend", "main.ts");
|
||||||
|
|
||||||
if (!existsSync(serverFile)) {
|
if (!existsSync(serverFile)) {
|
||||||
@@ -294,7 +315,15 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
|||||||
return;
|
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,
|
cwd: repoRoot,
|
||||||
stdio: ["ignore", "inherit", "inherit"],
|
stdio: ["ignore", "inherit", "inherit"],
|
||||||
env: {
|
env: {
|
||||||
@@ -315,6 +344,10 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await waitForStartup(port, serverProcess);
|
await waitForStartup(port, serverProcess);
|
||||||
} catch (err: unknown) {
|
} 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();
|
clearPidFile();
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -331,7 +364,7 @@ function notifyLanAddresses(ctx: any, port: number): void {
|
|||||||
const nets = networkInterfaces();
|
const nets = networkInterfaces();
|
||||||
for (const name of Object.keys(nets)) {
|
for (const name of Object.keys(nets)) {
|
||||||
for (const net of nets[name]) {
|
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");
|
ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +373,19 @@ function notifyLanAddresses(ctx: any, port: number): void {
|
|||||||
|
|
||||||
function findPidsByPort(port: number): number[] {
|
function findPidsByPort(port: number): number[] {
|
||||||
try {
|
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 out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" });
|
||||||
const pids = new Set<number>();
|
const pids = new Set<number>();
|
||||||
for (const match of out.matchAll(/pid=(\d+)/g)) {
|
for (const match of out.matchAll(/pid=(\d+)/g)) {
|
||||||
@@ -354,7 +400,11 @@ function findPidsByPort(port: number): number[] {
|
|||||||
|
|
||||||
function killProcessTree(pid: number): void {
|
function killProcessTree(pid: number): void {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, "SIGTERM");
|
if (process.platform === "win32") {
|
||||||
|
spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { encoding: "utf8" });
|
||||||
|
} else {
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -408,11 +458,12 @@ async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Pr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverProcess) {
|
if (serverProcess?.pid) {
|
||||||
serverProcess.kill("SIGTERM");
|
killProcessTree(serverProcess.pid);
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
serverPort = 0;
|
serverPort = 0;
|
||||||
clearPidFile();
|
clearPidFile();
|
||||||
|
await waitForPortFree(port);
|
||||||
ctx.ui.notify("网页服务已停止", "info");
|
ctx.ui.notify("网页服务已停止", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -420,7 +471,7 @@ async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Pr
|
|||||||
const pid = readPidFile();
|
const pid = readPidFile();
|
||||||
if (pid && isProcessAlive(pid)) {
|
if (pid && isProcessAlive(pid)) {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, "SIGTERM");
|
killProcessTree(pid);
|
||||||
await waitForPortFree(port);
|
await waitForPortFree(port);
|
||||||
clearPidFile();
|
clearPidFile();
|
||||||
ctx.ui.notify(`网页服务已停止(PID ${pid})`, "info");
|
ctx.ui.notify(`网页服务已停止(PID ${pid})`, "info");
|
||||||
@@ -479,11 +530,6 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
if (ensureSystemdService(systemdConfig, serverPort || DEFAULT_WEBUI_PORT)) {
|
if (ensureSystemdService(systemdConfig, serverPort || DEFAULT_WEBUI_PORT)) {
|
||||||
console.log(`[webui] 已注册 systemd 保活服务: ${WEBUI_SERVICE_NAME}`);
|
console.log(`[webui] 已注册 systemd 保活服务: ${WEBUI_SERVICE_NAME}`);
|
||||||
} else {
|
|
||||||
const reason = getSystemdDisabledReason();
|
|
||||||
if (reason) {
|
|
||||||
console.warn(`[webui] systemd 保活不可用,回退进程内管理: ${reason}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pi.registerCommand("webui", {
|
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",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
@@ -5015,7 +5014,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5084,7 +5082,6 @@
|
|||||||
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
|
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.28.0"
|
"esbuild": "~0.28.0"
|
||||||
},
|
},
|
||||||
@@ -5159,7 +5156,6 @@
|
|||||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5760,7 +5756,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5774,7 +5769,6 @@
|
|||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@@ -5978,7 +5972,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ export const IMAGE_MODELS = {
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
} satisfies ImagesModel<"openrouter-images">,
|
} 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": {
|
"openai/gpt-5-image": {
|
||||||
id: "openai/gpt-5-image",
|
id: "openai/gpt-5-image",
|
||||||
name: "OpenAI: GPT-5 Image",
|
name: "OpenAI: GPT-5 Image",
|
||||||
@@ -425,6 +440,36 @@ export const IMAGE_MODELS = {
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
} satisfies ImagesModel<"openrouter-images">,
|
} 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": {
|
"x-ai/grok-imagine-image-quality": {
|
||||||
id: "x-ai/grok-imagine-image-quality",
|
id: "x-ai/grok-imagine-image-quality",
|
||||||
name: "xAI: 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)", () => {
|
describe("GitHub Copilot (OAuth)", () => {
|
||||||
// OpenAI model via Copilot
|
// OpenAI model via Copilot
|
||||||
it.skipIf(!githubCopilotToken)(
|
it.skipIf(!githubCopilotToken)(
|
||||||
"gpt-4o - should detect overflow via isContextOverflow",
|
"claude-sonnet-4.6 - should detect overflow via isContextOverflow",
|
||||||
async () => {
|
async () => {
|
||||||
const model = getModel("github-copilot", "gpt-4o");
|
const model = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
const result = await testContextOverflow(model, githubCopilotToken!);
|
const result = await testContextOverflow(model, githubCopilotToken!);
|
||||||
logResult(result);
|
logResult(result);
|
||||||
|
|
||||||
@@ -297,8 +297,8 @@ describe("Context overflow error handling", () => {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => {
|
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => {
|
||||||
it("qwen-3-235b - should detect overflow via isContextOverflow", async () => {
|
it("gpt-oss-120b - should detect overflow via isContextOverflow", async () => {
|
||||||
const model = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507");
|
const model = getModel("cerebras", "gpt-oss-120b");
|
||||||
const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!);
|
const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!);
|
||||||
logResult(result);
|
logResult(result);
|
||||||
|
|
||||||
|
|||||||
@@ -630,37 +630,37 @@ describe("AI Providers Empty Message Tests", () => {
|
|||||||
|
|
||||||
describe("GitHub Copilot Provider Empty Messages", () => {
|
describe("GitHub Copilot Provider Empty Messages", () => {
|
||||||
it.skipIf(!githubCopilotToken)(
|
it.skipIf(!githubCopilotToken)(
|
||||||
"gpt-4o - should handle empty content array",
|
"claude-sonnet-4.6 - should handle empty content array",
|
||||||
{ retry: 3, timeout: 30000 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
|
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.skipIf(!githubCopilotToken)(
|
it.skipIf(!githubCopilotToken)(
|
||||||
"gpt-4o - should handle empty string content",
|
"claude-sonnet-4.6 - should handle empty string content",
|
||||||
{ retry: 3, timeout: 30000 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
|
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.skipIf(!githubCopilotToken)(
|
it.skipIf(!githubCopilotToken)(
|
||||||
"gpt-4o - should handle whitespace-only content",
|
"claude-sonnet-4.6 - should handle whitespace-only content",
|
||||||
{ retry: 3, timeout: 30000 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
|
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.skipIf(!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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
|
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("Fireworks models", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers the Fire Pass turbo router model", () => {
|
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).toBeDefined();
|
||||||
expect(model.api).toBe("anthropic-messages");
|
expect(model.api).toBe("anthropic-messages");
|
||||||
|
|||||||
@@ -443,19 +443,19 @@ describe("Tool Results with Images", () => {
|
|||||||
|
|
||||||
describe("GitHub Copilot Provider", () => {
|
describe("GitHub Copilot Provider", () => {
|
||||||
it.skipIf(!githubCopilotToken)(
|
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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
|
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.skipIf(!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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
|
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ describe("Token Statistics on Abort", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider", () => {
|
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 () => {
|
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
|
||||||
await testTokensOnAbort(llm);
|
await testTokensOnAbort(llm);
|
||||||
@@ -289,10 +289,10 @@ describe("Token Statistics on Abort", () => {
|
|||||||
|
|
||||||
describe("GitHub Copilot Provider", () => {
|
describe("GitHub Copilot Provider", () => {
|
||||||
it.skipIf(!githubCopilotToken)(
|
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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testTokensOnAbort(llm, { apiKey: githubCopilotToken });
|
await testTokensOnAbort(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -297,10 +297,10 @@ describe("Tool Call Without Result Tests", () => {
|
|||||||
|
|
||||||
describe("GitHub Copilot Provider", () => {
|
describe("GitHub Copilot Provider", () => {
|
||||||
it.skipIf(!githubCopilotToken)(
|
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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const model = getModel("github-copilot", "gpt-4o");
|
const model = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testToolCallWithoutResult(model, { apiKey: githubCopilotToken });
|
await testToolCallWithoutResult(model, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -666,10 +666,10 @@ describe("totalTokens field", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it(
|
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 },
|
{ retry: 3, timeout: 60000 },
|
||||||
async () => {
|
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}:`);
|
console.log(`\nOpenRouter / ${llm.id}:`);
|
||||||
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY });
|
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY });
|
||||||
@@ -706,10 +706,10 @@ describe("totalTokens field", () => {
|
|||||||
|
|
||||||
describe("GitHub Copilot (OAuth)", () => {
|
describe("GitHub Copilot (OAuth)", () => {
|
||||||
it.skipIf(!githubCopilotToken)(
|
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 },
|
{ retry: 3, timeout: 60000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
|
|
||||||
console.log(`\nGitHub Copilot / ${llm.id}:`);
|
console.log(`\nGitHub Copilot / ${llm.id}:`);
|
||||||
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: githubCopilotToken });
|
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", () => {
|
describe("GitHub Copilot Provider Unicode Handling", () => {
|
||||||
it.skipIf(!githubCopilotToken)(
|
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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testEmojiInToolResults(llm, { apiKey: githubCopilotToken });
|
await testEmojiInToolResults(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.skipIf(!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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken });
|
await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.skipIf(!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 },
|
{ retry: 3, timeout: 30000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("github-copilot", "gpt-4o");
|
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
||||||
await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken });
|
await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -426,7 +426,15 @@ Response:
|
|||||||
Execute a shell command and add output to conversation context.
|
Execute a shell command and add output to conversation context.
|
||||||
|
|
||||||
```json
|
```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:
|
Response:
|
||||||
|
|||||||
6
packages/coding-agent/npm-shrinkwrap.json
generated
6
packages/coding-agent/npm-shrinkwrap.json
generated
@@ -1321,8 +1321,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
},
|
}
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/json-bigint": {
|
"node_modules/json-bigint": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -1775,8 +1774,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
},
|
}
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/zod-to-json-schema": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.25.2",
|
"version": "3.25.2",
|
||||||
|
|||||||
@@ -546,7 +546,15 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
case "bash": {
|
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);
|
return success(id, "bash", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export type RpcCommand =
|
|||||||
| { id?: string; type: "abort_retry" }
|
| { id?: string; type: "abort_retry" }
|
||||||
|
|
||||||
// Bash
|
// Bash
|
||||||
| { id?: string; type: "bash"; command: string }
|
| { id?: string; type: "bash"; command: string; excludeFromContext?: boolean }
|
||||||
| { id?: string; type: "abort_bash" }
|
| { id?: string; type: "abort_bash" }
|
||||||
|
|
||||||
// Session
|
// 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