chore: commit pending workspace updates
Some checks failed
CI / build-check-test (push) Has been cancelled

This commit is contained in:
2026-06-14 20:36:01 +08:00
parent 62e2866228
commit ad62015fe3
152 changed files with 338 additions and 23015 deletions

8
.gitignore vendored
View File

@@ -50,6 +50,7 @@ progress.md
.pi/agent/git/
.pi/agent/npm/
.pi/agent/bin/
.pi/agent/data/
.pi/agent/auth.json
.pi/agent/models.json
.pi/agent/settings.json
@@ -67,3 +68,10 @@ progress.md
tmp/
bun.lock
collect.sh
# Root-level binary/build artifacts (do not commit)
/*.wasm
/llvm-*.js
/rustc-*.js
/util_cmd-*.js
/wasm32-*.tar.br

View File

@@ -62,11 +62,11 @@
- Go默认已有 `GOPROXY=https://goproxy.cn,direct`;若仍不可用,设置 `http_proxy`/`https_proxy` 环境变量
- Docker pull配置 Docker daemon 代理或使用 `docker pull` 前设置环境变量
- 代理地址汇总HTTP 代理 `http://192.168.1.1:17892`、SOCKS5 代理 `socks5://192.168.1.1:17891`(由 smywrt mihomo 提供)
- 操作完成<EFBFBD><EFBFBD><EFBFBD>如非必要应及时取消代理,避免影响内网服务访问
- 操作完成如非必要应及时取消代理,避免影响内网服务访问
## SproutClaw 项目规范
- 当用户要求安装 npm 扩展时,默认使用 `pi install npm:<包名>` 命令安装
- 安装目录为 `/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/`
- 当用户要求安装 npm 插件扩展时,默认使用 `sproutclaw install npm:<包名>` 命令安装
- 安装目录为 `sproutclaw/.pi/agent/npm/node_modules/`
- 不要使用 `npm install --save-dev` 安装 pi 扩展,避免冗余安装到根 `node_modules`
## 服务器Docker部署规则
@@ -76,17 +76,6 @@
- 资源限制:所有容器统一设置内存限制 `5GB`
- 管理员认证:默认使用内网密码 `shumengya520`(存在默认认证时使用)
## 网络代理规则(克隆与依赖下载)
- 当用户要求 `git clone` 克隆项目,或下载/安装项目依赖npm/pip/cargo/go mod 等)时,**优先配置代理后再执行操作**,避免因网络问题导致超时或失败
- Git 代理:执行前先设置环境变量 `export http_proxy=http://192.168.1.1:17892``export https_proxy=http://192.168.1.1:17892`(或 `git config --global http.proxy`),克隆完成后记得取消代理
- npm/yarn/pnpm使用 `--proxy http://192.168.1.1:17892` 或设置环境变量 `http_proxy`/`https_proxy`
- pip使用 `--proxy http://192.168.1.1:17892` 或设置环境变量
- cargo设置环境变量 `http_proxy`/`https_proxy`,或配置 `~/.cargo/config.toml` 中的 `[http]` 代理
- Go默认已有 `GOPROXY=https://goproxy.cn,direct`;若仍不可用,设置 `http_proxy`/`https_proxy` 环境变量
- Docker pull配置 Docker daemon 代理或使用 `docker pull` 前设置环境变量
- 代理地址汇总HTTP 代理 `http://192.168.1.1:17892`、SOCKS5 代理 `socks5://192.168.1.1:17891`(由 smywrt mihomo 提供)
- 操作完成后如非必要应及时取消代理,避免影响内网服务访问
## Emoji 表现
- 萌小芽的专属 Emoji 为:`(≧口≦)` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
- 在合适且自然的场景下,可少量使用这些 Emoji避免过度堆叠

View File

@@ -1,8 +0,0 @@
frontend/dist/
frontend/dist-desketop/
frontend/.env.desktop
data/
webui-service.env
*.db
webui-settings.json.migrated
.webui.pid

View File

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

View File

@@ -1,69 +0,0 @@
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const configDir = dirname(fileURLToPath(import.meta.url));
const backendDir = dirname(configDir);
const extensionRoot = resolve(backendDir, "..");
export interface WebUiPaths {
extensionRoot: string;
repoRoot: string;
agentDir: string;
publicDir: string;
pidFile: string;
sessionsDir: string;
agentExtensionsDir: string;
agentNpmNodeModules: string;
agentSettingsFile: string;
systemPromptFile: string;
modelsConfigFile: string;
mcpCacheFile: string;
mcpConfigFile: string;
piCliDist: string;
}
export function createWebUiPaths(): WebUiPaths {
const repoRoot = process.cwd();
const agentDir = resolve(process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent"));
return {
extensionRoot,
repoRoot,
agentDir,
publicDir: join(extensionRoot, "frontend", "dist"),
pidFile: join(extensionRoot, ".webui.pid"),
sessionsDir: join(agentDir, "sessions"),
agentExtensionsDir: join(agentDir, "extensions"),
agentNpmNodeModules: join(agentDir, "npm", "node_modules"),
agentSettingsFile: join(agentDir, "settings.json"),
systemPromptFile: join(agentDir, "AGENTS.md"),
modelsConfigFile: join(agentDir, "models.json"),
mcpCacheFile: join(agentDir, "mcp-cache.json"),
mcpConfigFile: join(agentDir, "mcp.json"),
piCliDist: join(repoRoot, "packages", "coding-agent", "dist", "cli.js"),
};
}
export type PiRpcLaunchMode = "dist" | "tsx";
export function resolvePiRpcLaunch(
paths: WebUiPaths,
): { command: string; args: string[]; mode: PiRpcLaunchMode } {
const rpcArgs = ["--mode", "rpc"];
if (existsSync(paths.piCliDist)) {
return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
}
const tsxCli = join(paths.repoRoot, "node_modules", "tsx", "dist", "cli.mjs");
const piCliSrc = join(paths.repoRoot, "packages", "coding-agent", "src", "cli.ts");
if (existsSync(tsxCli) && existsSync(piCliSrc)) {
return { command: process.execPath, args: [tsxCli, piCliSrc, ...rpcArgs], mode: "tsx" };
}
const buildHint =
process.platform === "win32"
? "请先运行: npm run build 或 pi-built.bat"
: "请先运行: npm run build 或 sproutclaw build";
throw new Error(`[webui] pi 未找到: ${paths.piCliDist}${buildHint}`);
}

View File

@@ -1,224 +0,0 @@
/**
* WebUI 专有 SQLite 配置存储
*
* 数据库文件位于扩展目录 data/webui.db与 pi-Agent 配置完全隔离。
*/
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
const SCHEMA_VERSION = 1;
export interface WebuiDbInfo {
dataDir: string;
dbPath: string;
}
export interface WebuiAvatarSettings {
userAvatarUrl: string;
agentAvatarUrl: string;
}
let db: DatabaseSync | null = null;
function requireDb(): DatabaseSync {
if (!db?.isOpen) {
throw new Error("WebUI 数据库未初始化");
}
return db;
}
function getMeta(key: string): string | null {
const row = requireDb()
.prepare("SELECT value FROM webui_meta WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? null;
}
function setMeta(key: string, value: string): void {
requireDb()
.prepare(
`INSERT INTO webui_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
)
.run(key, value);
}
function migrateLegacyJsonIfNeeded(extensionDir: string): void {
if (getMeta("legacy_json_migrated") === "1") return;
const legacyFile = join(extensionDir, "webui-settings.json");
if (!existsSync(legacyFile)) {
setMeta("legacy_json_migrated", "1");
return;
}
try {
const raw = JSON.parse(readFileSync(legacyFile, "utf8")) as Partial<WebuiAvatarSettings>;
if (typeof raw.userAvatarUrl === "string" && raw.userAvatarUrl && !getWebuiConfig("userAvatarUrl")) {
setWebuiConfig("userAvatarUrl", raw.userAvatarUrl);
}
if (typeof raw.agentAvatarUrl === "string" && raw.agentAvatarUrl && !getWebuiConfig("agentAvatarUrl")) {
setWebuiConfig("agentAvatarUrl", raw.agentAvatarUrl);
}
const migratedPath = `${legacyFile}.migrated`;
if (!existsSync(migratedPath)) {
renameSync(legacyFile, migratedPath);
}
console.log("[webui] 已从 webui-settings.json 迁移配置到 SQLite");
} catch (err) {
console.warn("[webui] 迁移 webui-settings.json 失败:", err);
}
setMeta("legacy_json_migrated", "1");
}
export function initWebuiDatabase(extensionDir: string): WebuiDbInfo {
const dataDir = join(extensionDir, "data");
const dbPath = join(dataDir, "webui.db");
mkdirSync(dataDir, { recursive: true });
db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS webui_meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webui_config (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
if (!getMeta("schema_version")) {
setMeta("schema_version", String(SCHEMA_VERSION));
}
migrateLegacyJsonIfNeeded(extensionDir);
return { dataDir, dbPath };
}
export function getWebuiConfig(key: string): string | null {
const row = requireDb()
.prepare("SELECT value FROM webui_config WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? null;
}
export function setWebuiConfig(key: string, value: string): void {
const now = new Date().toISOString();
requireDb()
.prepare(
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(key, value, now);
}
export function setWebuiConfigMany(entries: Record<string, string>): void {
const stmt = requireDb().prepare(
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
);
const now = new Date().toISOString();
for (const [key, value] of Object.entries(entries)) {
stmt.run(key, value, now);
}
}
export function getAllWebuiConfig(): Record<string, string> {
const rows = requireDb()
.prepare("SELECT key, value FROM webui_config ORDER BY key")
.all() as Array<{ key: string; value: string }>;
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
export function deleteWebuiConfig(key: string): boolean {
const result = requireDb().prepare("DELETE FROM webui_config WHERE key = ?").run(key);
return result.changes > 0;
}
export function readWebuiAvatarSettings(): WebuiAvatarSettings {
return {
userAvatarUrl: getWebuiConfig("userAvatarUrl") ?? "",
agentAvatarUrl: getWebuiConfig("agentAvatarUrl") ?? "",
};
}
export function writeWebuiAvatarSettings(settings: WebuiAvatarSettings): void {
setWebuiConfigMany({
userAvatarUrl: settings.userAvatarUrl,
agentAvatarUrl: settings.agentAvatarUrl,
});
}
export function closeWebuiDatabase(): void {
if (db?.isOpen) {
db.close();
db = null;
}
}
export function getWebuiDatabasePath(): string | null {
return db?.location() ?? null;
}
const PINNED_SESSIONS_CONFIG_KEY = "pinnedSessionPaths";
export function readPinnedSessionPaths(): string[] {
const raw = getWebuiConfig(PINNED_SESSIONS_CONFIG_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
} catch {
return [];
}
}
function writePinnedSessionPaths(paths: string[]): void {
const unique: string[] = [];
for (const path of paths) {
if (!unique.includes(path)) unique.push(path);
}
setWebuiConfig(PINNED_SESSIONS_CONFIG_KEY, JSON.stringify(unique));
}
export function prunePinnedSessionPaths(validPaths: Iterable<string>): string[] {
const valid = new Set(validPaths);
const current = readPinnedSessionPaths();
const pruned = current.filter((path) => valid.has(path));
if (pruned.length !== current.length) {
writePinnedSessionPaths(pruned);
}
return pruned;
}
export function pinSessionPath(path: string): string[] {
const current = readPinnedSessionPaths();
if (current.includes(path)) return current;
writePinnedSessionPaths([path, ...current]);
return readPinnedSessionPaths();
}
export function unpinSessionPath(path: string): string[] {
const next = readPinnedSessionPaths().filter((entry) => entry !== path);
writePinnedSessionPaths(next);
return next;
}
export function removePinnedSessionPath(path: string): void {
if (readPinnedSessionPaths().includes(path)) {
unpinSessionPath(path);
}
}
export function setSessionPinned(path: string, pinned: boolean): string[] {
return pinned ? pinSessionPath(path) : unpinSessionPath(path);
}

View File

@@ -1,16 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
/** CORS for desktop / cross-origin clients (e.g. frontend/dist-desketop loading local static files). */
export function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
res.setHeader("Access-Control-Allow-Origin", origin || "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return true;
}
return false;
}

View File

@@ -1,23 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export function json(res: ServerResponse, data: unknown, status = 200): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
export function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
try {
resolve(JSON.parse(body));
} catch {
reject(new Error("无效 JSON"));
}
});
req.on("error", reject);
});
}

View File

@@ -1,38 +0,0 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { applyCorsHeaders } from "./cors.ts";
import { serveStatic } from "./static.ts";
import { handleChatRoute } from "../routes/chat.ts";
import { handleExtensionUiRoute } from "../routes/extension-ui.ts";
import { handleCommandsRoute } from "../routes/commands.ts";
import { handleModelsRoute } from "../routes/models.ts";
import { handleSessionsRoute } from "../routes/sessions.ts";
import { handleSettingsRoute } from "../routes/settings.ts";
import { handleWebuiConfigRoute } from "../routes/webui-config.ts";
import type { WebUiContext } from "../types/context.ts";
export function createWebUiServer(ctx: WebUiContext) {
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url!, `http://localhost:${ctx.config.port}`);
const pathname = url.pathname;
if (pathname.startsWith("/api/") && applyCorsHeaders(req, res)) {
return;
}
if (req.method === "GET" && !pathname.startsWith("/api/")) {
serveStatic(ctx.config.paths.publicDir, pathname, req, res, true);
return;
}
if (handleChatRoute(req, res, ctx, pathname)) return;
if (handleExtensionUiRoute(req, res, ctx, pathname)) return;
if (handleSessionsRoute(req, res, ctx, pathname)) return;
if (handleModelsRoute(req, res, ctx, pathname)) return;
if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
if (handleSettingsRoute(req, res, ctx, pathname)) return;
if (handleCommandsRoute(req, res, ctx, pathname)) return;
res.writeHead(404);
res.end("Not found");
});
}

View File

@@ -1,86 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { gzipSync } from "node:zlib";
import type { ServerResponse, IncomingMessage } from "node:http";
import { isPathInsideRoot } from "../utils/paths.ts";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".woff2": "font/woff2",
".woff": "font/woff",
".ico": "image/x-icon",
".webmanifest": "application/manifest+json",
".json": "application/json",
};
const GZIPABLE = new Set([".html", ".js", ".css", ".json", ".webmanifest"]);
// Vite build outputs hashed filenames like index-DeT2iZAf.js
const HASHED_FILE_RE = /-[A-Za-z0-9_-]{6,}\.\w+$/;
function getCacheControl(file: string): string | undefined {
if (file === "/index.html") {
return "no-cache";
}
if (HASHED_FILE_RE.test(file) || file.endsWith(".woff2") || file.endsWith(".woff")) {
return "public, max-age=31536000, immutable";
}
return undefined;
}
export function serveStatic(
publicDir: string,
urlPath: string,
req: IncomingMessage,
res: ServerResponse,
spaFallback = false,
): void {
const file = urlPath === "/" ? "/index.html" : urlPath;
const full = join(publicDir, file);
const resolvedPublic = resolve(publicDir);
const resolvedFull = resolve(full);
if (!isPathInsideRoot(resolvedPublic, resolvedFull) && resolvedFull !== resolvedPublic) {
res.writeHead(403);
res.end("Forbidden");
return;
}
if (!existsSync(full)) {
if (spaFallback && !urlPath.includes(".")) {
serveStatic(publicDir, "/index.html", req, res, false);
return;
}
res.writeHead(404);
res.end("Not found");
return;
}
const ext = file.match(/\.\w+$/)?.[0] || ".html";
const mimeType = MIME[ext] || "application/octet-stream";
const content = readFileSync(full);
const headers: Record<string, string> = {
"Content-Type": mimeType,
};
const cacheControl = getCacheControl(file);
if (cacheControl) {
headers["Cache-Control"] = cacheControl;
}
const acceptEncoding = req.headers["accept-encoding"] || "";
const shouldGzip = GZIPABLE.has(ext) && acceptEncoding.includes("gzip");
if (shouldGzip) {
const compressed = gzipSync(content);
headers["Content-Encoding"] = "gzip";
res.writeHead(200, headers);
res.end(compressed);
return;
}
res.writeHead(200, headers);
res.end(content);
}

View File

@@ -1,93 +0,0 @@
#!/usr/bin/env node
/**
* pi-mono WebUI Server
*
* 被 webui 扩展启动,作为独立子进程运行。
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
*
* 用法(由扩展自动调用):
* tsx backend/main.ts --port 19133
*
* 前端静态文件位于 frontend/dist/ 下Vite 构建产物)。
*/
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { arch, hostname, platform, release, type } from "node:os";
import { parsePort } from "./config/cli.ts";
import { createWebUiPaths } from "./config/paths.ts";
import { closeWebuiDatabase, initWebuiDatabase } from "./db/index.ts";
import { createWebUiServer } from "./http/server.ts";
import { createPiClient } from "./rpc/pi-client.ts";
import type { WebUiContext } from "./types/context.ts";
const paths = createWebUiPaths();
const port = parsePort();
function writePidFile(): void {
try {
writeFileSync(paths.pidFile, String(process.pid), "utf8");
} catch {
/* ignore */
}
}
function clearPidFile(): void {
try {
if (existsSync(paths.pidFile)) unlinkSync(paths.pidFile);
} catch {
/* ignore */
}
}
function shutdown(exitCode = 0): never {
closeWebuiDatabase();
clearPidFile();
process.exit(exitCode);
}
const webuiDb = initWebuiDatabase(paths.extensionRoot);
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
console.log(`[webui] 运行环境: Node.js ${process.version} | ${type()} ${release()} (${platform()}/${arch()}) | 主机: ${hostname()}`);
process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
writePidFile();
const piClient = createPiClient(paths, (code) => {
if (code !== 0 && code !== null) {
console.error(`[webui] pi RPC 进程异常退出 (code=${code})WebUI 即将关闭`);
setTimeout(() => shutdown(1), 500);
return;
}
shutdown(1);
});
const ctx: WebUiContext = {
config: { paths, port },
rpc: {
sendCmd: piClient.sendCmd,
submitPrompt: piClient.submitPrompt,
sendExtensionUiResponse: piClient.sendExtensionUiResponse,
getRunSnapshot: piClient.getRunSnapshot,
connectSseClient: piClient.connectSseClient,
removeSseClient: piClient.removeSseClient,
},
db: webuiDb,
};
const server = createWebUiServer(ctx);
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(`[webui] 端口 ${port} 已被占用`);
} else {
console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
}
process.exit(1);
});
server.listen(port, "0.0.0.0", () => {
console.log(`[webui] HTTP 服务已启动: http://localhost:${port}`);
console.log(`[webui] 局域网访问: http://${hostname()}:${port}`);
});

View File

@@ -1,128 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { dispatchSlashCommand } from "../slash/dispatch.ts";
import { normalizeChatImages } from "../services/chat-images.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
async function trySlashDispatch(
msg: string,
imgs: ReturnType<typeof normalizeChatImages>,
sendCmd: WebUiContext["rpc"]["sendCmd"],
): Promise<Awaited<ReturnType<typeof dispatchSlashCommand>> | null> {
const trimmed = msg.trim();
if (!trimmed.startsWith("/")) return null;
const slash = await dispatchSlashCommand(trimmed, sendCmd);
return slash?.handled ? slash : null;
}
export function handleChatRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { sendCmd, submitPrompt } = ctx.rpc;
if (req.method === "POST" && pathname === "/api/chat") {
void readBody(req)
.then(async ({ message, images, streamingBehavior }) => {
const msg = typeof message === "string" ? message : "";
const imgs = normalizeChatImages(images);
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
const slash = await trySlashDispatch(msg, imgs, sendCmd);
if (slash) {
return json(res, { ok: true, slash: true, ...slash });
}
const behavior =
streamingBehavior === "steer" || streamingBehavior === "followUp"
? streamingBehavior
: undefined;
submitPrompt({ message: msg, images: imgs, streamingBehavior: behavior });
return json(res, { ok: true, accepted: true }, 202);
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/steer") {
void readBody(req)
.then(async ({ message, images }) => {
const msg = typeof message === "string" ? message.trim() : "";
const imgs = normalizeChatImages(images);
if (!msg && !imgs?.length) throw new Error("消息不能为空");
const result = await sendCmd({ type: "steer", message: msg, images: imgs });
if (!result.success) throw new Error(result.error || "steer 失败");
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/follow-up") {
void readBody(req)
.then(async ({ message, images }) => {
const msg = typeof message === "string" ? message.trim() : "";
const imgs = normalizeChatImages(images);
if (!msg && !imgs?.length) throw new Error("消息不能为空");
const result = await sendCmd({ type: "follow_up", message: msg, images: imgs });
if (!result.success) throw new Error(result.error || "follow_up 失败");
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/bash") {
void readBody(req)
.then(async ({ command, excludeFromContext }) => {
const cmd = typeof command === "string" ? command.trim() : "";
if (!cmd) throw new Error("命令不能为空");
const result = await sendCmd({
type: "bash",
command: cmd,
excludeFromContext: excludeFromContext === true,
});
if (!result.success) throw new Error(result.error || "命令执行失败");
json(res, result.data);
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "GET" && pathname === "/api/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
ctx.rpc.connectSseClient(res);
req.on("close", () => ctx.rpc.removeSseClient(res));
return true;
}
if (req.method === "POST" && pathname === "/api/messages") {
void sendCmd({ type: "get_messages" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/abort") {
void sendCmd({ type: "abort" })
.then(() => json(res, { ok: true }))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/abort-retry") {
void sendCmd({ type: "abort_retry" })
.then(() => json(res, { ok: true }))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

@@ -1,19 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { listSlashCommands } from "../services/slash-commands.ts";
import type { WebUiContext } from "../types/context.ts";
import { json } from "../http/request.ts";
export function handleCommandsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
if (req.method === "GET" && pathname === "/api/commands") {
void listSlashCommands(ctx.rpc.sendCmd)
.then((commands) => json(res, { commands }))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

@@ -1,25 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleExtensionUiRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
if (req.method !== "POST" || pathname !== "/api/extension-ui-response") {
return false;
}
void readBody(req)
.then(({ id, response }) => {
if (typeof id !== "string" || !id.trim()) throw new Error("缺少 extension UI 响应 id");
if (!response || typeof response !== "object") throw new Error("缺少 extension UI 响应内容");
ctx.rpc.sendExtensionUiResponse(id, response as Record<string, unknown>);
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}

View File

@@ -1,43 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleModelsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { sendCmd } = ctx.rpc;
if (req.method === "GET" && pathname === "/api/models") {
void sendCmd({ type: "get_available_models" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/model") {
void readBody(req)
.then(({ provider, modelId }) =>
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/thinking") {
void readBody(req)
.then(({ level }) =>
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

@@ -1,129 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
appendSessionName,
buildSessionListResponse,
deleteSessionFile,
pinSession,
readSessionMessages,
readSessionSummary,
resolveSessionFile,
} from "../services/sessions.ts";
import type { WebUiContext } from "../types/context.ts";
import { isSameResolvedPath } from "../utils/paths.ts";
import { json, readBody } from "../http/request.ts";
export function handleSessionsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { paths } = ctx.config;
const { sendCmd } = ctx.rpc;
if (req.method === "POST" && pathname === "/api/new-session") {
void sendCmd({ type: "new_session" })
.then(async (r: any) => {
if (!r.success) return json(res, { error: r.error }, 500);
if (r.data?.cancelled) return json(res, r.data);
const state = await sendCmd({ type: "get_state" });
const sessionFile = state.success ? state.data?.sessionFile : undefined;
return json(res, { ...r.data, sessionFile });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "GET" && pathname === "/api/sessions") {
json(res, buildSessionListResponse(paths));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/history") {
void readBody(req)
.then(({ path: sp }) => {
const messages = readSessionMessages(sp as string);
const summary = readSessionSummary(paths, sp as string);
json(res, { messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/delete") {
void readBody(req)
.then(({ path: sp }) => {
deleteSessionFile(paths, sp as string);
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/pin") {
void readBody(req)
.then(({ path: sp, pinned }) => {
const result = pinSession(paths, sp as string, Boolean(pinned));
json(res, { ok: true, ...result });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/load") {
void readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: paths.repoRoot });
if (!sw.success) throw new Error(sw.error);
const mr = await sendCmd({ type: "get_messages" });
if (!mr.success) throw new Error(mr.error);
const summary = readSessionSummary(paths, sp as string);
json(res, { messages: mr.data.messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/activate") {
void readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: paths.repoRoot });
if (!sw.success) throw new Error(sw.error);
const state = await sendCmd({ type: "get_state" });
if (!state.success) throw new Error(state.error);
json(res, { ok: true, state: state.data });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/name") {
void readBody(req)
.then(async ({ path: sp, name }) => {
if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
const savedName = appendSessionName(paths, sp, typeof name === "string" ? name : "");
const sessionPath = resolveSessionFile(paths, sp);
const state = await sendCmd({ type: "get_state" });
if (
state.success &&
state.data?.sessionFile &&
isSameResolvedPath(String(state.data.sessionFile), sessionPath)
) {
const rename = await sendCmd({ type: "set_session_name", name: savedName });
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
}
json(res, { ok: true, name: savedName });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "GET" && pathname === "/api/session-state") {
void sendCmd({ type: "get_state" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

@@ -1,225 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { arch, freemem, hostname, platform, release, totalmem, type as osType } from "node:os";
import { readWebuiAvatarSettings, writeWebuiAvatarSettings } from "../db/index.ts";
import { setExtensionEnabled } from "../settings/extension-settings.ts";
import { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from "../settings/mcp-settings.ts";
import { listSkillSettings, setSkillEnabled } from "../settings/skills-settings.ts";
import { normalizeAvatarUrl } from "../services/avatars.ts";
import {
listExtensionsForSettings,
listLoadedExtensionsByPath,
mergeExtensionToggleResponse,
} from "../services/extensions-display.ts";
import { readModelsConfig, writeModelsConfig } from "../services/models-config.ts";
import { readSystemPrompt, writeSystemPrompt } from "../services/system-prompt.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
function listMcpTools(ctx: WebUiContext): Record<string, unknown>[] {
return listMcpSettings(ctx.config.paths.mcpConfigFile, ctx.config.paths.mcpCacheFile);
}
export function handleSettingsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { paths } = ctx.config;
const { sendCmd } = ctx.rpc;
if (req.method === "GET" && pathname === "/api/settings") {
void listSkillSettings(paths.repoRoot, paths.agentDir)
.then((skills) =>
listExtensionsForSettings(paths, sendCmd).then((extensions) =>
json(res, {
...readWebuiAvatarSettings(),
webuiDbPath: ctx.db.dbPath,
systemPrompt: readSystemPrompt(paths),
systemPromptPath: paths.systemPromptFile,
modelsConfig: readModelsConfig(paths),
modelsConfigPath: paths.modelsConfigFile,
extensionsPath: paths.agentExtensionsDir,
mcpCachePath: paths.mcpCacheFile,
mcpConfigPath: paths.mcpConfigFile,
skills,
extensions,
mcpTools: listMcpTools(ctx),
}),
),
)
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/skills/toggle") {
void readBody(req)
.then(async ({ path: skillPath, enabled }) => {
if (typeof skillPath !== "string" || !skillPath.trim()) {
throw new Error("skill path 无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const skill = await setSkillEnabled(paths.repoRoot, paths.agentDir, skillPath.trim(), enabled);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, skill });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/extensions/toggle") {
void readBody(req)
.then(async ({ path: extensionPath, enabled }) => {
if (typeof extensionPath !== "string" || !extensionPath.trim()) {
throw new Error("extension path 无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const extension = await setExtensionEnabled(
paths.repoRoot,
paths.agentDir,
extensionPath.trim(),
enabled,
);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
let loadedByPath = new Map<string, any>();
try {
loadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);
} catch {
/* ignore */
}
json(res, {
ok: true,
extension: mergeExtensionToggleResponse(paths, extension, loadedByPath),
});
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/mcp/server/toggle") {
void readBody(req)
.then(async ({ server, enabled }) => {
if (typeof server !== "string" || !server.trim()) {
throw new Error("server 名称无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const entry = setMcpServerEnabled(
paths.mcpConfigFile,
paths.mcpCacheFile,
server.trim(),
enabled,
);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, server: entry });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/mcp/tool/toggle") {
void readBody(req)
.then(async ({ server, tool, enabled }) => {
if (typeof server !== "string" || !server.trim()) {
throw new Error("server 名称无效");
}
if (typeof tool !== "string" || !tool.trim()) {
throw new Error("tool 名称无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const entry = setMcpToolEnabled(
paths.mcpConfigFile,
paths.mcpCacheFile,
server.trim(),
tool.trim(),
enabled,
);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, tool: entry });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/reload") {
void sendCmd({ type: "reload" })
.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/models-config") {
void readBody(req)
.then(async ({ modelsConfig }) => {
if (typeof modelsConfig !== "string") {
throw new Error("modelsConfig 必须是字符串");
}
writeModelsConfig(paths, modelsConfig);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, modelsConfigPath: paths.modelsConfigFile });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/system-prompt") {
void readBody(req)
.then(async ({ systemPrompt }) => {
if (typeof systemPrompt !== "string") {
throw new Error("systemPrompt 必须是字符串");
}
writeSystemPrompt(paths, systemPrompt);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, systemPromptPath: paths.systemPromptFile });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/avatars") {
void readBody(req)
.then(({ userAvatarUrl, agentAvatarUrl }) => {
const settings = {
userAvatarUrl: normalizeAvatarUrl(userAvatarUrl),
agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),
};
writeWebuiAvatarSettings(settings);
json(res, { ok: true, ...settings });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "GET" && pathname === "/api/environment") {
json(res, {
nodeVersion: process.version,
platform: platform(),
arch: arch(),
osType: osType(),
osRelease: release(),
hostname: hostname(),
pid: process.pid,
cwd: process.cwd(),
uptime: Math.floor(process.uptime()),
totalMemMb: Math.round(totalmem() / 1024 / 1024),
freeMemMb: Math.round(freemem() / 1024 / 1024),
execPath: process.execPath,
});
return true;
}
return false;
}

View File

@@ -1,70 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
deleteWebuiConfig,
getAllWebuiConfig,
getWebuiConfig,
readWebuiAvatarSettings,
setWebuiConfig,
setWebuiConfigMany,
} from "../db/index.ts";
import { normalizeConfigKey, normalizeConfigValue } from "../services/avatars.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleWebuiConfigRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
url: URL,
): boolean {
if (req.method === "GET" && pathname === "/api/avatars") {
json(res, readWebuiAvatarSettings());
return true;
}
if (req.method === "GET" && pathname === "/api/webui/config") {
const key = url.searchParams.get("key");
if (key) {
const normalized = normalizeConfigKey(key);
json(res, { key: normalized, value: getWebuiConfig(normalized) });
return true;
}
json(res, { config: getAllWebuiConfig(), dbPath: ctx.db.dbPath });
return true;
}
if (req.method === "POST" && pathname === "/api/webui/config") {
void readBody(req)
.then(({ key, value, entries }) => {
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(entries as Record<string, unknown>)) {
normalized[normalizeConfigKey(rawKey)] = normalizeConfigValue(rawValue);
}
setWebuiConfigMany(normalized);
return json(res, { ok: true, config: getAllWebuiConfig() });
}
if (typeof key !== "string") throw new Error("缺少配置键 key");
const normalizedKey = normalizeConfigKey(key);
const normalizedValue = normalizeConfigValue(value);
setWebuiConfig(normalizedKey, normalizedValue);
return json(res, { ok: true, key: normalizedKey, value: normalizedValue });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/webui/config/delete") {
void readBody(req)
.then(({ key }) => {
const normalized = normalizeConfigKey(key);
const deleted = deleteWebuiConfig(normalized);
json(res, { ok: true, deleted, key: normalized });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

@@ -1,242 +0,0 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ServerResponse } from "node:http";
import { resolvePiRpcLaunch, type WebUiPaths } from "../config/paths.ts";
import type { RunSnapshot, SendCmd, SubmitPromptOptions } from "../types/context.ts";
export interface PiClient {
sendCmd: SendCmd;
submitPrompt: (options: SubmitPromptOptions) => void;
sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
getRunSnapshot: () => RunSnapshot;
connectSseClient: (res: ServerResponse) => void;
removeSseClient: (res: ServerResponse) => void;
child: ChildProcessWithoutNullStreams;
}
const BUFFERED_EVENT_TYPES = new Set([
"agent_start",
"agent_end",
"message_start",
"message_update",
"message_end",
"tool_execution_start",
"tool_execution_update",
"tool_execution_end",
"compaction_start",
"compaction_end",
"queue_update",
"auto_retry_start",
"auto_retry_end",
"bash_update",
]);
const DEFAULT_CMD_TIMEOUT_MS = 60_000;
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_000;
const PI_STDERR_BUFFER_MAX = 8192;
function formatSpawnArgs(args: string[]): string {
return args.map((arg) => JSON.stringify(arg)).join(" ");
}
function summarizeStderr(buffer: string): string {
const trimmed = buffer.trim();
if (!trimmed) return "(无 stderr 输出)";
const lines = trimmed.split(/\r?\n/).filter(Boolean);
return lines.slice(-8).join("\n");
}
export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
const piLaunch = resolvePiRpcLaunch(paths);
console.log(
`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${formatSpawnArgs(piLaunch.args)}`,
);
const pi = spawn(piLaunch.command, piLaunch.args, {
cwd: paths.repoRoot,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
PI_CODING_AGENT_DIR: paths.agentDir,
},
});
// Keep stdin pipe open; closing it on Windows can trigger RPC shutdown via stdin "end".
pi.stdin.write("");
let stderrBuffer = "";
pi.stderr.on("data", (data: Buffer) => {
const chunk = data.toString();
stderrBuffer = (stderrBuffer + chunk).slice(-PI_STDERR_BUFFER_MAX);
process.stderr.write(`[pi] ${data}`);
});
pi.on("exit", (code) => {
console.log(`[webui] pi 退出, code=${code}`);
if (code !== 0 && code !== null) {
console.error(`[webui] pi RPC 启动失败 (code=${code}):\n${summarizeStderr(stderrBuffer)}`);
}
onExit(code);
});
let buffer = "";
const pending = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }>();
const sseClients = new Set<ServerResponse>();
let reqId = 0;
let isStreaming = false;
let sessionFile: string | undefined;
let turnEventBuffer: Record<string, unknown>[] = [];
function getRunSnapshot(): RunSnapshot {
return {
isStreaming,
sessionFile,
replay: [...turnEventBuffer],
};
}
function broadcastSse(msg: Record<string, unknown>): void {
const data = `data: ${JSON.stringify(msg)}\n\n`;
for (const res of sseClients) res.write(data);
}
function trackAgentEvent(msg: Record<string, unknown>): void {
const type = msg.type;
if (typeof type !== "string") return;
if (type === "agent_start") {
isStreaming = true;
turnEventBuffer = [msg];
return;
}
if (type === "agent_end") {
turnEventBuffer.push(msg);
isStreaming = false;
turnEventBuffer = [];
return;
}
if (isStreaming && BUFFERED_EVENT_TYPES.has(type)) {
turnEventBuffer.push(msg);
}
}
function registerPending(
id: string,
handlers: { resolve: (value: any) => void; reject: (error: Error) => void },
timeoutMs: number,
timeoutLabel: string,
): void {
pending.set(id, handlers);
setTimeout(() => {
if (!pending.has(id)) return;
pending.delete(id);
handlers.reject(new Error(`命令超时: ${timeoutLabel}`));
}, timeoutMs);
}
function onLine(line: string): void {
if (!line.trim()) return;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
if (msg.type === "response" && msg.id && pending.has(String(msg.id))) {
const id = String(msg.id);
const p = pending.get(id)!;
pending.delete(id);
if (msg.command === "prompt" && msg.success === false) {
broadcastSse({
type: "prompt_rejected",
error: String(msg.error || "prompt rejected"),
});
}
if (msg.command === "get_state" && msg.success === true) {
const data = msg.data as { sessionFile?: string } | undefined;
if (data?.sessionFile) {
sessionFile = data.sessionFile;
}
}
p.resolve(msg);
return;
}
trackAgentEvent(msg);
broadcastSse(msg);
} catch {
/* ignore non-JSON lines */
}
}
pi.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) onLine(line);
});
const sendCmd: SendCmd = (command) =>
new Promise((resolvePromise, reject) => {
const id = `req_${++reqId}`;
const line = JSON.stringify({ ...command, id }) + "\n";
registerPending(
id,
{ resolve: resolvePromise, reject },
DEFAULT_CMD_TIMEOUT_MS,
String(command.type),
);
pi.stdin.write(line);
});
function submitPrompt(options: SubmitPromptOptions): void {
const id = `req_${++reqId}`;
const payload: Record<string, unknown> = {
type: "prompt",
message: options.message,
images: options.images,
id,
};
if (options.streamingBehavior) {
payload.streamingBehavior = options.streamingBehavior;
}
const line = JSON.stringify(payload) + "\n";
registerPending(
id,
{
resolve: () => {
/* preflight success: agent events arrive via SSE */
},
reject: (err) => {
broadcastSse({ type: "prompt_rejected", error: err.message });
},
},
PROMPT_PREFLIGHT_TIMEOUT_MS,
"prompt",
);
pi.stdin.write(line);
}
function sendExtensionUiResponse(id: string, response: Record<string, unknown>): void {
const line = JSON.stringify({ type: "extension_ui_response", id, ...response }) + "\n";
pi.stdin.write(line);
}
function connectSseClient(res: ServerResponse): void {
const snapshot = getRunSnapshot();
res.write(
`data: ${JSON.stringify({
type: "connected",
isStreaming: snapshot.isStreaming,
replay: snapshot.replay,
})}\n\n`,
);
sseClients.add(res);
}
return {
sendCmd,
submitPrompt,
sendExtensionUiResponse,
getRunSnapshot,
connectSseClient,
removeSseClient: (res) => sseClients.delete(res),
child: pi,
};
}

View File

@@ -1,32 +0,0 @@
export function normalizeAvatarUrl(value: unknown): string {
if (typeof value !== "string") return "";
const url = value.trim();
if (!url) return "";
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`无效头像链接: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("头像链接仅支持 http 或 https");
}
return parsed.toString();
}
export function normalizeConfigKey(key: unknown): string {
if (typeof key !== "string") throw new Error("配置键必须是字符串");
const trimmed = key.trim();
if (!trimmed) throw new Error("配置键不能为空");
if (trimmed.length > 128) throw new Error("配置键过长");
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(trimmed)) {
throw new Error("配置键格式无效");
}
return trimmed;
}
export function normalizeConfigValue(value: unknown): string {
if (typeof value !== "string") throw new Error("配置值必须是字符串");
if (value.length > 65536) throw new Error("配置值过长");
return value;
}

View File

@@ -1,24 +0,0 @@
const ALLOWED_CHAT_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const MAX_CHAT_IMAGES = 8;
const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export function normalizeChatImages(
images: unknown,
): Array<{ type: "image"; data: string; mimeType: string }> | undefined {
if (!Array.isArray(images) || images.length === 0) return undefined;
const out: Array<{ type: "image"; data: string; mimeType: string }> = [];
for (const raw of images.slice(0, MAX_CHAT_IMAGES)) {
if (!raw || typeof raw !== "object") continue;
const mimeType = String((raw as { mimeType?: string }).mimeType || "");
const data = String((raw as { data?: string }).data || "");
if ((raw as { type?: string }).type !== "image" || !ALLOWED_CHAT_IMAGE_MIME.has(mimeType) || !data) {
throw new Error(`不支持的图片类型: ${mimeType || "unknown"}`);
}
const size = Buffer.from(data, "base64").length;
if (size > MAX_CHAT_IMAGE_BYTES) {
throw new Error(`图片过大(最大 ${Math.round(MAX_CHAT_IMAGE_BYTES / 1024 / 1024)}MB`);
}
out.push({ type: "image", mimeType, data });
}
return out.length ? out : undefined;
}

View File

@@ -1,196 +0,0 @@
import { basename, resolve } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
import type { SendCmd } from "../types/context.ts";
import { listExtensionSettings, type ExtensionSettingsEntry } from "../settings/extension-settings.ts";
import {
getExtensionCategoryFromPath,
readConfiguredNpmPackageNames,
readNpmPackageVersion,
resolveNpmSource,
} from "../settings/extensions-paths.ts";
function getConfiguredNpmPackages(paths: WebUiPaths): Set<string> {
return readConfiguredNpmPackageNames(paths.agentSettingsFile);
}
function getExtensionPath(extension: any): string {
return String(extension.path || extension.resolvedPath || "");
}
function classifyExtension(
paths: WebUiPaths,
extension: any,
configuredPackages: Set<string>,
): "local" | "npm" | null {
const pathValue = getExtensionPath(extension);
return getExtensionCategoryFromPath(
pathValue,
paths.agentExtensionsDir,
paths.agentNpmNodeModules,
configuredPackages,
);
}
function cleanExtensionName(name: string): string {
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
}
function displayExtensionName(extensionPath: string, source: string): string {
const sourceMatch = source.match(/^npm:(.+)$/);
if (sourceMatch?.[1]) return sourceMatch[1];
const normalized = extensionPath.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const file = parts[parts.length - 1] || normalized;
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
return cleanExtensionName(parts[parts.length - 2]);
}
return cleanExtensionName(file);
}
function displayExtensionKind(
scope: string,
source: string,
category: "local" | "npm",
version?: string,
): string {
if (category === "npm") {
const packageName = source.startsWith("npm:") ? source.slice(4) : source;
const base = packageName ? `npm · ${packageName}` : "npm";
return version ? `${base} · ${version}` : base;
}
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "";
if (source === "auto" || source === "local") {
return scopeText ? `${scopeText}本地` : "本地";
}
return source ? `${scopeText || "未知"} · ${source}` : scopeText || "本地";
}
function displayExtensionLocation(repoRoot: string, extensionPath: string, resolvedPath: string): string {
const pathValue = extensionPath || resolvedPath;
if (!pathValue) return "";
return pathValue.replace(repoRoot, ".");
}
export function normalizeExtension(
paths: WebUiPaths,
extension: any,
configuredPackages: Set<string>,
): Record<string, unknown> {
const sourceInfo = extension.sourceInfo || {};
const pathValue = getExtensionPath(extension);
const category = classifyExtension(paths, extension, configuredPackages);
const source =
category === "npm"
? resolveNpmSource(pathValue, paths.agentNpmNodeModules, String(sourceInfo.source || ""))
: String(sourceInfo.source || "");
const resolvedPath = String(extension.resolvedPath || extension.path || pathValue);
const npmCategory = category === "npm" ? "npm" : "local";
const version =
category === "npm"
? readNpmPackageVersion(paths.agentNpmNodeModules, pathValue, source)
: undefined;
return {
name: displayExtensionName(pathValue, source),
rawName: extension.name || "",
path: pathValue,
resolvedPath,
scope: sourceInfo.scope || extension.scope || "",
source,
sourcePath: sourceInfo.path || "",
location: displayExtensionLocation(paths.repoRoot, pathValue, resolvedPath),
version,
kind: displayExtensionKind(sourceInfo.scope || extension.scope, source, npmCategory, version),
category: category || "local",
enabled: extension.enabled !== false,
commands: extension.commands || [],
tools: extension.tools || [],
flags: extension.flags || [],
shortcuts: extension.shortcuts || [],
handlers: extension.handlers || [],
};
}
export async function listLoadedExtensionsByPath(
paths: WebUiPaths,
sendCmd: SendCmd,
): Promise<Map<string, any>> {
const configuredPackages = getConfiguredNpmPackages(paths);
const response = await sendCmd({ type: "get_extensions" });
if (!response.success) throw new Error(response.error || "读取扩展失败");
const loadedByPath = new Map<string, any>();
for (const extension of response.data?.extensions || []) {
if (classifyExtension(paths, extension, configuredPackages) === null) continue;
const pathValue = getExtensionPath(extension);
loadedByPath.set(resolve(pathValue), extension);
if (extension.resolvedPath) {
loadedByPath.set(resolve(String(extension.resolvedPath)), extension);
}
}
return loadedByPath;
}
export async function listExtensionsForSettings(
paths: WebUiPaths,
sendCmd: SendCmd,
): Promise<Record<string, unknown>[]> {
const configuredPackages = getConfiguredNpmPackages(paths);
const resolved = await listExtensionSettings(paths.repoRoot, paths.agentDir);
let loadedByPath = new Map<string, any>();
try {
loadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);
} catch {
/* show resolved extensions even if agent RPC is unavailable */
}
return resolved
.map((entry) => {
const loaded =
loadedByPath.get(resolve(entry.path)) ||
loadedByPath.get(resolve(entry.resolvedPath || entry.path));
const merged = loaded
? { ...loaded, enabled: entry.enabled }
: {
path: entry.path,
resolvedPath: entry.resolvedPath,
sourceInfo: { scope: entry.scope, source: entry.source },
commands: [],
tools: [],
flags: [],
shortcuts: [],
handlers: [],
enabled: entry.enabled,
};
return normalizeExtension(paths, merged, configuredPackages);
})
.sort((a, b) => {
const categoryOrder = a.category === b.category ? 0 : a.category === "local" ? -1 : 1;
if (categoryOrder !== 0) return categoryOrder;
return String(a.name).localeCompare(String(b.name));
});
}
export function mergeExtensionToggleResponse(
paths: WebUiPaths,
extension: ExtensionSettingsEntry,
loadedByPath: Map<string, any>,
): Record<string, unknown> {
const configuredPackages = getConfiguredNpmPackages(paths);
const loaded =
loadedByPath.get(resolve(extension.path)) ||
loadedByPath.get(resolve(extension.resolvedPath || extension.path));
const merged = loaded
? { ...loaded, enabled: extension.enabled }
: {
path: extension.path,
resolvedPath: extension.resolvedPath,
sourceInfo: { scope: extension.scope, source: extension.source },
commands: [],
tools: [],
flags: [],
shortcuts: [],
handlers: [],
enabled: extension.enabled,
};
return normalizeExtension(paths, merged, configuredPackages);
}

View File

@@ -1,24 +0,0 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
export function readModelsConfig(paths: WebUiPaths): string {
if (!existsSync(paths.modelsConfigFile)) return "{\n \"providers\": {}\n}\n";
return readFileSync(paths.modelsConfigFile, "utf8");
}
export function writeModelsConfig(paths: WebUiPaths, content: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`models.json 不是有效的 JSON: ${message}`);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("models.json 根节点必须是 JSON 对象");
}
const formatted = `${JSON.stringify(parsed, null, 2)}\n`;
mkdirSync(dirname(paths.modelsConfigFile), { recursive: true });
writeFileSync(paths.modelsConfigFile, formatted, "utf8");
}

View File

@@ -1,257 +0,0 @@
import { randomUUID } from "node:crypto";
import {
appendFileSync,
existsSync,
readFileSync,
readdirSync,
statSync,
unlinkSync,
} from "node:fs";
import { join, resolve } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
import { isPathInsideRoot } from "../utils/paths.ts";
import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts";
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
const t = (text ?? "").trim();
if (!t) return true;
if (sessionHeaderId && t === sessionHeaderId) return true;
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
if (/^[0-9]{10,}$/.test(t)) return true;
return false;
}
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
if (!cleaned) return "";
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
let candidate = sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
if (candidate.length > maxChars) {
candidate = `${candidate.slice(0, maxChars).trimEnd()}`;
}
return candidate;
}
function extractPreview(msg: any): string {
const c = msg.content;
if (!c) return "";
if (typeof c === "string") return c.slice(0, 200);
if (Array.isArray(c)) {
const text = c
.filter((x: any) => x.type === "text")
.map((x: any) => x.text)
.join("")
.slice(0, 200);
if (text) return text;
const imageCount = c.filter((x: any) => x.type === "image").length;
if (imageCount > 0) return `[${imageCount} 张图片]`;
}
return "";
}
export function listSessionFiles(paths: WebUiPaths): string[] {
if (!existsSync(paths.sessionsDir)) return [];
const files: string[] = [];
const visit = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(fullPath);
}
}
};
visit(paths.sessionsDir);
return files.sort().reverse();
}
export function readSessionSummary(paths: WebUiPaths, filePath: string): Record<string, unknown> | null {
try {
const content = readFileSync(filePath, "utf8");
const lines = content.trim().split("\n");
if (!lines.length) return null;
const header = JSON.parse(lines[0]);
if (header.type !== "session") return null;
let nameFromInfo = "";
let messageCount = 0;
let firstMessage = "";
const stats = statSync(filePath);
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === "session_info" && entry.name) {
const n = String(entry.name).trim();
if (n && !isMachineSessionLabel(n, header.id)) {
nameFromInfo = n;
}
}
if (entry.type === "message") {
messageCount++;
if (!firstMessage && entry.message?.role === "user") {
firstMessage = extractPreview(entry.message);
}
}
} catch {
/* skip */
}
}
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
let name = nameFromInfo;
if (!name || isMachineSessionLabel(name, header.id)) {
name = fromFirstUser || "";
}
return {
path: filePath,
id: header.id,
name,
created: header.timestamp,
modified: stats.mtime.toISOString(),
messageCount,
firstMessage: firstMessage || "(空)",
};
} catch {
return null;
}
}
export function readSessionMessages(filePath: string): unknown[] {
try {
const content = readFileSync(filePath, "utf8");
return content
.trim()
.split("\n")
.map((line) => {
try {
const entry = JSON.parse(line);
return entry.type === "message" ? entry.message : null;
} catch {
return null;
}
})
.filter(Boolean);
} catch {
return [];
}
}
export function resolveSessionFile(paths: WebUiPaths, filePath: string): string {
if (typeof filePath !== "string" || !filePath.trim()) {
throw new Error("无效会话路径");
}
const sessionsRoot = resolve(paths.sessionsDir);
const resolved = resolve(filePath.trim());
if (!resolved.toLowerCase().endsWith(".jsonl")) {
throw new Error("无效会话路径");
}
if (!isPathInsideRoot(sessionsRoot, resolved)) {
throw new Error("无效会话路径");
}
return resolved;
}
export function appendSessionName(paths: WebUiPaths, filePath: string, name: string): string {
const sessionPath = resolveSessionFile(paths, filePath);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
const trimmed = name.trim();
if (!trimmed) throw new Error("会话名称不能为空");
const lines = readFileSync(sessionPath, "utf8").trim().split("\n");
const ids = new Set<string>();
let leafId: string | null = null;
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (typeof entry.id === "string") {
ids.add(entry.id);
leafId = entry.id;
}
} catch {
/* skip */
}
}
if (!leafId) throw new Error("无效会话文件");
let id = randomUUID().slice(0, 8);
for (let i = 0; i < 100 && ids.has(id); i++) {
id = randomUUID().slice(0, 8);
}
const entry = {
type: "session_info",
id,
parentId: leafId,
timestamp: new Date().toISOString(),
name: trimmed,
};
appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8");
return trimmed;
}
function sortSessionSummaries(
summaries: Array<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index]));
return [...summaries].sort((a, b) => {
const aPath = String(a.path);
const bPath = String(b.path);
const aPin = pinnedOrder.get(aPath);
const bPin = pinnedOrder.get(bPath);
if (aPin !== undefined && bPin !== undefined) return aPin - bPin;
if (aPin !== undefined) return -1;
if (bPin !== undefined) return 1;
return (
new Date(String(b.modified || b.created || 0)).getTime() -
new Date(String(a.modified || a.created || 0)).getTime()
);
});
}
function annotatePinnedSessions(
summaries: Array<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
const pinnedSet = new Set(pinnedPaths);
return summaries.map((summary) => ({
...summary,
pinned: pinnedSet.has(String(summary.path)),
}));
}
export function buildSessionListResponse(paths: WebUiPaths) {
const summaries = listSessionFiles(paths)
.map((filePath) => readSessionSummary(paths, filePath))
.filter(Boolean) as Array<Record<string, unknown>>;
const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path)));
const sorted = sortSessionSummaries(summaries, pinnedPaths);
return {
sessions: annotatePinnedSessions(sorted, pinnedPaths),
pinnedPaths,
};
}
export function deleteSessionFile(paths: WebUiPaths, sessionPathInput: string): void {
const sessionPath = resolveSessionFile(paths, sessionPathInput);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
unlinkSync(sessionPath);
removePinnedSessionPath(sessionPath);
}
export function pinSession(paths: WebUiPaths, sessionPathInput: string, pinned: boolean): {
path: string;
pinned: boolean;
pinnedPaths: string[];
} {
const sessionPath = resolveSessionFile(paths, sessionPathInput);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
const pinnedPaths = setSessionPinned(sessionPath, pinned);
return { path: sessionPath, pinned, pinnedPaths };
}

View File

@@ -1,51 +0,0 @@
import { BUILTIN_SLASH_COMMANDS } from "../../../../../../packages/coding-agent/src/core/slash-commands.ts";
import { filterWebUiSlashCommands, type SlashCommandEntry } from "../slash/dispatch.ts";
import type { SendCmd } from "../types/context.ts";
export async function listSlashCommands(sendCmd: SendCmd): Promise<SlashCommandEntry[]> {
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
const commands: SlashCommandEntry[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
name: command.name,
description: command.description,
source: "builtin",
}));
const response = await sendCmd({ type: "get_commands" });
if (!response.success) throw new Error(response.error || "读取命令失败");
for (const command of response.data?.commands || []) {
const name = String(command?.name || "");
if (!name) continue;
const source = String(command?.source || "");
if (source === "extension" && builtinNames.has(name)) continue;
if (source === "prompt") {
commands.push({
name,
description: String(command.description || ""),
source: "prompt",
});
continue;
}
if (source === "skill") {
commands.push({
name,
description: String(command.description || ""),
source: "skill",
});
continue;
}
if (source === "extension") {
commands.push({
name,
description: String(command.description || ""),
source: "extension",
});
}
}
return filterWebUiSlashCommands(commands).sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -1,13 +0,0 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
export function readSystemPrompt(paths: WebUiPaths): string {
if (!existsSync(paths.systemPromptFile)) return "";
return readFileSync(paths.systemPromptFile, "utf8");
}
export function writeSystemPrompt(paths: WebUiPaths, content: string): void {
mkdirSync(dirname(paths.systemPromptFile), { recursive: true });
writeFileSync(paths.systemPromptFile, content, "utf8");
}

View File

@@ -1,203 +0,0 @@
import { basename, dirname, join, relative, resolve } from "node:path";
import {
DefaultPackageManager,
type PathMetadata,
type ResolvedResource,
} from "../../../../../../packages/coding-agent/src/core/package-manager.ts";
import { SettingsManager, type PackageSource } from "../../../../../../packages/coding-agent/src/core/settings-manager.ts";
export interface ExtensionSettingsEntry {
path: string;
resolvedPath: string;
enabled: boolean;
name: string;
scope: string;
source: string;
}
interface ExtensionResourceItem {
path: string;
enabled: boolean;
metadata: PathMetadata;
}
function createManagers(repoRoot: string, agentDir: string) {
const settingsManager = SettingsManager.create(repoRoot, agentDir);
const packageManager = new DefaultPackageManager({
cwd: repoRoot,
agentDir,
settingsManager,
});
return { settingsManager, packageManager };
}
function normalizeExtensionPath(pathValue: string): string {
return resolve(pathValue);
}
function readExtensionDisplayName(pathValue: string): string {
const normalized = pathValue.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const file = parts[parts.length - 1] || normalized;
if (/^index\.[cm]?[tj]s$/i.test(file) && parts.length >= 2) {
return parts[parts.length - 2].replace(/\.[cm]?[tj]s$/i, "");
}
return basename(file).replace(/\.[cm]?[tj]s$/i, "");
}
function toResourceItem(resource: ResolvedResource): ExtensionResourceItem {
return {
path: resource.path,
enabled: resource.enabled,
metadata: resource.metadata,
};
}
function getTopLevelBaseDir(scope: "user" | "project", repoRoot: string, agentDir: string): string {
return scope === "project" ? join(repoRoot, ".pi") : agentDir;
}
function getResourcePattern(item: ExtensionResourceItem, repoRoot: string, agentDir: string): string {
if (item.metadata.origin === "package") {
const baseDir = item.metadata.baseDir ?? dirname(item.path);
return relative(baseDir, item.path);
}
const scope = item.metadata.scope as "user" | "project";
const baseDir = getTopLevelBaseDir(scope, repoRoot, agentDir);
return relative(baseDir, item.path);
}
function applyPatternUpdate(current: string[], pattern: string, enabled: boolean): string[] {
const disablePattern = `-${pattern}`;
const enablePattern = `+${pattern}`;
const updated = current.filter((entry) => {
const stripped = entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
return stripped !== pattern;
});
updated.push(enabled ? enablePattern : disablePattern);
return updated;
}
function toggleTopLevelResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const current = [...(settings.extensions ?? [])];
const pattern = getResourcePattern(item, repoRoot, agentDir);
const updated = applyPatternUpdate(current, pattern, enabled);
if (scope === "project") {
settingsManager.setProjectExtensionPaths(updated);
} else {
settingsManager.setExtensionPaths(updated);
}
}
function togglePackageResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const packages = [...(settings.packages ?? [])] as PackageSource[];
const pkgIndex = packages.findIndex((pkg) => {
const source = typeof pkg === "string" ? pkg : pkg.source;
return source === item.metadata.source;
});
if (pkgIndex === -1) return;
let pkg = packages[pkgIndex];
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
const current = [...((pkg.extensions ?? []) as string[])];
const pattern = getResourcePattern(item, repoRoot, agentDir);
const updated = applyPatternUpdate(current, pattern, enabled);
(pkg as Record<string, unknown>).extensions = updated.length > 0 ? updated : undefined;
if (!pkg.skills && !pkg.extensions && !pkg.prompts && !pkg.themes) {
packages.splice(pkgIndex, 1);
}
if (scope === "project") {
settingsManager.setProjectPackages(packages);
} else {
settingsManager.setPackages(packages);
}
}
function toggleExtensionResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
if (item.metadata.origin === "top-level") {
toggleTopLevelResource(item, enabled, settingsManager, repoRoot, agentDir);
} else {
togglePackageResource(item, enabled, settingsManager, repoRoot, agentDir);
}
}
function pathsMatch(a: string, b: string): boolean {
const left = normalizeExtensionPath(a);
const right = normalizeExtensionPath(b);
if (left === right) return true;
return resolve(a) === resolve(b);
}
function mapExtensionEntry(resource: ResolvedResource): ExtensionSettingsEntry {
return {
path: normalizeExtensionPath(resource.path),
resolvedPath: normalizeExtensionPath(resource.path),
enabled: resource.enabled,
name: readExtensionDisplayName(resource.path),
scope: resource.metadata.scope || "",
source: resource.metadata.source || "",
};
}
export async function listExtensionSettings(
repoRoot: string,
agentDir: string,
): Promise<ExtensionSettingsEntry[]> {
const { packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
return resolved.extensions.map(mapExtensionEntry).sort((a, b) => a.name.localeCompare(b.name));
}
export async function setExtensionEnabled(
repoRoot: string,
agentDir: string,
pathValue: string,
enabled: boolean,
): Promise<ExtensionSettingsEntry> {
const { settingsManager, packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
const match = resolved.extensions.find((resource) => pathsMatch(resource.path, pathValue));
if (!match) {
throw new Error("未找到对应扩展");
}
toggleExtensionResource(toResourceItem(match), enabled, settingsManager, repoRoot, agentDir);
const refreshed = await packageManager.resolve(async () => "skip");
const updated = refreshed.extensions.find((resource) => pathsMatch(resource.path, pathValue));
if (!updated) {
throw new Error("更新扩展状态后未能重新解析");
}
return mapExtensionEntry(updated);
}

View File

@@ -1,102 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
export function readConfiguredNpmPackageNames(settingsPath: string): Set<string> {
const names = new Set<string>();
if (!existsSync(settingsPath)) return names;
try {
const raw = JSON.parse(readFileSync(settingsPath, "utf8"));
const packages = raw?.packages;
if (!Array.isArray(packages)) return names;
for (const entry of packages) {
const source = typeof entry === "string" ? entry : entry?.source;
if (typeof source === "string" && source.startsWith("npm:")) {
const name = source.slice("npm:".length).trim();
if (name) names.add(name);
}
}
} catch {
/* ignore invalid settings */
}
return names;
}
export function extractNpmPackageNameFromPath(nodeModulesRoot: string, pathValue: string): string | null {
if (!pathValue) return null;
const root = resolve(nodeModulesRoot);
const normalized = resolve(pathValue);
if (normalized !== root && !normalized.startsWith(`${root}${sep}`)) {
return null;
}
const rel = relative(root, normalized);
const parts = rel.split(sep).filter(Boolean);
if (parts.length === 0) return null;
if (parts[0].startsWith("@") && parts.length >= 2) {
return `${parts[0]}/${parts[1]}`;
}
return parts[0] || null;
}
export function isLocalAgentExtension(pathValue: string, agentExtensionsDir: string): boolean {
if (!pathValue) return false;
const resolved = resolve(pathValue);
const root = resolve(agentExtensionsDir);
return resolved === root || resolved.startsWith(`${root}${sep}`);
}
export function isConfiguredNpmExtension(
pathValue: string,
nodeModulesRoot: string,
configuredPackages: Set<string>,
): boolean {
const packageName = extractNpmPackageNameFromPath(nodeModulesRoot, pathValue);
return packageName !== null && configuredPackages.has(packageName);
}
export function getExtensionCategoryFromPath(
pathValue: string,
agentExtensionsDir: string,
nodeModulesRoot: string,
configuredPackages: Set<string>,
): "local" | "npm" | null {
if (isConfiguredNpmExtension(pathValue, nodeModulesRoot, configuredPackages)) {
return "npm";
}
if (isLocalAgentExtension(pathValue, agentExtensionsDir)) {
return "local";
}
return null;
}
export function resolveNpmSource(pathValue: string, nodeModulesRoot: string, fallbackSource: string): string {
if (fallbackSource.startsWith("npm:")) return fallbackSource;
const packageName = extractNpmPackageNameFromPath(nodeModulesRoot, pathValue);
return packageName ? `npm:${packageName}` : fallbackSource;
}
export function readNpmPackageVersion(
nodeModulesRoot: string,
pathValue: string,
source: string,
): string | undefined {
let packageName = extractNpmPackageNameFromPath(nodeModulesRoot, pathValue);
if (!packageName && source.startsWith("npm:")) {
packageName = source.slice("npm:".length).trim() || null;
}
if (!packageName) return undefined;
const pkgJsonPath = join(nodeModulesRoot, packageName, "package.json");
if (!existsSync(pkgJsonPath)) return undefined;
try {
const raw = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { version?: unknown };
return typeof raw.version === "string" && raw.version.trim() ? raw.version.trim() : undefined;
} catch {
return undefined;
}
}

View File

@@ -1,245 +0,0 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const DISABLED_SERVERS_KEY = "mcpServersDisabled";
type McpServerEntry = Record<string, unknown>;
export interface McpToolSettingsEntry {
server: string;
name: string;
description: string;
parameters: string[];
required: string[];
enabled: boolean;
}
export interface McpServerSettingsEntry {
name: string;
configured: boolean;
enabled: boolean;
cached: boolean;
toolCount: number;
enabledToolCount: number;
resourceCount: number;
cachedAt: string;
tools: McpToolSettingsEntry[];
}
function readRawConfig(filePath: string): Record<string, unknown> {
if (!existsSync(filePath)) return { mcpServers: {} };
try {
const raw = JSON.parse(readFileSync(filePath, "utf8"));
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { mcpServers: {} };
} catch {
return { mcpServers: {} };
}
}
function writeRawConfig(filePath: string, raw: Record<string, unknown>): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
renameSync(tmpPath, filePath);
}
function getServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
const existing = raw.mcpServers ?? raw["mcp-servers"];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
return {};
}
return existing as Record<string, McpServerEntry>;
}
function getDisabledServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
const existing = raw[DISABLED_SERVERS_KEY];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
return {};
}
return existing as Record<string, McpServerEntry>;
}
function setServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
delete raw["mcp-servers"];
raw.mcpServers = servers;
}
function setDisabledServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
if (Object.keys(servers).length === 0) {
delete raw[DISABLED_SERVERS_KEY];
return;
}
raw[DISABLED_SERVERS_KEY] = servers;
}
function normalizeToolName(value: string): string {
return value.replace(/-/g, "_");
}
function isToolExcluded(toolName: string, serverName: string, excludeTools: unknown): boolean {
if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
const candidates = new Set<string>([
normalizeToolName(toolName),
normalizeToolName(`${serverName}_${toolName}`),
normalizeToolName(`${serverName.replace(/-/g, "_")}_${toolName}`),
]);
for (const excluded of excludeTools) {
if (typeof excluded !== "string") continue;
if (candidates.has(normalizeToolName(excluded))) {
return true;
}
}
return false;
}
function removeToolFromExcludeList(excludeTools: string[], toolName: string, serverName: string): string[] {
return excludeTools.filter((entry) => !isToolExcluded(toolName, serverName, [entry]));
}
function readJsonFile(filePath: string): any | null {
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function normalizeMcpTool(serverName: string, tool: any, serverEntry: McpServerEntry): McpToolSettingsEntry {
const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : [];
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
const name = String(tool?.name || "");
return {
server: serverName,
name,
description: String(tool?.description || ""),
parameters: properties,
required,
enabled: name ? !isToolExcluded(name, serverName, serverEntry.excludeTools) : false,
};
}
export function listMcpSettings(configPath: string, cachePath: string): McpServerSettingsEntry[] {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const disabledServers = getDisabledServersObject(raw);
const cache = readJsonFile(cachePath);
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
const serverNames = Array.from(
new Set([...Object.keys(activeServers), ...Object.keys(disabledServers), ...Object.keys(cachedServers)]),
).sort();
return serverNames.map((serverName) => {
const enabled = Object.prototype.hasOwnProperty.call(activeServers, serverName);
const serverEntry = (enabled ? activeServers[serverName] : disabledServers[serverName]) || {};
const entry = cachedServers[serverName] || {};
const tools = Array.isArray(entry.tools)
? entry.tools
.map((tool: any) => normalizeMcpTool(serverName, tool, serverEntry))
.filter((tool: McpToolSettingsEntry) => tool.name)
.sort((a: McpToolSettingsEntry, b: McpToolSettingsEntry) => a.name.localeCompare(b.name))
: [];
const enabledTools = enabled ? tools.filter((tool) => tool.enabled) : [];
const resources = Array.isArray(entry.resources) ? entry.resources : [];
return {
name: serverName,
configured: enabled || Object.prototype.hasOwnProperty.call(disabledServers, serverName),
enabled,
cached: Boolean(cachedServers[serverName]),
toolCount: tools.length,
enabledToolCount: enabledTools.length,
resourceCount: resources.length,
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
tools,
};
});
}
export function setMcpServerEnabled(
configPath: string,
cachePath: string,
serverName: string,
enabled: boolean,
): McpServerSettingsEntry {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const disabledServers = getDisabledServersObject(raw);
if (enabled) {
const entry = disabledServers[serverName];
if (!entry) {
const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (current?.enabled) return current;
throw new Error(`未找到已禁用的 MCP Server${serverName}`);
}
activeServers[serverName] = entry;
delete disabledServers[serverName];
} else {
const entry = activeServers[serverName];
if (!entry) {
const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (current && !current.enabled) return current;
throw new Error(`未找到 MCP Server${serverName}`);
}
disabledServers[serverName] = entry;
delete activeServers[serverName];
}
setServersObject(raw, activeServers);
setDisabledServersObject(raw, disabledServers);
writeRawConfig(configPath, raw);
const updated = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (!updated) {
throw new Error(`更新 MCP Server 状态失败:${serverName}`);
}
return updated;
}
export function setMcpToolEnabled(
configPath: string,
cachePath: string,
serverName: string,
toolName: string,
enabled: boolean,
): McpToolSettingsEntry {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const serverEntry = activeServers[serverName];
if (!serverEntry) {
throw new Error(`MCP Server 未启用:${serverName}`);
}
const excludeTools = Array.isArray(serverEntry.excludeTools)
? serverEntry.excludeTools.filter((value): value is string => typeof value === "string")
: [];
let nextExclude = excludeTools;
if (enabled) {
nextExclude = removeToolFromExcludeList(excludeTools, toolName, serverName);
} else if (!isToolExcluded(toolName, serverName, excludeTools)) {
nextExclude = [...excludeTools, toolName];
}
if (nextExclude.length > 0) {
serverEntry.excludeTools = nextExclude;
} else {
delete serverEntry.excludeTools;
}
activeServers[serverName] = serverEntry;
setServersObject(raw, activeServers);
writeRawConfig(configPath, raw);
const server = listMcpSettings(configPath, cachePath).find((item) => item.name === serverName);
const tool = server?.tools.find((item) => item.name === toolName);
if (!tool) {
throw new Error(`未找到 MCP工具${serverName}/${toolName}`);
}
return tool;
}

View File

@@ -1,452 +0,0 @@
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import {
DefaultPackageManager,
type PathMetadata,
type ResolvedResource,
} from "../../../../../../packages/coding-agent/src/core/package-manager.ts";
import { SettingsManager, type PackageSource } from "../../../../../../packages/coding-agent/src/core/settings-manager.ts";
import { parseFrontmatter } from "../../../../../../packages/coding-agent/src/utils/frontmatter.ts";
const SKILLS_DIR = "skills";
const SKILLS_DISABLED_DIR = "skills-disabled";
export interface SkillSettingsEntry {
path: string;
enabled: boolean;
toggleable: boolean;
name: string;
description: string;
scope: string;
source: string;
}
interface MovableSkillLocation {
baseDir: string;
fromPath: string;
isDirectory: boolean;
currentlyDisabled: boolean;
}
interface SkillScanTarget {
baseDir: string;
scope: string;
}
function createManagers(repoRoot: string, agentDir: string) {
const settingsManager = SettingsManager.create(repoRoot, agentDir);
const packageManager = new DefaultPackageManager({
cwd: repoRoot,
agentDir,
settingsManager,
});
return { settingsManager, packageManager };
}
function resolveSkillFilePath(pathValue: string): string {
const resolved = resolve(pathValue);
if (resolved.endsWith("SKILL.md")) return resolved;
const skillFile = join(resolved, "SKILL.md");
return existsSync(skillFile) ? skillFile : resolved;
}
function normalizeSkillPath(pathValue: string): string {
return resolveSkillFilePath(pathValue);
}
function readSkillMeta(pathValue: string): { name: string; description: string } {
const skillFile = resolveSkillFilePath(pathValue);
const fallbackName = basename(dirname(skillFile));
if (!existsSync(skillFile)) {
return { name: fallbackName, description: "" };
}
try {
const content = readFileSync(skillFile, "utf8");
const { frontmatter } = parseFrontmatter<{ name?: string; description?: string }>(content);
return {
name: String(frontmatter.name || fallbackName),
description: String(frontmatter.description || ""),
};
} catch {
return { name: fallbackName, description: "" };
}
}
function isSkillToggleable(metadata: PathMetadata, skillPath: string): boolean {
if (metadata.origin === "package") return false;
if (metadata.source?.startsWith("npm:")) return false;
if (skillPath.includes("/node_modules/") || skillPath.includes("\\node_modules\\")) return false;
return metadata.origin === "top-level" && metadata.source === "auto";
}
function getSkillScanTargets(repoRoot: string, agentDir: string): SkillScanTarget[] {
const targets: SkillScanTarget[] = [
{ baseDir: agentDir, scope: "user" },
{ baseDir: join(repoRoot, ".pi"), scope: "project" },
{ baseDir: join(homedir(), ".agents"), scope: "user" },
];
const userAgents = join(homedir(), ".agents");
let dir = resolve(repoRoot);
const gitRoot = (() => {
let current = dir;
while (true) {
if (existsSync(join(current, ".git"))) return current;
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
})();
dir = resolve(repoRoot);
while (true) {
const agentsBase = join(dir, ".agents");
if (resolve(agentsBase) !== resolve(userAgents)) {
targets.push({ baseDir: agentsBase, scope: "project" });
}
if (gitRoot && dir === gitRoot) break;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
const seen = new Set<string>();
return targets.filter((t) => {
const key = resolve(t.baseDir);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function collectSkillFilesInDir(dir: string, piRootMarkdown = false): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const walk = (currentDir: string, isRoot: boolean): void => {
let dirEntries: ReturnType<typeof readdirSync>;
try {
dirEntries = readdirSync(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of dirEntries) {
if (entry.name === "SKILL.md") {
const fullPath = join(currentDir, entry.name);
try {
if (statSync(fullPath).isFile()) entries.push(fullPath);
} catch {
/* ignore */
}
return;
}
}
for (const entry of dirEntries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = join(currentDir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
} catch {
continue;
}
}
if (piRootMarkdown && isRoot && isFile && entry.name.endsWith(".md")) {
entries.push(fullPath);
continue;
}
if (isDir) walk(fullPath, false);
}
};
walk(dir, true);
return entries;
}
function resolveMovableSkill(skillFilePath: string, baseDir: string): MovableSkillLocation | null {
const skillFile = resolve(skillFilePath);
const activeRoot = join(baseDir, SKILLS_DIR);
const disabledRoot = join(baseDir, SKILLS_DISABLED_DIR);
for (const [root, currentlyDisabled] of [
[activeRoot, false],
[disabledRoot, true],
] as const) {
const rel = relative(root, skillFile);
if (rel.startsWith("..") || isAbsolute(rel)) continue;
const parentRel = relative(root, dirname(skillFile));
if (parentRel === ".") {
return { baseDir, fromPath: skillFile, isDirectory: false, currentlyDisabled };
}
const topSegment = rel.split(/[/\\]/)[0];
if (!topSegment) continue;
const fromPath = join(root, topSegment);
if (!existsSync(fromPath)) continue;
return {
baseDir,
fromPath,
isDirectory: statSync(fromPath).isDirectory(),
currentlyDisabled,
};
}
return null;
}
function findMovableSkill(pathValue: string, repoRoot: string, agentDir: string): MovableSkillLocation | null {
const skillFile = resolveSkillFilePath(pathValue);
for (const { baseDir } of getSkillScanTargets(repoRoot, agentDir)) {
const found = resolveMovableSkill(skillFile, baseDir);
if (found) return found;
}
return null;
}
function moveSkillBetweenDirs(location: MovableSkillLocation, enabled: boolean): string {
const activeRoot = join(location.baseDir, SKILLS_DIR);
const disabledRoot = join(location.baseDir, SKILLS_DISABLED_DIR);
const destRoot = enabled ? activeRoot : disabledRoot;
mkdirSync(destRoot, { recursive: true });
const name = basename(location.fromPath);
const destPath = join(destRoot, name);
if (resolve(location.fromPath) === resolve(destPath)) {
return location.isDirectory ? join(destPath, "SKILL.md") : destPath;
}
if (existsSync(destPath)) {
throw new Error(`目标已存在: ${destPath}`);
}
movePath(location.fromPath, destPath);
return location.isDirectory ? join(destPath, "SKILL.md") : destPath;
}
function movePath(fromPath: string, destPath: string): void {
try {
renameSync(fromPath, destPath);
} catch (err) {
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
if (code !== "EXDEV") throw err;
cpSync(fromPath, destPath, { recursive: true });
rmSync(fromPath, { recursive: true, force: true });
}
}
function stripSkillPatternPrefix(entry: string): string {
if (entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) {
return entry.slice(1);
}
return entry;
}
function entryMatchesSkillPatterns(entry: string, patterns: Set<string>): boolean {
const stripped = stripSkillPatternPrefix(entry);
for (const pattern of patterns) {
if (stripped === pattern) return true;
if (stripped.endsWith(`/${pattern}`)) return true;
if (pattern.endsWith(stripped)) return true;
}
return false;
}
function patternsForSkillFile(skillFile: string, baseDir: string): Set<string> {
const patterns = new Set<string>();
for (const dirName of [SKILLS_DIR, SKILLS_DISABLED_DIR]) {
const root = join(baseDir, dirName);
const rel = relative(root, skillFile).replace(/\\/g, "/");
if (rel.startsWith("..") || isAbsolute(rel)) continue;
patterns.add(`${SKILLS_DIR}/${rel}`);
const top = rel.split("/")[0];
if (top && top !== rel) patterns.add(`${SKILLS_DIR}/${top}`);
if (rel.endsWith("/SKILL.md")) {
patterns.add(`${SKILLS_DIR}/${dirname(rel)}`);
}
}
return patterns;
}
function removeLegacySkillPatterns(
settingsManager: SettingsManager,
skillFilePath: string,
repoRoot: string,
agentDir: string,
): void {
const skillFile = normalizeSkillPath(skillFilePath);
const patterns = new Set<string>();
for (const { baseDir } of getSkillScanTargets(repoRoot, agentDir)) {
for (const p of patternsForSkillFile(skillFile, baseDir)) {
patterns.add(p);
}
}
const cleanList = (entries: string[]): string[] =>
entries.filter((entry) => !entryMatchesSkillPatterns(entry, patterns));
const globalSettings = settingsManager.getGlobalSettings();
const cleanedGlobal = cleanList([...(globalSettings.skills ?? [])]);
if (cleanedGlobal.length !== (globalSettings.skills ?? []).length) {
settingsManager.setSkillPaths(cleanedGlobal);
}
const projectSettings = settingsManager.getProjectSettings();
const cleanedProject = cleanList([...(projectSettings.skills ?? [])]);
if (cleanedProject.length !== (projectSettings.skills ?? []).length) {
settingsManager.setProjectSkillPaths(cleanedProject);
}
const cleanPackages = (packages: PackageSource[], setter: (pkgs: PackageSource[]) => void): void => {
let changed = false;
const updated = packages.map((pkg) => {
if (typeof pkg === "string") return pkg;
if (!pkg.skills?.length) return pkg;
const cleaned = cleanList([...pkg.skills]);
if (cleaned.length === pkg.skills.length) return pkg;
changed = true;
const next = { ...pkg, skills: cleaned.length > 0 ? cleaned : undefined };
if (!next.skills && !next.extensions && !next.prompts && !next.themes) {
return pkg.source;
}
return next;
});
if (changed) setter(updated);
};
cleanPackages([...(globalSettings.packages ?? [])], (pkgs) => settingsManager.setPackages(pkgs));
cleanPackages([...(projectSettings.packages ?? [])], (pkgs) => settingsManager.setProjectPackages(pkgs));
}
function toggleSkillByMove(
pathValue: string,
enabled: boolean,
repoRoot: string,
agentDir: string,
settingsManager: SettingsManager,
): string {
const location = findMovableSkill(pathValue, repoRoot, agentDir);
if (!location) {
throw new Error("仅支持切换 skills 目录下的 skill");
}
let newPath = normalizeSkillPath(pathValue);
if (enabled && location.currentlyDisabled) {
newPath = normalizeSkillPath(moveSkillBetweenDirs(location, true));
} else if (!enabled && !location.currentlyDisabled) {
newPath = normalizeSkillPath(moveSkillBetweenDirs(location, false));
}
removeLegacySkillPatterns(settingsManager, newPath, repoRoot, agentDir);
return newPath;
}
function mapSkillEntry(resource: ResolvedResource): SkillSettingsEntry {
const meta = readSkillMeta(resource.path);
const toggleable = isSkillToggleable(resource.metadata, resource.path);
return {
path: normalizeSkillPath(resource.path),
enabled: resource.enabled,
toggleable,
name: meta.name,
description: meta.description,
scope: resource.metadata.scope || "",
source: resource.metadata.source || "",
};
}
function mapDisabledSkillEntry(skillPath: string, scope: string): SkillSettingsEntry {
const meta = readSkillMeta(skillPath);
return {
path: normalizeSkillPath(skillPath),
enabled: false,
toggleable: true,
name: meta.name,
description: meta.description,
scope,
source: "auto",
};
}
function collectDisabledSkillEntries(repoRoot: string, agentDir: string): SkillSettingsEntry[] {
const entries: SkillSettingsEntry[] = [];
for (const { baseDir, scope } of getSkillScanTargets(repoRoot, agentDir)) {
const disabledDir = join(baseDir, SKILLS_DISABLED_DIR);
const piRootMarkdown =
resolve(baseDir) === resolve(agentDir) || resolve(baseDir) === resolve(join(repoRoot, ".pi"));
for (const skillPath of collectSkillFilesInDir(disabledDir, piRootMarkdown)) {
entries.push(mapDisabledSkillEntry(skillPath, scope));
}
}
return entries;
}
function pathsMatch(a: string, b: string): boolean {
const left = normalizeSkillPath(a);
const right = normalizeSkillPath(b);
if (left === right) return true;
return resolve(a) === resolve(b);
}
function mergeSkillEntries(resolved: SkillSettingsEntry[], disabled: SkillSettingsEntry[]): SkillSettingsEntry[] {
const merged = [...resolved];
for (const entry of disabled) {
if (!merged.some((item) => pathsMatch(item.path, entry.path))) {
merged.push(entry);
}
}
return merged.sort((a, b) => a.name.localeCompare(b.name));
}
export async function listSkillSettings(repoRoot: string, agentDir: string): Promise<SkillSettingsEntry[]> {
const { packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
const active = resolved.skills.map(mapSkillEntry);
const disabled = collectDisabledSkillEntries(repoRoot, agentDir);
return mergeSkillEntries(active, disabled);
}
export async function setSkillEnabled(
repoRoot: string,
agentDir: string,
pathValue: string,
enabled: boolean,
): Promise<SkillSettingsEntry> {
const { settingsManager, packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
const match = resolved.skills.find((resource) => pathsMatch(resource.path, pathValue));
const disabledOnly = collectDisabledSkillEntries(repoRoot, agentDir).find((entry) =>
pathsMatch(entry.path, pathValue),
);
if (match && !isSkillToggleable(match.metadata, match.path)) {
throw new Error("npm 包内的 skill 由包管理器控制,无法在此禁用");
}
if (!match && !disabledOnly) {
throw new Error("未找到对应 skill");
}
if (!findMovableSkill(pathValue, repoRoot, agentDir)) {
throw new Error("仅支持切换 skills 目录下的 skill");
}
const newPath = toggleSkillByMove(pathValue, enabled, repoRoot, agentDir, settingsManager);
const meta = readSkillMeta(newPath);
const scope = match?.metadata.scope || disabledOnly?.scope || "";
const source = match?.metadata.source || disabledOnly?.source || "auto";
return {
path: newPath,
enabled,
toggleable: true,
name: meta.name,
description: meta.description,
scope,
source,
};
}

View File

@@ -1,246 +0,0 @@
import { BUILTIN_SLASH_COMMANDS } from "../../../../../../packages/coding-agent/src/core/slash-commands.ts";
const BUILTIN_NAMES = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
/** Built-in slash commands that WebUI executes (not TUI-only hints). */
export const WEBUI_BUILTIN_SLASH_NAMES = new Set([
"compact",
"new",
"reload",
"clone",
"name",
"model",
"session",
"export",
"copy",
]);
export function isWebUiSlashCommand(source: "builtin" | "extension" | "prompt" | "skill", name: string): boolean {
if (source === "extension" || source === "prompt" || source === "skill") {
return true;
}
return WEBUI_BUILTIN_SLASH_NAMES.has(name);
}
export interface SlashCommandEntry {
name: string;
description: string;
source: "builtin" | "extension" | "prompt" | "skill";
}
export function filterWebUiSlashCommands(commands: SlashCommandEntry[]): SlashCommandEntry[] {
return commands.filter((command) => isWebUiSlashCommand(command.source, command.name));
}
export interface SlashDispatchResult {
handled: boolean;
message?: string;
action?: "new_session" | "reload_messages" | "reload_sessions" | "copy";
sessionFile?: string;
}
type SendCmd = (command: Record<string, unknown>) => Promise<any>;
interface ParsedSlash {
name: string;
args: string;
}
function parseSlashInput(text: string): ParsedSlash | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) return null;
const body = trimmed.slice(1);
const spaceIndex = body.indexOf(" ");
if (spaceIndex === -1) {
return { name: body, args: "" };
}
return {
name: body.slice(0, spaceIndex),
args: body.slice(spaceIndex + 1).trim(),
};
}
function webuiOnlyMessage(command: string): SlashDispatchResult {
const hints: Record<string, string> = {
settings: "请使用 WebUI 顶栏或设置页修改配置。",
"scoped-models": "该命令仅在终端交互模式可用。",
changelog: "该命令仅在终端交互模式可用。",
hotkeys: "该命令仅在终端交互模式可用。",
fork: "该命令仅在终端交互模式可用(需选择消息节点)。",
tree: "该命令仅在终端交互模式可用。",
login: "请在 pi 终端模式中执行 /login 配置认证。",
logout: "请在 pi 终端模式中执行 /logout 移除认证。",
resume: "请使用左侧会话列表切换会话。",
import: "请使用终端模式 /import或通过会话列表管理历史会话。",
share: "该命令仅在终端交互模式可用。",
quit: "WebUI 不会退出 pi 进程。",
};
return {
handled: true,
message: hints[command] || "该命令在 WebUI 中不可用,请在终端模式使用。",
};
}
async function findModelMatch(sendCmd: SendCmd, searchTerm: string): Promise<{ provider: string; modelId: string } | null> {
const response = await sendCmd({ type: "get_available_models" });
if (!response.success) return null;
const models = response.data?.models || [];
const term = searchTerm.trim().toLowerCase();
if (!term) return null;
if (term.includes("/")) {
const slashIndex = term.indexOf("/");
const provider = term.slice(0, slashIndex);
const modelId = term.slice(slashIndex + 1);
const match = models.find(
(model: any) =>
String(model.provider || "").toLowerCase() === provider &&
String(model.id || "").toLowerCase() === modelId,
);
if (match) {
return { provider: match.provider, modelId: match.id };
}
}
const byId = models.filter((model: any) => String(model.id || "").toLowerCase() === term);
if (byId.length === 1) {
return { provider: byId[0].provider, modelId: byId[0].id };
}
return null;
}
export function isBuiltinSlashCommand(text: string): boolean {
const parsed = parseSlashInput(text);
return parsed ? BUILTIN_NAMES.has(parsed.name) : false;
}
export async function dispatchSlashCommand(text: string, sendCmd: SendCmd): Promise<SlashDispatchResult | null> {
const parsed = parseSlashInput(text);
if (!parsed) return null;
if (!BUILTIN_NAMES.has(parsed.name)) return null;
const { name, args } = parsed;
switch (name) {
case "compact": {
const response = await sendCmd({ type: "compact", customInstructions: args || undefined });
if (!response.success) throw new Error(response.error || "压缩失败");
return { handled: true, action: "reload_messages" };
}
case "new": {
const response = await sendCmd({ type: "new_session" });
if (!response.success) throw new Error(response.error || "新建会话失败");
if (response.data?.cancelled) {
return { handled: true, message: "已取消新建会话" };
}
const state = await sendCmd({ type: "get_state" });
if (!state.success) throw new Error(state.error || "读取会话状态失败");
return {
handled: true,
action: "new_session",
sessionFile: state.data?.sessionFile,
message: "已开始新会话",
};
}
case "reload": {
const response = await sendCmd({ type: "reload" });
if (!response.success) throw new Error(response.error || "重新加载失败");
return { handled: true, action: "reload_sessions", message: "已重新加载配置与扩展" };
}
case "clone": {
const response = await sendCmd({ type: "clone" });
if (!response.success) throw new Error(response.error || "克隆会话失败");
const state = await sendCmd({ type: "get_state" });
return {
handled: true,
action: "new_session",
sessionFile: state.success ? state.data?.sessionFile : undefined,
message: "已克隆当前会话",
};
}
case "name": {
if (!args) {
const state = await sendCmd({ type: "get_state" });
if (!state.success) throw new Error(state.error || "读取会话状态失败");
const currentName = state.data?.sessionName;
return {
handled: true,
message: currentName ? `当前会话名称:${currentName}` : "用法:/name <名称>",
};
}
const response = await sendCmd({ type: "set_session_name", name: args });
if (!response.success) throw new Error(response.error || "设置会话名称失败");
return { handled: true, message: `会话名称已设为:${args}` };
}
case "model": {
if (!args) {
return { handled: true, message: "请使用顶栏模型选择器切换模型,或输入 /model provider/modelId。" };
}
const match = await findModelMatch(sendCmd, args);
if (!match) {
throw new Error(`未找到模型:${args}`);
}
const response = await sendCmd({ type: "set_model", provider: match.provider, modelId: match.modelId });
if (!response.success) throw new Error(response.error || "切换模型失败");
return { handled: true, message: `已切换模型:${match.provider}/${match.modelId}` };
}
case "session": {
const response = await sendCmd({ type: "get_session_stats" });
if (!response.success) throw new Error(response.error || "读取会话信息失败");
const stats = response.data || {};
const state = await sendCmd({ type: "get_state" });
const sessionName = state.success ? state.data?.sessionName : undefined;
const lines = [
sessionName ? `名称:${sessionName}` : null,
stats.sessionFile ? `文件:${stats.sessionFile}` : null,
stats.sessionId ? `ID${stats.sessionId}` : null,
`用户消息:${stats.userMessages ?? 0}`,
`助手消息:${stats.assistantMessages ?? 0}`,
`工具调用:${stats.toolCalls ?? 0}`,
`总计:${stats.totalMessages ?? 0}`,
].filter(Boolean);
return { handled: true, message: lines.join("\n") };
}
case "export": {
const response = await sendCmd({ type: "export_html", outputPath: args || undefined });
if (!response.success) throw new Error(response.error || "导出失败");
const outputPath = response.data?.path || response.data?.filePath || args || "默认路径";
return { handled: true, message: `会话已导出:${outputPath}` };
}
case "copy": {
const response = await sendCmd({ type: "get_last_assistant_text" });
if (!response.success) throw new Error(response.error || "读取助手消息失败");
const copyText = String(response.data?.text || "").trim();
if (!copyText) {
return { handled: true, message: "暂无可复制的助手消息。" };
}
return { handled: true, action: "copy", message: copyText };
}
case "settings":
case "scoped-models":
case "changelog":
case "hotkeys":
case "fork":
case "tree":
case "login":
case "logout":
case "resume":
case "import":
case "share":
case "quit":
return webuiOnlyMessage(name);
default:
return null;
}
}

View File

@@ -1,33 +0,0 @@
import type { ServerResponse } from "node:http";
import type { WebUiPaths } from "../config/paths.ts";
import type { WebuiDbInfo } from "../db/index.ts";
export type SendCmd = (command: Record<string, unknown>) => Promise<any>;
export interface SubmitPromptOptions {
message: string;
images?: Array<{ type: "image"; data: string; mimeType: string }>;
streamingBehavior?: "steer" | "followUp";
}
export interface RunSnapshot {
isStreaming: boolean;
sessionFile?: string;
replay: Record<string, unknown>[];
}
export interface WebUiContext {
config: {
paths: WebUiPaths;
port: number;
};
rpc: {
sendCmd: SendCmd;
submitPrompt: (options: SubmitPromptOptions) => void;
sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
getRunSnapshot: () => RunSnapshot;
connectSseClient: (res: ServerResponse) => void;
removeSseClient: (res: ServerResponse) => void;
};
db: WebuiDbInfo;
}

View File

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

View File

@@ -1,3 +0,0 @@
node_modules
dist/
dist-desketop/

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f7f8fb" />
<meta name="description" content="树萌芽智能 AI 助手 Web 客户端" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="萌小芽" />
<title>萌小芽</title>
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
<link rel="apple-touch-icon" href="/logo192.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
{
"name": "sproutclaw-webui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"generate-icons": "node scripts/generate-icons.mjs",
"dev": "vite",
"build": "npm run generate-icons && tsc -b && vite build && vite build --mode desktop",
"build:web": "npm run generate-icons && tsc -b && vite build",
"build:desktop": "npm run generate-icons && tsc -b && vite build --mode desktop",
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "11.11.1",
"marked": "15.0.12",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-router-dom": "7.15.1"
},
"devDependencies": {
"@mogeko/maple-mono-cn": "7.9.0",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.7.0",
"sharp": "0.34.5",
"typescript": "5.9.3",
"vite": "6.4.2",
"vite-plugin-pwa": "1.3.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

View File

@@ -1,22 +0,0 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const __dirname = dirname(fileURLToPath(import.meta.url));
const publicDir = join(__dirname, "..", "public");
const source = join(publicDir, "logo.png");
if (!existsSync(source)) {
console.warn("[generate-icons] logo.png not found, skipping");
process.exit(0);
}
for (const size of [192, 512]) {
const out = join(publicDir, `logo${size}.png`);
await sharp(source)
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toFile(out);
console.log(`[generate-icons] wrote ${out}`);
}

View File

@@ -1,28 +0,0 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
import { ChatLayout } from "./components/layout/ChatLayout";
import { AvatarProvider } from "./context/AvatarContext";
import { BootstrapProvider } from "./context/BootstrapContext";
import { ChatProvider } from "./context/ChatContext";
import { ChatPage } from "./routes/ChatPage";
import { SettingsPage } from "./routes/SettingsPage";
export function App() {
return (
<BrowserRouter>
<BootstrapProvider>
<AvatarProvider>
<ChatProvider>
<Routes>
<Route path="/" element={<ChatLayout />}>
<Route index element={<ChatPage />} />
</Route>
<Route path="/settings" element={<SettingsPage />} />
</Routes>
<PwaUpdatePrompt />
</ChatProvider>
</AvatarProvider>
</BootstrapProvider>
</BrowserRouter>
);
}

View File

@@ -1,13 +0,0 @@
const API_BASE = (import.meta.env.VITE_API_BASE ?? "").trim().replace(/\/$/, "");
/** Resolve an API path. Empty base keeps same-origin relative paths for web `dist/`. */
export function apiUrl(path: string): string {
if (!path.startsWith("/")) {
throw new Error(`API path must start with /: ${path}`);
}
return API_BASE ? `${API_BASE}${path}` : path;
}
export function getApiBase(): string {
return API_BASE;
}

View File

@@ -1,71 +0,0 @@
import { apiGet, apiPost } from "./client";
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
import type { BashResult } from "../utils/bash";
import type { NewSessionResponse } from "../types/session";
export interface ChatResponse {
ok?: boolean;
accepted?: boolean;
slash?: boolean;
message?: string;
action?: "new_session" | "reload_messages" | "reload_sessions" | "copy";
sessionFile?: string;
error?: string;
}
export type SendMode = "prompt" | "steer" | "follow_up";
export function sendChat(
message: string,
images?: ImageContent[],
options?: { streamingBehavior?: "steer" | "followUp" },
): Promise<ChatResponse> {
return apiPost("/api/chat", { message, images, streamingBehavior: options?.streamingBehavior });
}
export function sendSteer(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
return apiPost("/api/steer", { message, images });
}
export function sendFollowUp(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
return apiPost("/api/follow-up", { message, images });
}
export function runBash(command: string, options?: { excludeFromContext?: boolean }): Promise<BashResult> {
return apiPost("/api/bash", { command, excludeFromContext: options?.excludeFromContext });
}
export function abortChat(): Promise<{ ok: boolean }> {
return apiPost("/api/abort");
}
export function abortRetry(): Promise<{ ok: boolean }> {
return apiPost("/api/abort-retry");
}
export function sendExtensionUiResponse(
id: string,
response: Record<string, unknown>,
): Promise<{ ok: boolean }> {
return apiPost("/api/extension-ui-response", { id, response });
}
export function fetchSessionState(): Promise<SessionState> {
return apiGet("/api/session-state");
}
export function fetchModels(): Promise<{ models: ModelInfo[] }> {
return apiGet("/api/models");
}
export function setModel(provider: string, modelId: string): Promise<{ ok: boolean }> {
return apiPost("/api/model", { provider, modelId });
}
export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
return apiPost("/api/thinking", { level });
}
export function createNewSession(): Promise<NewSessionResponse> {
return apiPost("/api/new-session");
}

View File

@@ -1,32 +0,0 @@
import { apiUrl } from "./base";
export class ApiError extends Error {
constructor(
message: string,
public status?: number,
) {
super(message);
this.name = "ApiError";
}
}
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(apiUrl(url), init);
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
if (!res.ok || (data && "error" in data && data.error)) {
throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
}
return data;
}
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
return apiFetch<T>(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
export async function apiGet<T>(url: string): Promise<T> {
return apiFetch<T>(url);
}

View File

@@ -1,6 +0,0 @@
import { apiGet } from "./client";
import type { SlashCommandsResponse } from "../types/commands";
export function fetchSlashCommands(): Promise<SlashCommandsResponse> {
return apiGet("/api/commands");
}

View File

@@ -1,59 +0,0 @@
import { apiGet, apiPost } from "./client";
import { apiUrl } from "./base";
import type { SessionState } from "../types/message";
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
return apiGet("/api/sessions");
}
export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload> {
return apiPost("/api/sessions/history", { path });
}
export async function activateSessionBackend(path: string): Promise<SessionState | null> {
const activateRes = await fetch(apiUrl("/api/sessions/activate"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
const activateData = (await activateRes.json().catch(() => ({}))) as {
error?: string;
state?: SessionState;
};
if (activateRes.ok && !activateData.error) {
return activateData.state ?? null;
}
const activateError = activateData.error || activateRes.statusText || "Unknown error";
if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
throw new Error(activateError);
}
const fallbackRes = await fetch(apiUrl("/api/sessions/load"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
const fallbackData = (await fallbackRes.json().catch(() => ({}))) as { error?: string };
if (!fallbackRes.ok || fallbackData.error) {
throw new Error(fallbackData.error || fallbackRes.statusText);
}
return null;
}
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
return apiPost("/api/sessions/load", { path });
}
export function deleteSession(path: string): Promise<{ ok?: boolean }> {
return apiPost("/api/sessions/delete", { path });
}
export function renameSession(path: string, name: string): Promise<{ ok?: boolean }> {
return apiPost("/api/sessions/name", { path, name });
}
export function setSessionPinned(path: string, pinned: boolean): Promise<{ ok?: boolean; pinned?: boolean; pinnedPaths?: string[] }> {
return apiPost("/api/sessions/pin", { path, pinned });
}

View File

@@ -1,56 +0,0 @@
import { apiGet, apiPost } from "./client";
import type { AvatarSettings, EnvironmentInfo, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
export function fetchSettings(): Promise<SettingsData> {
return apiGet("/api/settings");
}
export function toggleSkill(path: string, enabled: boolean): Promise<{ ok?: boolean; skill?: SkillInfo }> {
return apiPost("/api/settings/skills/toggle", { path, enabled });
}
export function toggleExtension(
path: string,
enabled: boolean,
): Promise<{ ok?: boolean; extension?: ExtensionInfo }> {
return apiPost("/api/settings/extensions/toggle", { path, enabled });
}
export function toggleMcpServer(
server: string,
enabled: boolean,
): Promise<{ ok?: boolean; server?: McpServerInfo }> {
return apiPost("/api/settings/mcp/server/toggle", { server, enabled });
}
export function toggleMcpTool(
server: string,
tool: string,
enabled: boolean,
): Promise<{ ok?: boolean; tool?: McpToolInfo }> {
return apiPost("/api/settings/mcp/tool/toggle", { server, tool, enabled });
}
export function fetchAvatars(): Promise<AvatarSettings> {
return apiGet("/api/avatars");
}
export function reloadSettings(): Promise<{ ok?: boolean }> {
return apiPost("/api/settings/reload");
}
export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPath?: string }> {
return apiPost("/api/settings/system-prompt", { systemPrompt });
}
export function saveModelsConfig(modelsConfig: string): Promise<{ modelsConfigPath?: string }> {
return apiPost("/api/settings/models-config", { modelsConfig });
}
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
return apiPost("/api/settings/avatars", avatars);
}
export function fetchEnvironment(): Promise<EnvironmentInfo> {
return apiGet("/api/environment");
}

View File

@@ -1,25 +0,0 @@
import { apiGet, apiPost } from "./client";
export interface WebuiConfigResponse {
config?: Record<string, string>;
dbPath?: string;
key?: string;
value?: string | null;
}
export function fetchWebuiConfig(key?: string): Promise<WebuiConfigResponse> {
const query = key ? `?key=${encodeURIComponent(key)}` : "";
return apiGet(`/api/webui/config${query}`);
}
export function saveWebuiConfig(key: string, value: string): Promise<{ ok?: boolean; key?: string; value?: string }> {
return apiPost("/api/webui/config", { key, value });
}
export function saveWebuiConfigMany(entries: Record<string, string>): Promise<{ ok?: boolean; config?: Record<string, string> }> {
return apiPost("/api/webui/config", { entries });
}
export function deleteWebuiConfig(key: string): Promise<{ ok?: boolean; deleted?: boolean; key?: string }> {
return apiPost("/api/webui/config/delete", { key });
}

View File

@@ -1,93 +0,0 @@
.block {
margin: 0;
}
.blockDim .header {
opacity: 0.75;
}
.blockDim .command,
.blockDim .output {
color: #6b7280;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid #e8ecf0;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 6px;
background: #eef2ff;
color: #4f46e5;
}
.prompt {
font-family: var(--font-mono);
font-size: inherit;
font-weight: 600;
color: #6366f1;
flex-shrink: 0;
}
.command {
min-width: 0;
flex: 1;
font-family: var(--font-mono);
font-size: inherit;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.output {
margin: 0;
padding: 10px 12px;
font-family: var(--font-mono);
font-size: inherit;
line-height: inherit;
color: #4b5563;
background: #fff;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
max-height: min(420px, 50vh);
}
.outputEmpty {
color: #9ca3af;
font-style: italic;
}
.outputStreaming::after {
content: "▊";
animation: blink 0.8s step-end infinite;
color: #6366f1;
margin-left: 1px;
}
.exit {
padding: 6px 12px;
border-top: 1px solid #fecaca;
background: #fef2f2;
font-family: var(--font-mono);
font-size: inherit;
color: #b91c1c;
}
@keyframes blink {
50% {
opacity: 0;
}
}

View File

@@ -1,41 +0,0 @@
import styles from "./BashOutputBlock.module.css";
interface BashOutputBlockProps {
command?: string;
content: string;
exitCode?: number;
streaming?: boolean;
excludeFromContext?: boolean;
}
export function BashOutputBlock({
command,
content,
exitCode,
streaming,
excludeFromContext,
}: BashOutputBlockProps) {
const hasOutput = Boolean(content.trim());
const showExit = exitCode !== undefined && exitCode !== 0 && !streaming;
return (
<div className={`${styles.block} ${excludeFromContext ? styles.blockDim : ""}`}>
<div className={styles.header}>
<span className={styles.icon} aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</span>
<span className={styles.prompt}>$</span>
<span className={styles.command}>{command || "shell"}</span>
</div>
<pre
className={`${styles.output} ${!hasOutput ? styles.outputEmpty : ""} ${streaming ? styles.outputStreaming : ""}`}
>
{hasOutput ? content : streaming ? "执行中..." : "(无输出)"}
</pre>
{showExit ? <div className={styles.exit}>退 {exitCode}</div> : null}
</div>
);
}

View File

@@ -1,153 +0,0 @@
.inputArea {
padding: 10px 16px 12px;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
}
.inputAreaDragOver {
background: rgba(239, 246, 255, 0.96);
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 720px;
margin: 0 auto 8px;
}
.attachmentItem {
position: relative;
width: 72px;
height: 72px;
}
.attachmentThumb {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.attachmentRemove {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
border: none;
border-radius: 999px;
background: rgba(15, 23, 42, 0.78);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.fileInput {
display: none;
}
.wrapper {
display: flex;
gap: 8px;
align-items: flex-end;
max-width: 720px;
margin: 0 auto;
background: #f6f7fb;
border: 1px solid #e5e5e5;
border-radius: 14px;
padding: 4px 4px 4px 8px;
transition: border-color 0.15s;
}
.wrapper:focus-within {
border-color: #2563eb;
background: #fff;
}
.input {
flex: 1;
background: transparent;
border: none;
color: #1a1a1a;
padding: 8px 0;
font-size: 16px;
font-family: inherit;
resize: none;
outline: none;
min-height: 24px;
max-height: 120px;
line-height: 1.45;
}
.input::placeholder {
color: #bbb;
}
.attachBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 11px;
color: #64748b;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.attachBtn:hover:not(:disabled) {
background: #eef2ff;
color: #2563eb;
}
.attachBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sendBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #2563eb;
border: none;
border-radius: 11px;
color: #fff;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, opacity 0.15s;
touch-action: manipulation;
}
.sendBtn:hover {
background: #1d4ed8;
}
.sendBtn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
@media (max-width: 768px) {
.inputArea {
position: sticky;
bottom: 0;
z-index: 5;
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
}
.wrapper {
padding: 2px 2px 2px 10px;
}
}

View File

@@ -1,345 +0,0 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
import { fetchSlashCommands } from "../../api/commands";
import { useChatContext } from "../../context/ChatContext";
import type { SlashCommand } from "../../types/commands";
import type { ImageContent } from "../../types/message";
import { isTouchLike } from "../../utils/format";
import {
clipboardItemsToImages,
fileToImageContent,
imageToDataUrl,
MAX_CHAT_IMAGES,
} from "../../utils/images";
import { parseBashInput } from "../../utils/bash";
import {
applySlashCompletion,
filterSlashCommands,
filterWebUiSlashCommands,
parseSlashCommandInput,
} from "../../utils/slashCommands";
import { SlashCommandMenu } from "./SlashCommandMenu";
import styles from "./ChatInput.module.css";
export function ChatInput() {
const {
isStreaming,
sendMessage,
runBashCommand,
addSystemMessage,
abortStream,
toggleToolsExpanded,
} = useChatContext();
const [value, setValue] = useState("");
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
const [dragOver, setDragOver] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const touchLike = isTouchLike();
useEffect(() => {
let cancelled = false;
void fetchSlashCommands()
.then((data) => {
if (!cancelled) setSlashCommands(filterWebUiSlashCommands(data.commands || []));
})
.catch((err) => {
if (!cancelled) {
addSystemMessage(err instanceof Error ? err.message : String(err));
}
});
return () => {
cancelled = true;
};
}, [addSystemMessage]);
useEffect(() => {
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (!inputRef.current || document.activeElement !== inputRef.current) return;
if (e.key === "Escape" && isStreaming) {
e.preventDefault();
void abortStream();
}
if (e.key === "o" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
toggleToolsExpanded();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isStreaming, abortStream, toggleToolsExpanded]);
const slashContext = useMemo(() => parseSlashCommandInput(value), [value]);
const filteredSlashCommands = useMemo(
() => (slashContext ? filterSlashCommands(slashCommands, slashContext.query) : []),
[slashCommands, slashContext],
);
const slashMenuOpen = Boolean(slashContext && filteredSlashCommands.length > 0);
useEffect(() => {
setSelectedSlashIndex(0);
}, [value, slashMenuOpen]);
const canSend = value.trim().length > 0 || pendingImages.length > 0;
const applySlashSelection = (command: SlashCommand) => {
const nextValue = applySlashCompletion(value, command.name);
setValue(nextValue);
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(nextValue.length, nextValue.length);
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
});
};
const addImages = async (files: FileList | File[]) => {
const list = Array.from(files);
if (!list.length) return;
const remaining = MAX_CHAT_IMAGES - pendingImages.length;
if (remaining <= 0) {
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
return;
}
const next: ImageContent[] = [];
for (const file of list.slice(0, remaining)) {
try {
next.push(await fileToImageContent(file));
} catch (err) {
addSystemMessage(err instanceof Error ? err.message : String(err));
}
}
if (next.length) {
setPendingImages((prev) => [...prev, ...next].slice(0, MAX_CHAT_IMAGES));
}
};
const removePendingImage = (index: number) => {
setPendingImages((prev) => prev.filter((_, i) => i !== index));
};
const handleSend = async (mode: "prompt" | "steer" | "follow_up" = "prompt") => {
const text = value.trim();
if (!text && pendingImages.length === 0) return;
const images = pendingImages.length ? pendingImages : undefined;
setValue("");
setPendingImages([]);
if (inputRef.current) inputRef.current.style.height = "auto";
if (text.startsWith("!") && parseBashInput(text)) {
await runBashCommand(text);
} else {
const sendMode = isStreaming && mode === "prompt" ? "steer" : mode;
await sendMessage(text, images, { mode: sendMode });
}
if (touchLike) inputRef.current?.blur();
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
if (slashMenuOpen) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedSlashIndex(
(prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
);
return;
}
if (e.key === "Tab") {
e.preventDefault();
const command = filteredSlashCommands[selectedSlashIndex];
if (command) applySlashSelection(command);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setValue((prev) => {
const trimmed = prev.trimStart();
if (!trimmed.startsWith("/")) return prev;
return prev.replace(/^\s*\//, "");
});
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const command = filteredSlashCommands[selectedSlashIndex];
if (command) applySlashSelection(command);
return;
}
}
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void handleSend("follow_up");
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend(isStreaming ? "steer" : "prompt");
}
};
const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
const native = e.nativeEvent as InputEvent;
if (touchLike && native.inputType === "insertLineBreak") {
e.preventDefault();
void handleSend(isStreaming ? "steer" : "prompt");
}
};
const handleInput = () => {
const el = inputRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
};
const handlePaste = (e: ClipboardEvent<HTMLElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
const hasImage = Array.from(items).some((item) => item.type.startsWith("image/"));
if (!hasImage) return;
e.preventDefault();
void clipboardItemsToImages(items)
.then((images) => {
if (!images.length) return;
setPendingImages((prev) => {
const remaining = MAX_CHAT_IMAGES - prev.length;
if (remaining <= 0) {
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
return prev;
}
return [...prev, ...images.slice(0, remaining)];
});
})
.catch((err) => {
addSystemMessage(err instanceof Error ? err.message : String(err));
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files?.length) void addImages(files);
e.target.value = "";
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setDragOver(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (files.length) void addImages(files);
};
return (
<div
className={`${styles.inputArea} ${dragOver ? styles.inputAreaDragOver : ""}`}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{pendingImages.length > 0 ? (
<div className={styles.attachments}>
{pendingImages.map((img, index) => (
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
<img className={styles.attachmentThumb} src={imageToDataUrl(img)} alt="" />
<button
type="button"
className={styles.attachmentRemove}
onClick={() => removePendingImage(index)}
aria-label="移除图片"
>
×
</button>
</div>
))}
</div>
) : null}
{slashMenuOpen ? (
<SlashCommandMenu
commands={filteredSlashCommands}
selectedIndex={selectedSlashIndex}
onSelect={applySlashSelection}
/>
) : null}
<div className={styles.wrapper}>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
className={styles.fileInput}
onChange={handleFileChange}
tabIndex={-1}
aria-hidden
/>
<button
type="button"
className={styles.attachBtn}
onClick={() => fileInputRef.current?.click()}
disabled={pendingImages.length >= MAX_CHAT_IMAGES}
title="上传图片"
aria-label="上传图片"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
</button>
<textarea
ref={inputRef}
className={styles.input}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={handleInput}
onKeyDown={handleKeyDown}
onBeforeInput={handleBeforeInput}
placeholder={isStreaming ? "Enter 改方向Shift+Enter 排队后续…" : "输入消息… (/ 命令, ! bash)"}
aria-label="消息输入框"
enterKeyHint="send"
inputMode="text"
/>
<button
className={styles.sendBtn}
type="button"
onClick={() => void handleSend(isStreaming ? "steer" : "prompt")}
disabled={!canSend}
aria-label="发送"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
</button>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,84 +0,0 @@
.wrap {
position: relative;
flex-shrink: 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 11px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 9px;
background: #fff;
color: var(--text-primary);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.btn:hover:not(:disabled) {
background: #fff;
border-color: rgba(37, 99, 235, 0.22);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
}
.btn:disabled {
color: var(--text-tertiary);
cursor: not-allowed;
background: rgba(248, 250, 252, 0.9);
}
.btnIcon {
width: 32px;
padding: 0;
justify-content: center;
}
.menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 168px;
padding: 6px;
border: 1px solid var(--toolbar-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
z-index: 120;
}
.menuItem {
display: block;
width: 100%;
padding: 9px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.menuItem:hover {
background: rgba(37, 99, 235, 0.06);
}
.menuItemDesc {
display: block;
margin-top: 3px;
font-size: 10px;
color: var(--text-tertiary);
font-weight: 500;
}
@media (max-width: 768px) {
.menu {
right: auto;
left: 0;
}
}

View File

@@ -1,164 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { fetchSessionHistory } from "../../api/sessions";
import { useChatContext } from "../../context/ChatContext";
import type { ChatMessage, ToolCallBlock } from "../../types/message";
import { extractContent, extractThinking, formatToolCall, getToolCalls } from "../../utils/toolCall";
import { nextMessageId } from "../../utils/format";
import {
downloadTextFile,
messagesToMarkdown,
messagesToMinimalHtml,
safeExportFilename,
} from "../../utils/exportConversation";
import styles from "./ExportMenu.module.css";
function historyToExportMessages(
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
): ChatMessage[] {
const result: ChatMessage[] = [];
for (const m of messages) {
if (m.role === "user") {
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
} 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) {
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 thinking = extractThinking(m.content);
if (thinking) {
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
}
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 result;
}
export function ExportMenu({ compact = false }: { compact?: boolean }) {
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
const [open, setOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const hasMessages = messages.some((m) => !m.streaming && m.content.trim());
const canExport = hasMessages && !isStreaming && !exporting;
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const resolveExportMessages = async (): Promise<ChatMessage[]> => {
if (activeSessionPath) {
try {
const payload = await fetchSessionHistory(activeSessionPath);
if (payload.messages?.length) {
return historyToExportMessages(payload.messages);
}
} catch {
/* fallback to in-memory messages */
}
}
return messages;
};
const handleExport = async (format: "markdown" | "html") => {
if (!canExport) return;
setExporting(true);
setOpen(false);
try {
const exportMessages = await resolveExportMessages();
const filtered = exportMessages.filter((m) => !m.streaming && m.content.trim());
if (!filtered.length) {
addSystemMessage("当前对话没有可导出的内容");
return;
}
const title = headerTitle.trim() || "对话";
const base = safeExportFilename(title);
if (format === "markdown") {
downloadTextFile(`${base}.md`, messagesToMarkdown(title, filtered), "text/markdown");
} else {
downloadTextFile(`${base}.html`, messagesToMinimalHtml(title, filtered), "text/html");
}
} catch (err) {
addSystemMessage(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setExporting(false);
}
};
return (
<div className={styles.wrap} ref={wrapRef}>
<button
type="button"
className={compact ? `${styles.btn} ${styles.btnIcon}` : styles.btn}
disabled={!canExport}
aria-expanded={open}
aria-haspopup="menu"
aria-label="导出对话"
onClick={() => setOpen((value) => !value)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 3v12M7 8l5 5 5-5M5 21h14" />
</svg>
{compact ? null : "导出"}
</button>
{open ? (
<div className={styles.menu} role="menu">
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("markdown")}>
Markdown
<span className={styles.menuItemDesc}>.md Markdown </span>
</button>
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("html")}>
HTML
<span className={styles.menuItemDesc}> HTML便</span>
</button>
</div>
) : null}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,204 +0,0 @@
import { useState } from "react";
import type { ChatMessage } from "../../types/message";
import { useAvatars } from "../../context/AvatarContext";
import {
downloadTextFile,
messageMarkdownFilename,
singleAssistantMarkdown,
} from "../../utils/exportConversation";
import { renderMarkdown } from "../../utils/markdown";
import { imageToDataUrl } from "../../utils/images";
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
import { ToolCallBlock } from "./ToolCallBlock";
import { ThinkingBlock } from "./ThinkingBlock";
import { BashOutputBlock } from "./BashOutputBlock";
import styles from "./MessageList.module.css";
interface MessageBubbleProps {
message: ChatMessage;
agentGutter?: "avatar" | "spacer" | "none";
toolsExpanded?: boolean;
}
function ChatAvatar({ src, className }: { src: string; className: string }) {
const [failed, setFailed] = useState(false);
if (!src || failed) return null;
return (
<img
className={className}
src={src}
width={32}
height={32}
alt=""
decoding="async"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
);
}
function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avatarUrl: string }) {
if (mode === "spacer") {
return <div className={styles.assistantAvatarSpacer} aria-hidden="true" />;
}
return (
<div className={styles.assistantAvatarSlot}>
<ChatAvatar src={avatarUrl} className={styles.assistantAvatar} />
</div>
);
}
function AssistantActions({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const markdown = singleAssistantMarkdown(content);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(markdown);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const handleDownload = () => {
downloadTextFile(messageMarkdownFilename(content), markdown, "text/markdown");
};
return (
<div className={styles.assistantToolbar}>
<button
type="button"
className={`${styles.msgActionBtn} ${copied ? styles.msgActionBtnCopied : ""}`}
onClick={() => void handleCopy()}
title="复制 Markdown"
>
{copied ? "已复制" : "复制"}
</button>
<button
type="button"
className={styles.msgActionBtn}
onClick={handleDownload}
title="下载 Markdown"
>
</button>
</div>
);
}
export function MessageBubble({
message,
agentGutter = "none",
toolsExpanded = false,
}: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? (
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
) : null;
if (message.role === "thinking") {
return (
<div className={styles.assistantRow}>
{gutter}
<div className={`${styles.msg} ${styles.thinking}`}>
<ThinkingBlock content={message.content} streaming={message.streaming} />
</div>
</div>
);
}
if (message.role === "tool_call") {
return (
<div className={styles.assistantRow}>
{gutter}
<div className={`${styles.msg} ${styles.toolCall}`}>
<ToolCallBlock content={message.content} forceOpen={toolsExpanded} />
</div>
</div>
);
}
if (message.role === "bash") {
return (
<div className={styles.assistantRow}>
{gutter}
<div className={`${styles.msg} ${styles.bash}`}>
<BashOutputBlock
command={message.bashCommand}
content={message.content}
exitCode={message.bashExitCode}
streaming={message.streaming}
excludeFromContext={message.bashExcludeFromContext}
/>
</div>
</div>
);
}
if (message.role === "compaction_summary") {
return (
<div className={`${styles.msg} ${styles.system}`}>
<CompactionSummaryBlock content={message.content} />
</div>
);
}
if (message.role === "branch_summary") {
return (
<div className={`${styles.msg} ${styles.system}`}>
<CompactionSummaryBlock content={message.content} label="分支摘要" />
</div>
);
}
const classNames = [styles.msg];
if (message.role === "user") classNames.push(styles.user);
if (message.role === "assistant") classNames.push(styles.assistant);
if (message.role === "system") classNames.push(styles.system);
if (message.streaming) classNames.push(styles.streaming);
if (message.role === "assistant") {
const showActions = !message.streaming && message.content.trim().length > 0;
return (
<div className={styles.assistantRow}>
{gutter}
<div className={classNames.join(" ")}>
{showActions ? <AssistantActions content={message.content} /> : null}
<div
className={styles.assistantBody}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
</div>
</div>
);
}
if (message.role === "user") {
return (
<div className={styles.userRow}>
<div className={classNames.join(" ")}>
{message.content ? <div>{message.content}</div> : null}
{message.images?.length ? (
<div className={styles.userImages}>
{message.images.map((img, index) => (
<img
key={`${img.mimeType}-${index}`}
className={styles.userImage}
src={imageToDataUrl(img)}
alt=""
loading="lazy"
/>
))}
</div>
) : null}
</div>
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
</div>
);
}
return <div className={classNames.join(" ")}>{message.content}</div>;
}

View File

@@ -1,434 +0,0 @@
.chat {
flex: 1;
overflow-y: auto;
padding: 12px 16px 10px;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.chat::-webkit-scrollbar {
width: 6px;
}
.chat::-webkit-scrollbar-track {
background: transparent;
}
.chat::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 3px;
}
.chat::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: #bbb;
text-align: center;
gap: 8px;
}
.emptyIcon {
color: #ddd;
}
.empty p {
font-size: 13px;
}
.emptyHint {
font-size: 11px;
color: #ccc;
}
.msg {
padding: 10px 12px;
line-height: 1.4;
font-size: 16px;
word-wrap: break-word;
white-space: pre-wrap;
}
.user {
max-width: min(700px, 85%);
background: linear-gradient(180deg, #f0f7ff 0%, #eaf2ff 100%);
border: 1px solid #dbeafe;
border-radius: 14px 14px 4px 14px;
margin: 0;
color: #1a1a1a;
}
.userRow {
align-self: flex-end;
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 10px;
width: 100%;
max-width: min(700px, 85%);
margin: 0 0 2px;
}
.userAvatar {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-top: 8px;
object-fit: cover;
border-radius: 999px;
}
.userImages {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.userImages:first-child {
margin-top: 0;
}
.userImage {
max-width: min(280px, 100%);
max-height: 240px;
border-radius: 10px;
object-fit: contain;
border: 1px solid #dbeafe;
background: #fff;
}
.assistantRow {
align-self: flex-start;
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
max-width: 100%;
margin: 0 0 2px;
}
.assistantAvatar {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-top: 8px;
object-fit: cover;
border-radius: 999px;
}
.assistantAvatarSlot {
width: 32px;
flex-shrink: 0;
}
.assistantAvatarSpacer {
width: 32px;
flex-shrink: 0;
}
.assistant {
flex: 1;
min-width: 0;
max-width: 100%;
padding: 10px 12px;
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
color: #1a1a1a;
line-height: 1.46;
white-space: normal;
}
.assistantToolbar {
display: flex;
justify-content: flex-end;
gap: 4px;
margin: -2px -2px 6px 0;
min-height: 22px;
opacity: 0;
transition: opacity 0.15s ease;
}
.assistant:hover .assistantToolbar,
.assistant:focus-within .assistantToolbar {
opacity: 1;
}
.msgActionBtn {
height: 22px;
padding: 0 7px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
color: #6b7280;
font-size: 10px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.msgActionBtn:hover {
background: #f3f4f6;
color: #374151;
border-color: #d1d5db;
}
.msgActionBtnCopied {
color: #15803d;
border-color: #bbf7d0;
background: #f0fdf4;
}
.assistantBody {
min-width: 0;
}
.system {
align-self: center;
color: #aaa;
font-size: 11px;
text-align: center;
padding: 6px 10px;
}
.toolCall {
flex: 1;
min-width: 0;
max-width: 100%;
font-size: 16px;
line-height: 1.46;
color: #666;
padding: 0;
margin: 0;
overflow: hidden;
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.thinking {
flex: 1;
min-width: 0;
max-width: 100%;
font-size: 16px;
line-height: 1.46;
padding: 0;
margin: 0;
overflow: hidden;
background: #faf8fc;
border: 1px solid #ece6f5;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.bash {
flex: 1;
min-width: 0;
max-width: 100%;
font-size: 16px;
line-height: 1.46;
padding: 0;
margin: 0;
overflow: hidden;
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.streaming::after {
content: "▊";
animation: blink 0.8s step-end infinite;
color: #2563eb;
margin-left: 1px;
}
.assistantRow + .assistantRow {
margin-top: 2px;
}
.assistant :global(h1),
.assistant :global(h2),
.assistant :global(h3),
.assistant :global(h4),
.assistant :global(h5),
.assistant :global(h6) {
margin: 0.78em 0 0.3em;
font-weight: 600;
line-height: 1.25;
}
.assistant :global(h1) { font-size: 1.18em; }
.assistant :global(h2) { font-size: 1.1em; }
.assistant :global(h3) { font-size: 1.03em; }
.assistant :global(h4) { font-size: 1em; }
.assistant > :global(:first-child) { margin-top: 0; }
.assistant > :global(:last-child) { margin-bottom: 0; }
.assistant :global(p) {
margin: 0 0 0.46em;
}
.assistant :global(p + p) {
margin-top: 0.3em;
}
.assistant :global(strong) { font-weight: 600; }
.assistant :global(em) { font-style: italic; }
.assistant :global(a) {
color: #2563eb;
text-decoration: none;
}
.assistant :global(a:hover) { text-decoration: underline; }
.assistant :global(ul),
.assistant :global(ol) {
margin: 0.32em 0 0.48em;
padding-left: 1.18em;
}
.assistant :global(li) {
margin: 0.08em 0;
padding-left: 0.04em;
}
.assistant :global(li > p) {
margin: 0;
}
.assistant :global(blockquote) {
margin: 0.36em 0;
padding: 3px 9px;
border-left: 2px solid #e5e5e5;
color: #666;
}
.assistant :global(hr) {
border: none;
border-top: 1px solid #eee;
margin: 0.58em 0;
}
.assistant :global(table) {
border-collapse: collapse;
margin: 0.42em 0;
font-size: 13px;
width: 100%;
}
.assistant :global(th),
.assistant :global(td) {
border: 1px solid #e5e5e5;
padding: 4px 7px;
text-align: left;
}
.assistant :global(th) {
background: #fafafa;
font-weight: 600;
}
.assistant :global(code:not(.hljs)) {
font-size: 0.9em;
background: #f5f5f5;
padding: 1px 5px;
border-radius: 4px;
color: #d63384;
word-break: break-word;
}
.assistant :global(pre.assistant-pre) {
margin: 0.5em 0 0.6em;
padding: 0;
background: transparent;
border: 1px solid #e8edf2;
border-radius: 8px;
overflow-x: auto;
line-height: 1.48;
}
.assistant :global(pre.assistant-pre code.hljs) {
display: block;
padding: 10px 12px;
background: none;
border: none;
color: inherit;
font-size: 13px;
line-height: 1.48;
word-break: normal;
tab-size: 2;
}
.assistant :global(img) {
max-width: 100%;
border-radius: 8px;
margin: 0.42em 0;
}
@media (min-width: 769px) {
.assistantRow {
max-width: 66.6667%;
box-sizing: border-box;
}
}
@media (max-width: 768px) {
.chat {
padding: 10px 12px 8px;
}
.msg {
padding: 11px 12px;
font-size: 16px;
}
.userRow {
max-width: 88%;
}
.user {
max-width: 100%;
}
.assistantAvatarSpacer,
.assistantAvatarSlot {
width: 28px;
}
.assistantAvatar {
width: 28px;
height: 28px;
margin-top: 10px;
}
.userAvatar {
width: 28px;
height: 28px;
margin-top: 10px;
}
.assistantToolbar {
opacity: 1;
}
}
@media (max-width: 480px) {
.userRow {
max-width: 92%;
}
}

View File

@@ -1,96 +0,0 @@
import { useEffect, useRef } from "react";
import { useChatContext } from "../../context/ChatContext";
import type { MessageRole } from "../../types/message";
import { MessageBubble } from "./MessageBubble";
import styles from "./MessageList.module.css";
const NEAR_BOTTOM_THRESHOLD = 96;
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD;
}
function scrollToBottom(el: HTMLElement): void {
el.scrollTop = el.scrollHeight;
}
function isAgentSideRole(role: MessageRole): boolean {
return role === "assistant" || role === "thinking" || role === "tool_call" || role === "bash";
}
export function MessageList() {
const { messages, toolsExpanded } = useChatContext();
const chatRef = useRef<HTMLDivElement>(null);
const stickToBottomRef = useRef(true);
const prevMessageCountRef = useRef(0);
useEffect(() => {
const el = chatRef.current;
if (!el) return;
const onScroll = () => {
stickToBottomRef.current = isNearBottom(el);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const el = chatRef.current;
if (!el) return;
const prevCount = prevMessageCountRef.current;
const messageCount = messages.length;
prevMessageCountRef.current = messageCount;
const lastMessage = messages[messageCount - 1];
const userJustSent = messageCount > prevCount && lastMessage?.role === "user";
if (userJustSent || prevCount === 0) {
stickToBottomRef.current = true;
}
if (!stickToBottomRef.current) return;
requestAnimationFrame(() => {
if (chatRef.current) scrollToBottom(chatRef.current);
});
}, [messages]);
if (messages.length === 0) {
return (
<div className={styles.chat} ref={chatRef}>
<div className={styles.empty}>
<div className={styles.emptyIcon}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<p></p>
<p className={styles.emptyHint}> Enter · Shift+Enter </p>
</div>
</div>
);
}
return (
<div className={styles.chat} ref={chatRef}>
{messages.map((msg, index) => {
const prev = messages[index - 1];
const prevIsAgentSide = prev ? isAgentSideRole(prev.role) : false;
const isAgentSide = isAgentSideRole(msg.role);
const agentGutter = isAgentSide ? (prevIsAgentSide ? "spacer" : "avatar") : "none";
return (
<MessageBubble
key={msg.id}
message={msg}
agentGutter={agentGutter}
toolsExpanded={toolsExpanded}
/>
);
})}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
.bar {
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
flex: 1 1 auto;
}
.ctxChip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
color: #1e40af;
background: rgba(37, 99, 235, 0.08);
border: 1px solid rgba(37, 99, 235, 0.12);
}
.ctxWarn {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.18);
}
.ctxDanger {
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.16);
}
@media (max-width: 768px) {
.bar {
justify-content: flex-start;
}
.ctxChip {
height: 22px;
}
}

View File

@@ -1,42 +0,0 @@
import { useChatContext } from "../../context/ChatContext";
import { formatFooterTokens } from "../../utils/format";
import styles from "./SessionContextBar.module.css";
export function SessionContextBar() {
const { sessionState, isStreaming } = useChatContext();
const data = sessionState;
if (!data || data.error || !data.stats || !data.model) {
return null;
}
const liveStreaming = isStreaming || data.isStreaming;
if (liveStreaming || data.isCompacting) {
return null;
}
const cx = data.stats.contextUsage;
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
if (!cw) return null;
const pctRaw = cx?.percent;
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
const pctNum = pctRaw != null ? Number(pctRaw) : null;
const autoInd = data.autoCompactionEnabled ? " auto" : "";
const ctxTxt =
pctStr === "?"
? `?/${formatFooterTokens(cw)}${autoInd}`
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
let ctxClass = styles.ctxChip;
if (pctNum != null) {
if (pctNum > 90) ctxClass = `${styles.ctxChip} ${styles.ctxDanger}`;
else if (pctNum > 70) ctxClass = `${styles.ctxChip} ${styles.ctxWarn}`;
}
return (
<div className={styles.bar}>
<span className={ctxClass} title="上下文占用">{ctxTxt}</span>
</div>
);
}

View File

@@ -1,67 +0,0 @@
.menu {
max-width: 720px;
margin: 0 auto 8px;
}
.list {
max-height: 240px;
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.item {
display: grid;
grid-template-columns: minmax(120px, auto) 1fr auto;
gap: 8px 12px;
width: 100%;
padding: 10px 12px;
border: none;
border-bottom: 1px solid #f3f4f6;
background: transparent;
text-align: left;
cursor: pointer;
font: inherit;
}
.item:last-child {
border-bottom: none;
}
.item:hover,
.itemActive {
background: #eff6ff;
}
.name {
color: #1d4ed8;
font-weight: 600;
font-family: inherit;
}
.description {
color: #64748b;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source {
color: #94a3b8;
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 768px) {
.item {
grid-template-columns: 1fr;
gap: 4px;
}
.source {
justify-self: start;
}
}

View File

@@ -1,49 +0,0 @@
import { useEffect, useRef } from "react";
import type { SlashCommand } from "../../types/commands";
import { slashCommandSourceLabel } from "../../utils/slashCommands";
import styles from "./SlashCommandMenu.module.css";
interface SlashCommandMenuProps {
commands: SlashCommand[];
selectedIndex: number;
onSelect: (command: SlashCommand) => void;
}
export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCommandMenuProps) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const list = listRef.current;
if (!list) return;
const item = list.children[selectedIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: "nearest" });
}, [selectedIndex, commands.length]);
if (!commands.length) return null;
return (
<div className={styles.menu} role="listbox" aria-label="斜杠命令">
<div ref={listRef} className={styles.list}>
{commands.map((command, index) => (
<button
key={command.name}
type="button"
className={`${styles.item} ${index === selectedIndex ? styles.itemActive : ""}`}
role="option"
aria-selected={index === selectedIndex}
onMouseDown={(e) => {
e.preventDefault();
onSelect(command);
}}
>
<span className={styles.name}>/{command.name}</span>
{command.description ? (
<span className={styles.description}>{command.description}</span>
) : null}
<span className={styles.source}>{slashCommandSourceLabel(command.source)}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -1,80 +0,0 @@
.block {
margin: 0;
}
.summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
user-select: none;
font-weight: 500;
color: #7c6a9a;
}
.summary::-webkit-details-marker {
display: none;
}
.summary::before {
content: "";
width: 0;
height: 0;
border-left: 5px solid #a894c6;
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent;
flex-shrink: 0;
transform: rotate(0deg);
transition: transform 0.15s ease;
}
.block[open] > .summary::before {
transform: rotate(90deg);
}
.summaryLabel {
flex-shrink: 0;
font-size: inherit;
letter-spacing: 0.02em;
}
.summaryText {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-style: italic;
color: #8b7aa8;
}
.detail {
margin: 0;
padding: 6px 10px 8px;
border-top: 1px solid #ece6f5;
}
.body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: inherit;
line-height: inherit;
font-style: italic;
color: #6f5f8d;
}
.streaming::after {
content: "▊";
animation: blink 0.8s step-end infinite;
color: #8b6fd4;
margin-left: 1px;
}
@keyframes blink {
50% {
opacity: 0;
}
}

View File

@@ -1,23 +0,0 @@
import { deriveThinkingSummary } from "../../utils/toolCall";
import styles from "./ThinkingBlock.module.css";
interface ThinkingBlockProps {
content: string;
streaming?: boolean;
}
export function ThinkingBlock({ content, streaming }: ThinkingBlockProps) {
const summary = deriveThinkingSummary(content);
return (
<details className={styles.block}>
<summary className={styles.summary}>
<span className={styles.summaryLabel}></span>
<span className={styles.summaryText}>{summary}</span>
</summary>
<div className={styles.detail}>
<div className={`${styles.body} ${streaming ? styles.streaming : ""}`}>{content}</div>
</div>
</details>
);
}

View File

@@ -1,70 +0,0 @@
.collapsible {
margin: 0;
}
.summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
user-select: none;
font-weight: 500;
color: #555;
}
.summary::-webkit-details-marker {
display: none;
}
.summary::before {
content: "";
width: 0;
height: 0;
border-left: 5px solid #888;
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent;
flex-shrink: 0;
transform: rotate(0deg);
transition: transform 0.15s ease;
}
.collapsible[open] > .summary::before {
transform: rotate(90deg);
}
.summaryText {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail {
margin: 0;
padding: 6px 10px 8px;
border-top: 1px solid #eee;
}
.detailPre {
margin: 0;
padding: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: inherit;
line-height: inherit;
}
.detailPre :global(.diffAdd) {
color: #15803d;
}
.detailPre :global(.diffDel) {
color: #b91c1c;
}
.detailPre :global(.diffMeta) {
color: #6b7280;
}

View File

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

View File

@@ -1,32 +0,0 @@
.app {
display: flex;
height: var(--app-height);
min-height: 0;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: #fff;
}
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 99;
}
.overlayOpen {
display: block;
}
@media (max-width: 768px) {
.app {
height: var(--app-height);
}
}

View File

@@ -1,22 +0,0 @@
import { Outlet } from "react-router-dom";
import { useChatContext } from "../../context/ChatContext";
import { Sidebar } from "./Sidebar";
import layoutStyles from "./AppLayout.module.css";
export function ChatLayout() {
const { sidebarOpen, toggleSidebar } = useChatContext();
return (
<div className={layoutStyles.app}>
<Sidebar />
<main className={layoutStyles.main}>
<Outlet />
</main>
<div
className={`${layoutStyles.overlay} ${sidebarOpen ? layoutStyles.overlayOpen : ""}`}
onClick={toggleSidebar}
role="presentation"
/>
</div>
);
}

View File

@@ -1,273 +0,0 @@
.header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 14px 8px;
border-bottom: 1px solid var(--header-border);
flex-shrink: 0;
background: var(--header-bg);
backdrop-filter: blur(14px);
box-shadow: var(--header-shadow);
}
.topRow {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.menuBtn {
display: none;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
background: var(--surface-muted);
border: 1px solid var(--toolbar-border);
border-radius: 10px;
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
}
.menuBtn:hover {
background: #fff;
color: var(--text-primary);
}
.titleBlock {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
.title {
margin: 0;
min-width: 0;
font-size: 15px;
font-weight: 650;
color: var(--text-primary);
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.bottomRow {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--toolbar-border);
border-radius: 12px;
background: var(--toolbar-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.controls {
display: flex;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.fieldLabel {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
line-height: 1;
padding-left: 2px;
}
.selectWrap {
position: relative;
min-width: 0;
}
.selectWrap::after {
content: "";
position: absolute;
top: 50%;
right: 8px;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid var(--text-tertiary);
transform: translateY(-35%);
pointer-events: none;
}
.select {
appearance: none;
height: 30px;
min-width: 120px;
max-width: 220px;
width: 100%;
padding: 0 24px 0 10px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 9px;
background: #fff;
color: var(--text-primary);
font-size: 11px;
font-weight: 500;
outline: none;
cursor: pointer;
}
.selectModel {
min-width: 140px;
max-width: min(280px, 34vw);
font-family: var(--font-mono, ui-monospace, monospace);
}
.selectCompact {
min-width: 68px;
max-width: 88px;
}
.select:focus {
border-color: rgba(37, 99, 235, 0.35);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.select:disabled {
color: var(--text-tertiary);
background: rgba(248, 250, 252, 0.9);
cursor: not-allowed;
}
.statsWrap {
display: flex;
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
}
.statsWrap::before {
content: "";
width: 1px;
align-self: stretch;
min-height: 24px;
margin-right: 2px;
background: linear-gradient(
180deg,
transparent 0%,
rgba(15, 23, 42, 0.08) 18%,
rgba(15, 23, 42, 0.08) 82%,
transparent 100%
);
flex-shrink: 0;
}
.toolsToggleBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: var(--surface-muted);
border: 1px solid var(--toolbar-border);
border-radius: 9px;
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
}
.toolsToggleBtn:hover {
background: #fff;
color: var(--text-primary);
}
@media (max-width: 768px) {
.header {
gap: 6px;
padding: 8px 10px 7px;
}
.menuBtn {
display: flex;
}
.title {
font-size: 14px;
}
.bottomRow {
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 6px;
}
.controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 6px;
width: 100%;
}
.fieldLabel {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.select {
height: 32px;
min-width: 0;
max-width: none;
font-size: 12px;
}
.selectModel,
.selectCompact {
min-width: 0;
max-width: none;
}
.statsWrap::before {
display: none;
}
}
@media (max-width: 420px) {
.title {
font-size: 13px;
}
}

View File

@@ -1,166 +0,0 @@
import { useChatContext } from "../../context/ChatContext";
import { modelKey, modelLabel, shortModelLabel } from "../../utils/sessionTitle";
import { ExportMenu } from "../chat/ExportMenu";
import { SessionContextBar } from "../chat/SessionContextBar";
import styles from "./Header.module.css";
function ModelControls({
availableModels,
currentModelKey,
currentModel,
thinkingLevel,
availableThinkingLevels,
isStreaming,
isApplyingModelSettings,
applyModelSelection,
applyThinkingSelection,
}: {
availableModels: ReturnType<typeof useChatContext>["availableModels"];
currentModelKey: string;
currentModel: ReturnType<typeof useChatContext>["availableModels"][number] | undefined;
thinkingLevel: string;
availableThinkingLevels: ReturnType<typeof useChatContext>["availableThinkingLevels"];
isStreaming: boolean;
isApplyingModelSettings: boolean;
applyModelSelection: (key: string) => Promise<void>;
applyThinkingSelection: (level: string) => Promise<void>;
}) {
return (
<div className={styles.controls}>
<label className={styles.field}>
<span className={styles.fieldLabel}></span>
<div className={styles.selectWrap}>
<select
className={`${styles.select} ${styles.selectModel}`}
aria-label="选择模型"
title={currentModel ? modelLabel(currentModel) : "选择模型"}
value={currentModelKey}
disabled={isStreaming || isApplyingModelSettings}
onChange={(e) => void applyModelSelection(e.target.value)}
>
{availableModels.length === 0 ? (
<option value=""></option>
) : (
availableModels.map((m) => (
<option key={modelKey(m)} value={modelKey(m)} title={modelLabel(m)}>
{shortModelLabel(m)}
</option>
))
)}
</select>
</div>
</label>
<label className={styles.field}>
<span className={styles.fieldLabel}></span>
<div className={styles.selectWrap}>
<select
className={`${styles.select} ${styles.selectCompact}`}
aria-label="选择推理强度"
title="推理强度"
value={
availableThinkingLevels.includes(thinkingLevel as typeof availableThinkingLevels[number])
? thinkingLevel
: availableThinkingLevels[0] || "off"
}
disabled={
!availableThinkingLevels.length ||
availableThinkingLevels.length === 1 ||
isStreaming ||
isApplyingModelSettings
}
onChange={(e) => void applyThinkingSelection(e.target.value)}
>
{availableThinkingLevels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</div>
</label>
</div>
);
}
function ToolsToggleButton({
expanded,
onClick,
}: {
expanded: boolean;
onClick: () => void;
}) {
return (
<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">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
);
}
export function Header() {
const {
headerTitle,
headerMeta,
toggleSidebar,
toggleToolsExpanded,
toolsExpanded,
availableModels,
currentModelKey,
thinkingLevel,
availableThinkingLevels,
isStreaming,
isApplyingModelSettings,
applyModelSelection,
applyThinkingSelection,
} = useChatContext();
const currentModel = availableModels.find((m) => modelKey(m) === currentModelKey);
const controlProps = {
availableModels,
currentModelKey,
currentModel,
thinkingLevel,
availableThinkingLevels,
isStreaming,
isApplyingModelSettings,
applyModelSelection,
applyThinkingSelection,
};
return (
<header className={styles.header}>
<div className={styles.topRow}>
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
<div className={styles.titleBlock}>
<h1 className={styles.title} title={headerTitle}>
{headerTitle}
</h1>
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
</div>
<div className={styles.topActions}>
<ToolsToggleButton expanded={toolsExpanded} onClick={toggleToolsExpanded} />
<ExportMenu compact />
</div>
</div>
<div className={styles.bottomRow}>
<ModelControls {...controlProps} />
<div className={styles.statsWrap}>
<SessionContextBar />
</div>
</div>
</header>
);
}

View File

@@ -1,170 +0,0 @@
.sidebar {
width: 236px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-right: 1px solid #e5e5e5;
display: flex;
flex-direction: column;
flex-shrink: 0;
z-index: 100;
transition: transform 0.25s ease;
}
.header {
padding: 18px 16px 10px;
border-bottom: 1px solid #f0f0f0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.logo {
width: 36px;
height: 36px;
object-fit: contain;
flex-shrink: 0;
}
.title {
font-size: 17px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.3px;
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 6px;
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sectionRefresh {
background: none;
border: none;
color: #bbb;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
transition: color 0.15s, background 0.15s;
}
.sectionRefresh:hover {
color: #666;
background: #f0f0f0;
}
.actions {
padding: 8px 10px 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.actionBtn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 10px;
color: #555;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
text-decoration: none;
}
.actionBtn:hover {
background: #f0f0f0;
color: #1a1a1a;
}
.actionBtn svg {
flex-shrink: 0;
}
.status {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
.statusRow {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.connected {
background: #22c55e;
}
.streaming {
background: #f59e0b;
animation: pulse 1.2s ease-in-out infinite;
}
.disconnected {
background: #ef4444;
}
.statusModel {
font-size: 10px;
color: #bbb;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.open {
transform: translateX(0);
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
transform: translateX(-100%);
}
.open {
transform: translateX(0);
}
}

View File

@@ -1,63 +0,0 @@
import { Link } from "react-router-dom";
import { useChatContext } from "../../context/ChatContext";
import { SessionList } from "../session/SessionList";
import styles from "./Sidebar.module.css";
export function Sidebar() {
const { connected, isStreaming, sidebarOpen, loadSessions, newSession } = useChatContext();
const statusDotClass = connected
? isStreaming
? `${styles.statusDot} ${styles.streaming}`
: `${styles.statusDot} ${styles.connected}`
: `${styles.statusDot} ${styles.disconnected}`;
const statusText = !connected ? "未连接" : isStreaming ? "输入中..." : "就绪";
return (
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.open : ""}`}>
<div className={styles.header}>
<div className={styles.brand}>
<img className={styles.logo} src="/logo.png" width={36} height={36} alt="" decoding="async" />
<h2 className={styles.title}></h2>
</div>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span></span>
<button type="button" className={styles.sectionRefresh} onClick={() => void loadSessions()} title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
</svg>
</button>
</div>
<SessionList />
</div>
<div className={styles.actions}>
<button type="button" className={styles.actionBtn} onClick={newSession}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<Link to="/settings" className={styles.actionBtn}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z" />
</svg>
</Link>
</div>
<div className={styles.status}>
<div className={styles.statusRow}>
<span className={statusDotClass} />
<span>{statusText}</span>
</div>
</div>
</aside>
);
}

View File

@@ -1,74 +0,0 @@
.bar {
position: fixed;
left: 50%;
bottom: calc(16px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
gap: 12px;
max-width: min(92vw, 420px);
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e7eb;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(10px);
}
.text {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.4;
color: #374151;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.primary,
.secondary {
height: 32px;
padding: 0 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.primary {
color: #fff;
background: #2563eb;
border-color: #2563eb;
}
.primary:hover {
background: #1d4ed8;
}
.secondary {
color: #4b5563;
background: #fff;
border-color: #d1d5db;
}
.secondary:hover {
background: #f3f4f6;
}
@media (max-width: 480px) {
.bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.actions {
justify-content: flex-end;
}
}

View File

@@ -1,39 +0,0 @@
import { useRegisterSW } from "virtual:pwa-register/react";
import styles from "./PwaUpdatePrompt.module.css";
export function PwaUpdatePrompt() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(registration) {
if (!registration) return;
window.setInterval(() => {
void registration.update();
}, 60 * 60 * 1000);
},
onRegisterError(error) {
console.error("[PWA] service worker registration failed:", error);
},
});
if (!needRefresh) return null;
return (
<div className={styles.bar} role="status" aria-live="polite">
<div className={styles.text}>使</div>
<div className={styles.actions}>
<button type="button" className={styles.secondary} onClick={() => setNeedRefresh(false)}>
</button>
<button
type="button"
className={styles.primary}
onClick={() => void updateServiceWorker(true)}
>
</button>
</div>
</div>
);
}

View File

@@ -1,186 +0,0 @@
.splash {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background:
radial-gradient(circle at 18% 12%, rgba(37, 99, 235, 0.12), transparent 34%),
radial-gradient(circle at 82% 88%, rgba(34, 197, 94, 0.08), transparent 32%),
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
opacity: 1;
transition: opacity 0.42s ease;
}
.splashExiting {
opacity: 0;
pointer-events: none;
}
.bgPulse {
position: absolute;
inset: -20%;
background:
radial-gradient(circle at 50% 45%, rgba(37, 99, 235, 0.14), transparent 42%),
radial-gradient(circle at 50% 55%, rgba(34, 197, 94, 0.1), transparent 48%);
animation: bgPulse 3.6s ease-in-out infinite;
pointer-events: none;
}
.bgGlow {
position: absolute;
width: min(72vw, 420px);
height: min(72vw, 420px);
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.12) 0%, transparent 68%);
filter: blur(8px);
animation: glowDrift 4.8s ease-in-out infinite;
pointer-events: none;
}
.content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 24px;
}
.logoWrap {
position: relative;
width: 132px;
height: 132px;
display: flex;
align-items: center;
justify-content: center;
}
.ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid rgba(37, 99, 235, 0.28);
opacity: 0;
animation: ringExpand 2.4s ease-out infinite;
}
.ring2 {
animation-delay: 0.8s;
}
.ring3 {
animation-delay: 1.6s;
}
.logo {
width: 96px;
height: 96px;
object-fit: contain;
border-radius: 22px;
box-shadow:
0 10px 28px rgba(37, 99, 235, 0.18),
0 2px 8px rgba(15, 23, 42, 0.08);
animation: logoFloat 2.8s ease-in-out infinite;
}
.titleBlock {
text-align: center;
}
.title {
font-size: clamp(28px, 6vw, 36px);
font-weight: 700;
letter-spacing: -0.4px;
color: #111827;
line-height: 1.15;
}
.subtitle {
margin-top: 6px;
font-size: 14px;
color: #6b7280;
letter-spacing: 0.2px;
}
.dots {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
animation: dotPulse 1.2s ease-in-out infinite;
}
.dot2 {
animation-delay: 0.2s;
}
.dot3 {
animation-delay: 0.4s;
}
@keyframes bgPulse {
0%, 100% { transform: scale(1); opacity: 0.72; }
50% { transform: scale(1.06); opacity: 1; }
}
@keyframes glowDrift {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-10px) scale(1.04); }
}
@keyframes logoFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes ringExpand {
0% {
transform: scale(0.72);
opacity: 0.65;
}
70% {
opacity: 0.12;
}
100% {
transform: scale(1.45);
opacity: 0;
}
}
@keyframes dotPulse {
0%, 80%, 100% {
transform: scale(0.72);
opacity: 0.45;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.bgPulse,
.bgGlow,
.logo,
.ring,
.dot {
animation: none !important;
}
.ring {
opacity: 0.2;
}
}

View File

@@ -1,31 +0,0 @@
import styles from "./SplashScreen.module.css";
interface SplashScreenProps {
exiting?: boolean;
}
export function SplashScreen({ exiting = false }: SplashScreenProps) {
return (
<div className={`${styles.splash} ${exiting ? styles.splashExiting : ""}`} aria-hidden={exiting}>
<div className={styles.bgPulse} />
<div className={styles.bgGlow} />
<div className={styles.content}>
<div className={styles.logoWrap}>
<span className={styles.ring} />
<span className={`${styles.ring} ${styles.ring2}`} />
<span className={`${styles.ring} ${styles.ring3}`} />
<img className={styles.logo} src="/logo192.png" width={96} height={96} alt="" decoding="async" />
</div>
<div className={styles.titleBlock}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
</div>
<div className={styles.dots} aria-label="加载中">
<span className={styles.dot} />
<span className={`${styles.dot} ${styles.dot2}`} />
<span className={`${styles.dot} ${styles.dot3}`} />
</div>
</div>
</div>
);
}

View File

@@ -1,149 +0,0 @@
.list {
flex: 1;
overflow-y: auto;
padding: 0 8px 6px;
}
.list::-webkit-scrollbar {
width: 4px;
}
.list::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 2px;
}
.empty {
padding: 16px 10px;
color: #bbb;
font-size: 12px;
text-align: center;
}
.item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
background: transparent;
border: none;
border-radius: 10px;
transition: background 0.15s;
margin-bottom: 3px;
}
.item:hover {
background: #f5f5f5;
}
.active {
background: #f0f7ff;
}
.pinned {
background: #fffbeb;
}
.pinned.active {
background: #fef3c7;
}
.main {
min-width: 0;
text-align: left;
padding: 8px 2px 8px 10px;
background: transparent;
border: none;
cursor: pointer;
width: 100%;
}
.name {
font-size: 13px;
font-weight: 500;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
}
.titleRow {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.titleRow .name {
flex: 1;
min-width: 0;
}
.renameInput {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: #1a1a1a;
padding: 2px 6px;
border: 1px solid #2563eb;
border-radius: 4px;
background: #fff;
outline: none;
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
}
.meta {
font-size: 10px;
color: #bbb;
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
margin-top: 4px;
}
.actions {
display: flex;
align-items: center;
gap: 0;
margin-right: 2px;
flex-shrink: 0;
}
.pinBtn,
.renameBtn,
.deleteBtn {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: #bbb;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pinBtnActive {
color: #ca8a04;
}
.pinBtn:hover,
.pinBtnActive:hover {
color: #ca8a04;
background: #fef9c3;
}
.renameBtn:hover {
color: #2563eb;
background: #eff6ff;
}
.deleteBtn:hover {
color: #dc2626;
background: #fee2e2;
}

View File

@@ -1,130 +0,0 @@
import { useState } from "react";
import { useChatContext } from "../../context/ChatContext";
import { formatTimeAgo } from "../../utils/format";
import { formatSessionTitle } from "../../utils/sessionTitle";
import styles from "./SessionList.module.css";
export function SessionList() {
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
useChatContext();
const [editingPath, setEditingPath] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState("");
const startEditing = (path: string, currentName: string) => {
setEditingPath(path);
setEditingValue(currentName);
};
const confirmRename = (path: string) => {
const trimmed = editingValue.trim();
if (trimmed) {
void renameSession(path, trimmed);
}
setEditingPath(null);
setEditingValue("");
};
const cancelEditing = () => {
setEditingPath(null);
setEditingValue("");
};
if (!sessions.length) {
return <div className={styles.empty}></div>;
}
return (
<div className={styles.list}>
{sessions.map((s) => {
const isActive = activeSessionPath === s.path;
const isPinned = Boolean(s.pinned);
const title = formatSessionTitle(s.name);
const isEditing = editingPath === s.path;
return (
<div
key={s.path}
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
>
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
<div className={styles.titleRow}>
{isEditing ? (
<input
className={styles.renameInput}
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
confirmRename(s.path);
} else if (e.key === "Escape") {
cancelEditing();
}
}}
onBlur={() => confirmRename(s.path)}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
) : (
<div className={styles.name}>{title}</div>
)}
</div>
<div className={styles.meta}>
{s.messageCount ?? 0} · {formatTimeAgo(s.modified || s.created)}
</div>
</button>
<div className={styles.actions}>
<button
type="button"
className={`${styles.pinBtn} ${isPinned ? styles.pinBtnActive : ""}`}
title={isPinned ? "取消置顶" : "置顶会话"}
aria-label={isPinned ? `取消置顶 ${title}` : `置顶 ${title}`}
onClick={(e) => {
e.stopPropagation();
void toggleSessionPin(s.path, !isPinned);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 17v5" />
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
</svg>
</button>
<button
type="button"
className={styles.renameBtn}
title="重命名会话"
aria-label={`重命名 ${title}`}
onClick={(e) => {
e.stopPropagation();
if (isEditing) {
cancelEditing();
} else {
startEditing(s.path, s.name || title);
}
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
<button
type="button"
className={styles.deleteBtn}
title="删除会话"
aria-label={`删除 ${title}`}
onClick={(e) => {
e.stopPropagation();
void deleteSession(s.path);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,66 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import * as settingsApi from "../api/settings";
import type { AvatarSettings } from "../types/events";
interface AvatarContextValue extends AvatarSettings {
loaded: boolean;
refreshAvatars: () => Promise<void>;
updateAvatars: (next: AvatarSettings) => void;
}
const AvatarContext = createContext<AvatarContextValue | null>(null);
export function AvatarProvider({ children }: { children: ReactNode }) {
const [userAvatarUrl, setUserAvatarUrl] = useState("");
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
const [loaded, setLoaded] = useState(false);
const refreshAvatars = useCallback(async () => {
try {
const data = await settingsApi.fetchAvatars();
setUserAvatarUrl(data.userAvatarUrl || "");
setAgentAvatarUrl(data.agentAvatarUrl || "");
} catch {
/* ignore */
} finally {
setLoaded(true);
}
}, []);
const updateAvatars = useCallback((next: AvatarSettings) => {
setUserAvatarUrl(next.userAvatarUrl || "");
setAgentAvatarUrl(next.agentAvatarUrl || "");
setLoaded(true);
}, []);
useEffect(() => {
void refreshAvatars();
}, [refreshAvatars]);
const value = useMemo(
(): AvatarContextValue => ({
userAvatarUrl,
agentAvatarUrl,
loaded,
refreshAvatars,
updateAvatars,
}),
[userAvatarUrl, agentAvatarUrl, loaded, refreshAvatars, updateAvatars],
);
return <AvatarContext.Provider value={value}>{children}</AvatarContext.Provider>;
}
export function useAvatars(): AvatarContextValue {
const ctx = useContext(AvatarContext);
if (!ctx) throw new Error("useAvatars must be used within AvatarProvider");
return ctx;
}

View File

@@ -1,89 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useLocation } from "react-router-dom";
import { SplashScreen } from "../components/pwa/SplashScreen";
const MIN_SPLASH_MS = 400;
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
type BootstrapRoute = "chat" | "settings";
interface BootstrapContextValue {
markRouteReady: (route: BootstrapRoute) => void;
}
const BootstrapContext = createContext<BootstrapContextValue | null>(null);
export function BootstrapProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
chat: false,
settings: false,
});
const [splashVisible, setSplashVisible] = useState(true);
const [splashExiting, setSplashExiting] = useState(false);
const mountTime = useRef(Date.now());
const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
}, []);
useEffect(() => {
setReadyRoutes((prev) => ({ ...prev, [route]: false }));
setSplashVisible(true);
setSplashExiting(false);
mountTime.current = Date.now();
}, [route]);
useEffect(() => {
if (!readyRoutes[route]) return;
let warmBoot = false;
try {
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
} catch {
/* ignore */
}
if (warmBoot) {
setSplashVisible(false);
setSplashExiting(false);
return;
}
const elapsed = Date.now() - mountTime.current;
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
const timer = window.setTimeout(() => {
setSplashExiting(true);
window.setTimeout(() => setSplashVisible(false), 420);
}, remaining);
return () => window.clearTimeout(timer);
}, [readyRoutes, route]);
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
return (
<BootstrapContext.Provider value={value}>
{children}
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
</BootstrapContext.Provider>
);
}
export function useBootstrap(): BootstrapContextValue {
const ctx = useContext(BootstrapContext);
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
return ctx;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@mogeko/maple-mono-cn/dist/font/result.css";
import { App } from "./App";
import "./styles/global.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

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

View File

@@ -1,857 +0,0 @@
.page {
display: flex;
flex-direction: column;
min-height: var(--app-height);
height: var(--app-height);
overflow: hidden;
background: #fff;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
padding: 0;
}
.pageHeader {
flex-shrink: 0;
width: 100%;
margin: 0;
padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left));
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #eef0f3;
background: #fafbfc;
}
.pageTitle {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.logo {
width: 34px;
height: 34px;
object-fit: contain;
flex-shrink: 0;
}
.pageTitle h1 {
font-size: 18px;
font-weight: 700;
color: #111827;
line-height: 1.2;
}
.pageTitle p {
margin-top: 2px;
font-size: 12px;
color: #8b95a8;
}
.shell {
flex: 1;
min-height: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.layout {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
}
.sidebar {
flex-shrink: 0;
width: 220px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 12px;
padding-left: max(12px, env(safe-area-inset-left));
background: #f4f6f9;
border-right: 1px solid #e5e7eb;
}
.navBtn {
display: flex;
align-items: center;
width: 100%;
margin: 0;
padding: 11px 14px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: #4b5563;
text-align: left;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
touch-action: manipulation;
}
.navBtn:hover {
background: rgba(255, 255, 255, 0.72);
color: #111827;
}
.navBtnActive {
background: #fff;
color: #2563eb;
border-color: #e5e7eb;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.navBtn:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.panes {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 14px max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) 14px;
}
.pane {
flex: 1;
min-height: 0;
display: none;
flex-direction: column;
}
.paneActive {
display: flex;
}
.panel {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
min-width: 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.panelHeader {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
row-gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid #eef0f3;
background: #fafbfc;
}
.panelHeader h2 {
font-size: 14px;
line-height: 1.3;
font-weight: 600;
color: #111827;
}
.panelHeader p {
margin-top: 3px;
font-size: 11px;
color: #8b95a8;
word-break: break-all;
}
.primaryBtn,
.secondaryBtn,
.linkBtn {
height: 30px;
padding: 0 12px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
}
.linkBtn {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: #374151;
background: #fff;
border: 1px solid #d1d5db;
}
.linkBtn:hover {
background: #f3f4f6;
}
.primaryBtn {
color: #fff;
background: #2563eb;
border: 1px solid #2563eb;
}
.primaryBtn:hover {
background: #1d4ed8;
border-color: #1d4ed8;
}
.primaryBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.secondaryBtn {
color: #374151;
background: #fff;
border: 1px solid #d1d5db;
}
.secondaryBtn:hover {
background: #f3f4f6;
}
.textarea {
display: block;
width: 100%;
flex: 1 1 auto;
min-height: 0;
padding: 14px;
border: none;
outline: none;
resize: none;
color: #111827;
background: #fff;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.55;
white-space: pre;
overflow: auto;
}
.textarea:focus {
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
}
.formBody {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.fieldLabel {
font-size: 13px;
font-weight: 600;
color: #111827;
}
.textInput {
width: 100%;
height: 38px;
padding: 0 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #111827;
font-family: inherit;
font-size: 13px;
outline: none;
}
.textInput:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.fieldHint {
margin: 0;
font-size: 12px;
color: #8b95a8;
line-height: 1.5;
}
.avatarPreviewRow {
display: flex;
align-items: center;
gap: 10px;
}
.avatarPreview {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.previewHint {
font-size: 12px;
color: #9ca3af;
}
.status {
flex-shrink: 0;
min-height: 30px;
padding: 7px 14px 9px;
border-top: 1px solid #eef0f3;
color: #8b95a8;
font-size: 12px;
}
.statusOk {
color: #15803d;
}
.statusError {
color: #b91c1c;
}
.list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
.empty {
padding: 20px 10px;
color: #9ca3af;
font-size: 12px;
text-align: center;
}
.emptyCompact {
padding: 10px 4px 2px;
text-align: left;
}
.skillItem,
.mcpServerItem,
.extensionItem {
padding: 10px 10px 9px;
border: 1px solid #edf0f4;
border-radius: 8px;
background: #fff;
}
.skillItem + .skillItem,
.mcpServerItem + .mcpServerItem,
.extensionItem + .extensionItem {
margin-top: 7px;
}
.extensionGroup + .extensionGroup {
margin-top: 16px;
}
.extensionGroupHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.extensionGroupHeader h3 {
margin: 0;
color: #111827;
font-size: 13px;
font-weight: 600;
}
.extensionGroupHeader span {
color: #9ca3af;
font-size: 11px;
flex-shrink: 0;
}
.extensionGroupList {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
@media (max-width: 960px) {
.extensionGroupList {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.extensionGroupList {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.envGrid {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid #edf0f4;
border-radius: 8px;
overflow: hidden;
}
.envRow {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 14px;
border-bottom: 1px solid #f3f4f6;
}
.envRow:last-child {
border-bottom: none;
}
.envRow:nth-child(even) {
background: #fafbfc;
}
.envLabel {
flex-shrink: 0;
width: 100px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
}
.envValue {
flex: 1;
min-width: 0;
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
font-size: 12px;
color: #111827;
word-break: break-all;
background: none;
}
@media (max-width: 480px) {
.extensionGroupList {
grid-template-columns: 1fr;
}
}
.extensionHeader {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.extensionTitleRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.extensionItem .itemName {
flex: 1;
min-width: 0;
}
.extensionSubtitle {
color: #9ca3af;
font-size: 10px;
line-height: 1.35;
word-break: break-word;
}
.extensionGroupList .extensionItem + .extensionItem {
margin-top: 0;
}
.skillItemDisabled {
opacity: 0.72;
background: #fafbfc;
}
.skillActions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.skillToggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.skillToggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.skillToggleUi {
position: relative;
width: 38px;
height: 22px;
border-radius: 999px;
background: #cbd5e1;
transition: background 0.15s;
flex-shrink: 0;
}
.skillToggleUi::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
transition: transform 0.15s;
}
.skillToggle input:checked + .skillToggleUi {
background: #2563eb;
}
.skillToggle input:checked + .skillToggleUi::after {
transform: translateX(16px);
}
.skillToggle input:disabled + .skillToggleUi {
opacity: 0.55;
}
.skillToggleLabel {
font-size: 12px;
color: #64748b;
min-width: 42px;
}
.titleRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.itemName {
min-width: 0;
color: #111827;
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemMeta {
flex-shrink: 0;
color: #9ca3af;
font-size: 10px;
}
.itemDesc {
margin-top: 5px;
color: #4b5563;
font-size: 12px;
line-height: 1.45;
}
.itemPath {
margin-top: 6px;
color: #9ca3af;
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.4;
word-break: break-all;
}
.mcpToolList {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
@media (max-width: 960px) {
.mcpToolList {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.mcpToolList {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.mcpToolList {
grid-template-columns: 1fr;
}
}
.mcpServerHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.mcpServerMain {
min-width: 0;
}
.mcpServerSub {
margin-top: 3px;
color: #9ca3af;
font-size: 10px;
}
.mcpServerBadges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 5px;
flex-shrink: 0;
}
.badge {
display: inline-flex;
align-items: center;
height: 19px;
padding: 0 6px;
border-radius: 999px;
background: #f3f4f6;
color: #6b7280;
font-size: 10px;
line-height: 1;
}
.mcpToolItem {
min-width: 0;
padding: 7px 8px;
border: 1px solid #f1f3f6;
border-radius: 7px;
background: #fbfcfe;
}
.mcpToolItem[open] {
background: #fff;
}
.mcpToolSummary {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
cursor: pointer;
list-style: none;
}
.mcpToolSummaryActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-shrink: 0;
}
.mcpToolItemDisabled {
opacity: 0.72;
background: #fafbfc;
}
.mcpToolSummary::-webkit-details-marker {
display: none;
}
.mcpToolName {
color: #111827;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
line-height: 1.35;
overflow-wrap: anywhere;
word-break: break-word;
}
.mcpToolDesc {
margin-top: 6px;
color: #6b7280;
font-size: 11px;
line-height: 1.4;
}
.mcpParamList {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
}
.mcpParamList code {
padding: 2px 6px;
border-radius: 5px;
background: #fff;
border: 1px solid #edf0f4;
color: #374151;
font-family: var(--font-mono);
font-size: 10px;
word-break: break-all;
}
.extensionCounts {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 7px;
}
.extensionCounts span {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
font-size: 11px;
line-height: 1;
}
.extensionDetails {
margin-top: 8px;
border-top: 1px solid #f1f3f6;
padding-top: 7px;
}
.extensionDetails > summary {
cursor: pointer;
color: #6b7280;
font-size: 11px;
user-select: none;
}
.extensionDetails > summary:hover {
color: #2563eb;
}
.nameList {
margin-top: 8px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 8px;
align-items: start;
font-size: 11px;
color: #8b95a8;
}
.nameList > div {
display: flex;
flex-wrap: wrap;
gap: 5px;
min-width: 0;
}
.nameList code {
padding: 2px 6px;
border-radius: 5px;
background: #f8fafc;
border: 1px solid #edf0f4;
color: #374151;
font-family: var(--font-mono);
font-size: 10px;
word-break: break-all;
}
@media (max-width: 768px) {
.pageHeader {
align-items: flex-start;
padding-top: max(12px, env(safe-area-inset-top));
padding-bottom: 12px;
padding-left: max(12px, env(safe-area-inset-left));
padding-right: max(12px, env(safe-area-inset-right));
}
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
flex-direction: row;
align-items: stretch;
gap: 8px;
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
border-right: none;
border-bottom: 1px solid #e5e7eb;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.sidebar::-webkit-scrollbar {
height: 0;
width: 0;
}
.navBtn {
flex: 1 1 0;
min-width: 0;
justify-content: center;
text-align: center;
padding: 12px 8px;
font-size: 13px;
}
.panes {
flex: 1;
min-height: 0;
padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
}
.textarea {
font-size: 12px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
@import "./variables.css";
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
font-family: var(--font-family);
letter-spacing: var(--font-letter-spacing);
}
body {
font-family: inherit;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.06), transparent 28%),
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
color: #1a1a1a;
height: var(--app-height);
overflow: hidden;
}
button,
input,
textarea,
select,
optgroup {
font-family: inherit;
}
[hidden] {
display: none !important;
}
code,
kbd,
samp,
pre {
font-family: inherit;
}
.hljs,
pre code.hljs,
code.hljs {
font-family: inherit !important;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}

View File

@@ -1,26 +0,0 @@
:root {
--app-height: 100vh;
--font-family: "Maple Mono CN", ui-monospace, monospace;
--font-letter-spacing: -0.05em;
--font-ui: var(--font-family);
--font-mono: var(--font-family);
--text-primary: #111827;
--text-secondary: #4b5563;
--text-tertiary: #9ca3af;
--surface-muted: #f3f4f6;
--header-bg: rgba(255, 255, 255, 0.9);
--header-border: rgba(15, 23, 42, 0.06);
--header-shadow: 0 1px 0 rgba(15, 23, 42, 0.04), 0 10px 30px rgba(15, 23, 42, 0.04);
--toolbar-bg: linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(241, 245, 249, 0.96) 100%);
--toolbar-border: rgba(15, 23, 42, 0.07);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes blink {
50% { opacity: 0; }
}

View File

@@ -1,11 +0,0 @@
export type SlashCommandSource = "builtin" | "extension" | "prompt" | "skill";
export interface SlashCommand {
name: string;
description?: string;
source: SlashCommandSource;
}
export interface SlashCommandsResponse {
commands: SlashCommand[];
}

Some files were not shown because too many files have changed in this diff Show More