feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
Some checks failed
CI / build-check-test (push) Has been cancelled
Restructure local extensions into per-feature directories, split WebUI into backend modules with slash commands and systemd support, and track prompts/skills under .pi/agent for portable Gitea deployment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -41,15 +41,26 @@ plans/
|
||||
.pi/sessions/
|
||||
.pi/extensions/webui/.webui.pid
|
||||
.pi/extensions/webui/.webui.log
|
||||
# Local runtime / secrets (do not commit)
|
||||
.local/
|
||||
progress.md
|
||||
|
||||
# Pi agent: track extensions, ignore local runtime secrets and sessions
|
||||
.pi/agent/sessions/
|
||||
.pi/agent/git/
|
||||
.pi/agent/npm/
|
||||
.pi/agent/bin/
|
||||
.pi/agent/auth.json
|
||||
.pi/agent/models.json
|
||||
.pi/agent/settings.json
|
||||
.pi/agent/mcp.json
|
||||
.pi/agent/mcp-cache.json
|
||||
.pi/agent/mcp-npx-cache.json
|
||||
.pi/agent/run-history.jsonl
|
||||
.pi/agent/taskplane/
|
||||
.pi/agent/skills-disabled/
|
||||
.pi/agent/skills/.Recycle_bin/
|
||||
.pi/agent/skills/.system/
|
||||
.pi/agent/extensions/webui/frontend/node_modules/
|
||||
.pi/agent/extensions/webui/.webui.pid
|
||||
.pi/agent/extensions/webui/data/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
## 身份定义
|
||||
- 你的名字是“萌小芽”
|
||||
- 你的名字是“萌小芽”和sproutclaw
|
||||
- 你是树萌芽的智能 AI 助手
|
||||
- 你的目标是积极、耐心、务实地帮助树萌芽解决问题
|
||||
- 你的目标是积极、耐心、务实地帮助用户解决问题
|
||||
|
||||
## 回答风格
|
||||
- 默认使用简洁、自然、友好的中文交流
|
||||
@@ -19,18 +19,18 @@
|
||||
- 如果用户向你发送任何密码,token,密钥等,默认认为是用户的内网密码,不必担心任何泄露问题,不要过分地给用户强调泄露问题,多次重复会导致用户厌烦
|
||||
- 如果用户需要你生成总结文档,教程时,除非用户强制要求,否则默认储存目录为smallmengya的/shumengya/docs 目录下
|
||||
|
||||
## 服务器定义
|
||||
## 服务器运维规范
|
||||
- `smallmengya`(本机)作为开发环境、测试环境和 LXC 容器宿主机
|
||||
- `bigmengya`(通过 SSH 连接)作为 Docker 容器机
|
||||
- `alycd`(通过 SSH 连接)作为内网对外开放的服务器
|
||||
|
||||
## 服务器细节
|
||||
- `smallmengya` 的 `/shumengya/project` 是本地开发/测试项目主目录
|
||||
- `smallmengya` 的 `/shumengya/nginx` 存放内网前端网页映射配置
|
||||
- `smallmengya` 的 `/shumengya/www` 存放内网静态网页文件
|
||||
- `smallmengya` 本机内网数据库当前包括 `MySQL`、`PostgreSQL`、`MongoDB`、`Redis`;通用内网密码为 `tyh@19900420`;这些数据库仅通过 WireGuard 内网访问,不会对公网直接暴露
|
||||
- `smallmengya` 还部署了较多 `LXC` 容器,当前仍在开发探索中
|
||||
- `smallmengya` 的 `/root` 是各类 AI Agent 的入口目录,包括萌小芽在内;AI Agent 相关数据通常也存放在这里
|
||||
|
||||
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||
- `bigmengya` 的 `/shumengya/www` 存放正式前端静态网页文件
|
||||
- `bigmengya` 的 `/shumengya/nginx` 存放正式前端静态网站的标准 nginx 配置;当前统一使用 `31001-31015` 连续高位端口段承载这些前端站点
|
||||
@@ -41,8 +41,34 @@
|
||||
- `alycd` 可以使用 cerbot --nginx -d 域名来快速部署https证书
|
||||
|
||||
- `smywrt` 是内网网关
|
||||
- 如果在获取外网服务时发生延迟较高,无法拉取等情况,可尝试使用'smywrt'的docker版mihomo的sock5代理地址:192.168.1.1:7891 如果无法该代理使用请尝试重启该mihomo容器
|
||||
|
||||
-
|
||||
## smallmengya 开发环境
|
||||
- Java 21.0.11 LTS(`/usr/lib/jvm/jdk-21.0.11+10`,`JAVA_HOME` 已写入 `/etc/profile.d/java.sh`)
|
||||
- Go 1.26.3(`/usr/local/go/bin/go`,`GOPROXY=https://goproxy.cn,direct`)
|
||||
- Node.js 26.2.0(nvm 管理,默认版本已设为 26)
|
||||
- Bun 1.3.14(`~/.bun/bin/bun`)
|
||||
- Rust 1.96.0(`~/.cargo/bin/rustc`,新终端自动加载 `~/.cargo/env`)
|
||||
- Python 3.14.5(`/usr/local/python3.14/bin/python3`,`python3` 默认指向此版本)
|
||||
- 本机 Go 代理不可用时,使用smywrt mihomo `socks5://192.168.1.1:17891` http代理:192.168.1.1:17892 mixed代理:192.168.1.1:17890
|
||||
|
||||
## 网络代理规则(克隆与依赖下载)
|
||||
- 当用户要求 `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 提供)
|
||||
- 操作完成后如非必要应及时取消代理,避免影响内网服务访问
|
||||
|
||||
## SproutClaw 项目规范
|
||||
- 当用户要求安装 npm 扩展时,默认使用 `pi install npm:<包名>` 命令安装
|
||||
- 安装目录为 `/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/`
|
||||
- 不要使用 `npm install --save-dev` 安装 pi 扩展,避免冗余安装到根 `node_modules`
|
||||
|
||||
## 服务器Docker部署规则
|
||||
- 部署环境:如果用户未说明则默认部署在`bigmengya` 服务器,路径 `/shumengya/docker`
|
||||
- 部署方式:使用 `docker compose`,不得破坏现有容器配置
|
||||
@@ -50,6 +76,17 @@
|
||||
- 资源限制:所有容器统一设置内存限制 `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 为:`O(≧口≦)O` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
|
||||
- 在合适且自然的场景下,可少量使用这些 Emoji,避免过度堆叠
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
|
||||
* 方便快速启动 sproutclaw(cd 到项目目录并执行 ./pi-test.sh)。
|
||||
*
|
||||
* 命令 /install-commands 可随时手动重装。
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
|
||||
const BIN_DIR = "/usr/local/bin";
|
||||
const COMMANDS = ["mengya", "sproutclaw"];
|
||||
|
||||
/** 创建或修复命令脚本 */
|
||||
function installCommand(name: string): boolean {
|
||||
const path = `${BIN_DIR}/${name}`;
|
||||
|
||||
const script = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
exec ./pi-test.sh "$@"
|
||||
`;
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const current = readFileSync(path, "utf-8");
|
||||
if (current === script) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable or invalid command files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, script, "utf-8");
|
||||
chmodSync(path, 0o755);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// 启动时自动安装
|
||||
for (const name of COMMANDS) {
|
||||
const created = installCommand(name);
|
||||
if (created) {
|
||||
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 命令:手动重装
|
||||
pi.registerCommand("install-commands", {
|
||||
description: "Install mengya and sproutclaw commands to /usr/local/bin",
|
||||
handler: async (_args, ctx) => {
|
||||
let count = 0;
|
||||
for (const name of COMMANDS) {
|
||||
if (installCommand(name)) count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success");
|
||||
} else {
|
||||
ctx.ui.notify("Both commands already exist", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
107
.pi/agent/extensions/sproutclaw-setup/index.ts
Normal file
107
.pi/agent/extensions/sproutclaw-setup/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令:
|
||||
* - mengya:源码版(./pi-test.sh,tsx + src)
|
||||
* - sproutclaw:构建版(./pi-built.sh,node dist/cli.js)
|
||||
*
|
||||
* 命令 /install-commands 可随时手动重装。
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
|
||||
const BIN_DIR = "/usr/local/bin";
|
||||
|
||||
const COMMAND_LAUNCHERS: Record<string, string> = {
|
||||
mengya: "./pi-test.sh",
|
||||
};
|
||||
|
||||
function commandScript(launcher: string): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
exec ${launcher} "$@"
|
||||
`;
|
||||
}
|
||||
|
||||
function sproutclawScript(): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
|
||||
case "\${1:-}" in
|
||||
\tbuild)
|
||||
\t\tshift
|
||||
\t\texec npm run build "\$@"
|
||||
\t\t;;
|
||||
\trun)
|
||||
\t\tshift
|
||||
\t\texec ./pi-built.sh "\$@"
|
||||
\t\t;;
|
||||
\thelp|-h|--help)
|
||||
\t\tcat <<'EOF'
|
||||
sproutclaw - SproutClaw pi 构建版启动器
|
||||
|
||||
用法:
|
||||
sproutclaw 启动 pi(构建版)
|
||||
sproutclaw run [args] 同 sproutclaw
|
||||
sproutclaw build 从源码构建所有包
|
||||
|
||||
其余参数透传给 pi,例如: sproutclaw --version
|
||||
EOF
|
||||
\t\t;;
|
||||
\t*)
|
||||
\t\texec ./pi-built.sh "\$@"
|
||||
\t\t;;
|
||||
esac
|
||||
`;
|
||||
}
|
||||
|
||||
/** 创建或修复命令脚本 */
|
||||
function installCommand(name: string, script: string): boolean {
|
||||
const path = `${BIN_DIR}/${name}`;
|
||||
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const current = readFileSync(path, "utf-8");
|
||||
if (current === script) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable or invalid command files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, script, "utf-8");
|
||||
chmodSync(path, 0o755);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
for (const [name, launcher] of Object.entries(COMMAND_LAUNCHERS)) {
|
||||
const created = installCommand(name, commandScript(launcher));
|
||||
if (created) {
|
||||
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sproutclawCreated = installCommand("sproutclaw", sproutclawScript());
|
||||
if (sproutclawCreated) {
|
||||
console.log("[sproutclaw-setup] Created /usr/local/bin/sproutclaw");
|
||||
}
|
||||
|
||||
pi.registerCommand("install-commands", {
|
||||
description: "Install mengya (source) and sproutclaw (built) commands to /usr/local/bin",
|
||||
handler: async (_args, ctx) => {
|
||||
let count = 0;
|
||||
for (const [name, launcher] of Object.entries(COMMAND_LAUNCHERS)) {
|
||||
if (installCommand(name, commandScript(launcher))) count++;
|
||||
}
|
||||
if (installCommand("sproutclaw", sproutclawScript())) count++;
|
||||
if (count > 0) {
|
||||
ctx.ui.notify(`Installed ${count} command(s): mengya (source), sproutclaw (build/run)`, "success");
|
||||
} else {
|
||||
ctx.ui.notify("Commands already up to date", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,10 @@ function keyTextOr(action: string, fallback: string): string {
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function renderDivider(theme: { fg: (color: string, text: string) => string }, width: number): string {
|
||||
return theme.fg("dim", "─".repeat(Math.max(1, width)));
|
||||
}
|
||||
|
||||
function renderSproutclawLogo(theme: any, width: number): string[] {
|
||||
if (width < 96) {
|
||||
return [
|
||||
@@ -111,13 +115,6 @@ export default function (pi: ExtensionAPI) {
|
||||
keyHint("app.clipboard.pasteImage", "粘贴图片"),
|
||||
rawKeyHint("drop files", "附加文件"),
|
||||
].join("\n");
|
||||
const compactInstructions = [
|
||||
keyHint("app.interrupt", "中断"),
|
||||
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "清空/退出"),
|
||||
rawKeyHint("/", "命令"),
|
||||
rawKeyHint("!", "shell"),
|
||||
rawKeyHint(keyTextOr("app.tools.expand", "Ctrl+O"), "详情"),
|
||||
].join(rgb(" • ", [165, 205, 255]));
|
||||
const compactOnboarding = startupLine(
|
||||
"工作台",
|
||||
`描述要处理的代码、部署或服务器问题,我会执行命令、修改文件并同步进度。按 ${keyTextOr("app.tools.expand", "Ctrl+O")} 展开详情。`,
|
||||
@@ -132,8 +129,11 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const divider = renderDivider(theme, width);
|
||||
const lines: string[] = [
|
||||
divider,
|
||||
...renderSproutclawLogo(theme, width),
|
||||
divider,
|
||||
rgb("萌芽运维开发 Agent 助手", [170, 235, 255]),
|
||||
"",
|
||||
];
|
||||
@@ -142,7 +142,6 @@ export default function (pi: ExtensionAPI) {
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
} else {
|
||||
lines.push(compactInstructions);
|
||||
lines.push(compactOnboarding);
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
6
.pi/agent/extensions/webui/.gitignore
vendored
6
.pi/agent/extensions/webui/.gitignore
vendored
@@ -1,4 +1,8 @@
|
||||
dist/
|
||||
frontend/dist/
|
||||
frontend/dist-desketop/
|
||||
frontend/.env.desktop
|
||||
data/
|
||||
webui-service.env
|
||||
*.db
|
||||
webui-settings.json.migrated
|
||||
.webui.pid
|
||||
|
||||
9
.pi/agent/extensions/webui/backend/config/cli.ts
Normal file
9
.pi/agent/extensions/webui/backend/config/cli.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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;
|
||||
}
|
||||
56
.pi/agent/extensions/webui/backend/config/paths.ts
Normal file
56
.pi/agent/extensions/webui/backend/config/paths.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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 function resolvePiRpcLaunch(paths: WebUiPaths): { command: string; args: string[]; mode: "dist" } {
|
||||
const rpcArgs = ["--mode", "rpc"];
|
||||
if (!existsSync(paths.piCliDist)) {
|
||||
throw new Error(
|
||||
`[webui] 构建版 sproutclaw 未找到: ${paths.piCliDist}。请先运行: sproutclaw build`,
|
||||
);
|
||||
}
|
||||
return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
|
||||
}
|
||||
16
.pi/agent/extensions/webui/backend/http/cors.ts
Normal file
16
.pi/agent/extensions/webui/backend/http/cors.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
23
.pi/agent/extensions/webui/backend/http/request.ts
Normal file
23
.pi/agent/extensions/webui/backend/http/request.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
36
.pi/agent/extensions/webui/backend/http/server.ts
Normal file
36
.pi/agent/extensions/webui/backend/http/server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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 { 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 (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");
|
||||
});
|
||||
}
|
||||
83
.pi/agent/extensions/webui/backend/http/static.ts
Normal file
83
.pi/agent/extensions/webui/backend/http/static.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import type { ServerResponse, IncomingMessage } from "node:http";
|
||||
|
||||
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);
|
||||
if (!full.startsWith(publicDir)) {
|
||||
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);
|
||||
}
|
||||
83
.pi/agent/extensions/webui/backend/main.ts
Normal file
83
.pi/agent/extensions/webui/backend/main.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/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 { 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}`);
|
||||
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
writePidFile();
|
||||
|
||||
const piClient = createPiClient(paths, () => shutdown(1));
|
||||
|
||||
const ctx: WebUiContext = {
|
||||
config: { paths, port },
|
||||
rpc: {
|
||||
sendCmd: piClient.sendCmd,
|
||||
submitPrompt: piClient.submitPrompt,
|
||||
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://smallmengya:${port}`);
|
||||
});
|
||||
76
.pi/agent/extensions/webui/backend/routes/chat.ts
Normal file
76
.pi/agent/extensions/webui/backend/routes/chat.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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";
|
||||
|
||||
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 }) => {
|
||||
const msg = typeof message === "string" ? message : "";
|
||||
const imgs = normalizeChatImages(images);
|
||||
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
|
||||
if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
|
||||
|
||||
if (!imgs?.length && msg.trim().startsWith("/")) {
|
||||
const slash = await dispatchSlashCommand(msg, sendCmd);
|
||||
if (slash?.handled) {
|
||||
return json(res, { ok: true, slash: true, ...slash });
|
||||
}
|
||||
}
|
||||
|
||||
submitPrompt({ message: msg, images: imgs });
|
||||
return json(res, { ok: true, accepted: true }, 202);
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/bash") {
|
||||
void readBody(req)
|
||||
.then(async ({ command }) => {
|
||||
const cmd = typeof command === "string" ? command.trim() : "";
|
||||
if (!cmd) throw new Error("命令不能为空");
|
||||
const result = await sendCmd({ type: "bash", command: cmd });
|
||||
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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
19
.pi/agent/extensions/webui/backend/routes/commands.ts
Normal file
19
.pi/agent/extensions/webui/backend/routes/commands.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
}
|
||||
43
.pi/agent/extensions/webui/backend/routes/models.ts
Normal file
43
.pi/agent/extensions/webui/backend/routes/models.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
}
|
||||
124
.pi/agent/extensions/webui/backend/routes/sessions.ts
Normal file
124
.pi/agent/extensions/webui/backend/routes/sessions.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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 { 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 === 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;
|
||||
}
|
||||
206
.pi/agent/extensions/webui/backend/routes/settings.ts
Normal file
206
.pi/agent/extensions/webui/backend/routes/settings.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
70
.pi/agent/extensions/webui/backend/routes/webui-config.ts
Normal file
70
.pi/agent/extensions/webui/backend/routes/webui-config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
}
|
||||
203
.pi/agent/extensions/webui/backend/rpc/pi-client.ts
Normal file
203
.pi/agent/extensions/webui/backend/rpc/pi-client.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
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",
|
||||
]);
|
||||
|
||||
const DEFAULT_CMD_TIMEOUT_MS = 60_000;
|
||||
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
|
||||
const piLaunch = resolvePiRpcLaunch(paths);
|
||||
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
|
||||
|
||||
const pi = spawn(piLaunch.command, piLaunch.args, {
|
||||
cwd: paths.repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: paths.agentDir,
|
||||
},
|
||||
});
|
||||
|
||||
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
|
||||
pi.on("exit", (code) => {
|
||||
console.log(`[webui] pi 退出, code=${code}`);
|
||||
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 line =
|
||||
JSON.stringify({
|
||||
type: "prompt",
|
||||
message: options.message,
|
||||
images: options.images,
|
||||
id,
|
||||
}) + "\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 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,
|
||||
getRunSnapshot,
|
||||
connectSseClient,
|
||||
removeSseClient: (res) => sseClients.delete(res),
|
||||
child: pi,
|
||||
};
|
||||
}
|
||||
32
.pi/agent/extensions/webui/backend/services/avatars.ts
Normal file
32
.pi/agent/extensions/webui/backend/services/avatars.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
24
.pi/agent/extensions/webui/backend/services/chat-images.ts
Normal file
24
.pi/agent/extensions/webui/backend/services/chat-images.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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);
|
||||
}
|
||||
24
.pi/agent/extensions/webui/backend/services/models-config.ts
Normal file
24
.pi/agent/extensions/webui/backend/services/models-config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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");
|
||||
}
|
||||
250
.pi/agent/extensions/webui/backend/services/sessions.ts
Normal file
250
.pi/agent/extensions/webui/backend/services/sessions.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
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 { 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 {
|
||||
const resolved = resolve(filePath);
|
||||
const sessionsRoot = resolve(paths.sessionsDir);
|
||||
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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));
|
||||
}
|
||||
13
.pi/agent/extensions/webui/backend/services/system-prompt.ts
Normal file
13
.pi/agent/extensions/webui/backend/services/system-prompt.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
}
|
||||
102
.pi/agent/extensions/webui/backend/settings/extensions-paths.ts
Normal file
102
.pi/agent/extensions/webui/backend/settings/extensions-paths.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
245
.pi/agent/extensions/webui/backend/settings/mcp-settings.ts
Normal file
245
.pi/agent/extensions/webui/backend/settings/mcp-settings.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
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;
|
||||
}
|
||||
452
.pi/agent/extensions/webui/backend/settings/skills-settings.ts
Normal file
452
.pi/agent/extensions/webui/backend/settings/skills-settings.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
246
.pi/agent/extensions/webui/backend/slash/dispatch.ts
Normal file
246
.pi/agent/extensions/webui/backend/slash/dispatch.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
31
.pi/agent/extensions/webui/backend/types/context.ts
Normal file
31
.pi/agent/extensions/webui/backend/types/context.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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 }>;
|
||||
}
|
||||
|
||||
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;
|
||||
getRunSnapshot: () => RunSnapshot;
|
||||
connectSseClient: (res: ServerResponse) => void;
|
||||
removeSseClient: (res: ServerResponse) => void;
|
||||
};
|
||||
db: WebuiDbInfo;
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
node_modules
|
||||
dist/
|
||||
dist-desketop/
|
||||
|
||||
@@ -12,15 +12,6 @@
|
||||
<title>萌小芽</title>
|
||||
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-regular.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-bold.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"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",
|
||||
@@ -72,7 +73,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2630,6 +2630,13 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mogeko/maple-mono-cn": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@mogeko/maple-mono-cn/-/maple-mono-cn-7.9.0.tgz",
|
||||
"integrity": "sha512-NZls63+8Q4+17saZqhN5sFB9UqIfyI73q+NNyXNQFmdzivzSYAKLCuo2yvheyEm95kuH8VEhwTl4YvbMoqIbFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3181,7 +3188,6 @@
|
||||
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3250,7 +3256,6 @@
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -3442,7 +3447,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -5292,7 +5296,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -5302,7 +5305,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -6072,7 +6074,6 @@
|
||||
"integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -6360,7 +6361,6 @@
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"generate-icons": "node scripts/generate-icons.mjs",
|
||||
"dev": "vite",
|
||||
"build": "npm run generate-icons && tsc -b && vite build",
|
||||
"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": {
|
||||
@@ -17,6 +19,7 @@
|
||||
"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",
|
||||
|
||||
@@ -12,20 +12,15 @@ export function App() {
|
||||
<BrowserRouter>
|
||||
<BootstrapProvider>
|
||||
<AvatarProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ChatProvider>
|
||||
<ChatLayout />
|
||||
</ChatProvider>
|
||||
}
|
||||
>
|
||||
<Route index element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<PwaUpdatePrompt />
|
||||
<ChatProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<ChatLayout />}>
|
||||
<Route index element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<PwaUpdatePrompt />
|
||||
</ChatProvider>
|
||||
</AvatarProvider>
|
||||
</BootstrapProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
13
.pi/agent/extensions/webui/frontend/src/api/base.ts
Normal file
13
.pi/agent/extensions/webui/frontend/src/api/base.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +1,26 @@
|
||||
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 function sendChat(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||
export interface ChatResponse {
|
||||
ok?: boolean;
|
||||
accepted?: boolean;
|
||||
slash?: boolean;
|
||||
message?: string;
|
||||
action?: "new_session" | "reload_messages" | "reload_sessions" | "copy";
|
||||
sessionFile?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function sendChat(message: string, images?: ImageContent[]): Promise<ChatResponse> {
|
||||
return apiPost("/api/chat", { message, images });
|
||||
}
|
||||
|
||||
export function runBash(command: string): Promise<BashResult> {
|
||||
return apiPost("/api/bash", { command });
|
||||
}
|
||||
|
||||
export function abortChat(): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/abort");
|
||||
}
|
||||
@@ -25,6 +41,6 @@ export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/thinking", { level });
|
||||
}
|
||||
|
||||
export function createNewSession(): Promise<{ sessionFile?: string }> {
|
||||
export function createNewSession(): Promise<NewSessionResponse> {
|
||||
return apiPost("/api/new-session");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiUrl } from "./base";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -9,7 +11,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
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);
|
||||
|
||||
6
.pi/agent/extensions/webui/frontend/src/api/commands.ts
Normal file
6
.pi/agent/extensions/webui/frontend/src/api/commands.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { apiGet } from "./client";
|
||||
import type { SlashCommandsResponse } from "../types/commands";
|
||||
|
||||
export function fetchSlashCommands(): Promise<SlashCommandsResponse> {
|
||||
return apiGet("/api/commands");
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
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[] }> {
|
||||
@@ -9,21 +11,26 @@ export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload
|
||||
return apiPost("/api/sessions/history", { path });
|
||||
}
|
||||
|
||||
export async function activateSessionBackend(path: string): Promise<void> {
|
||||
const activateRes = await fetch("/api/sessions/activate", {
|
||||
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 };
|
||||
if (activateRes.ok && !activateData.error) return;
|
||||
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("/api/sessions/load", {
|
||||
const fallbackRes = await fetch(apiUrl("/api/sessions/load"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
@@ -32,6 +39,7 @@ export async function activateSessionBackend(path: string): Promise<void> {
|
||||
if (!fallbackRes.ok || fallbackData.error) {
|
||||
throw new Error(fallbackData.error || fallbackRes.statusText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { AvatarSettings, SettingsData } from "../types/events";
|
||||
import type { AvatarSettings, 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");
|
||||
}
|
||||
@@ -17,6 +43,10 @@ export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPa
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
.block {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import styles from "./BashOutputBlock.module.css";
|
||||
|
||||
interface BashOutputBlockProps {
|
||||
command?: string;
|
||||
content: string;
|
||||
exitCode?: number;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export function BashOutputBlock({ command, content, exitCode, streaming }: BashOutputBlockProps) {
|
||||
const hasOutput = Boolean(content.trim());
|
||||
const showExit = exitCode !== undefined && exitCode !== 0 && !streaming;
|
||||
|
||||
return (
|
||||
<div className={styles.block}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
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 {
|
||||
@@ -8,18 +10,72 @@ import {
|
||||
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, addSystemMessage } = useChatContext();
|
||||
const { isStreaming, sendMessage, runBashCommand, addSystemMessage } = 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();
|
||||
|
||||
const canSend = (value.trim().length > 0 || pendingImages.length > 0) && !isStreaming;
|
||||
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]);
|
||||
|
||||
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 isBashInput = Boolean(value.trim() && parseBashInput(value));
|
||||
const isSlashInput = value.trimStart().startsWith("/");
|
||||
const canSendDuringStream = isBashInput || isSlashInput;
|
||||
const canSend =
|
||||
(value.trim().length > 0 || pendingImages.length > 0) && (!isStreaming || canSendDuringStream);
|
||||
|
||||
const 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);
|
||||
@@ -50,18 +106,62 @@ export function ChatInput() {
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = value.trim();
|
||||
if ((!text && pendingImages.length === 0) || isStreaming) return;
|
||||
const isBash = Boolean(text && parseBashInput(text));
|
||||
if ((!text && pendingImages.length === 0) || (isStreaming && !isBash && !text.startsWith("/"))) return;
|
||||
|
||||
const images = pendingImages.length ? pendingImages : undefined;
|
||||
setValue("");
|
||||
setPendingImages([]);
|
||||
if (inputRef.current) inputRef.current.style.height = "auto";
|
||||
await sendMessage(text, images);
|
||||
|
||||
if (text.startsWith("!") && parseBashInput(text)) {
|
||||
await runBashCommand(text);
|
||||
} else {
|
||||
await sendMessage(text, images);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -161,6 +261,13 @@ export function ChatInput() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{slashMenuOpen ? (
|
||||
<SlashCommandMenu
|
||||
commands={filteredSlashCommands}
|
||||
selectedIndex={selectedSlashIndex}
|
||||
onSelect={applySlashSelection}
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.wrapper}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -195,7 +302,7 @@ export function ChatInput() {
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
placeholder="输入消息,Ctrl+V 粘贴图片..."
|
||||
placeholder="输入消息…"
|
||||
aria-label="消息输入框"
|
||||
enterKeyHint="send"
|
||||
inputMode="text"
|
||||
|
||||
@@ -4,69 +4,76 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
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: #9ca3af;
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
background: #f9fafb;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.btnIcon {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 148px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||
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: 8px 10px;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
}
|
||||
|
||||
.menuItemDesc {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchSessionHistory } from "../../api/sessions";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import type { ChatMessage } from "../../types/message";
|
||||
import { extractContent, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||
import type { ChatMessage, ToolCallBlock } from "../../types/message";
|
||||
import { extractContent, extractThinking, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||
import { nextMessageId } from "../../utils/format";
|
||||
import {
|
||||
downloadTextFile,
|
||||
@@ -20,23 +20,52 @@ function historyToExportMessages(
|
||||
if (m.role === "user") {
|
||||
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
|
||||
} else if (m.role === "assistant") {
|
||||
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,
|
||||
});
|
||||
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() {
|
||||
export function ExportMenu({ compact = false }: { compact?: boolean }) {
|
||||
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -106,16 +135,17 @@ export function ExportMenu() {
|
||||
<div className={styles.wrap} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btn}
|
||||
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">
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { renderMarkdown } from "../../utils/markdown";
|
||||
import { imageToDataUrl } from "../../utils/images";
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { ThinkingBlock } from "./ThinkingBlock";
|
||||
import { BashOutputBlock } from "./BashOutputBlock";
|
||||
import styles from "./MessageList.module.css";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -81,6 +83,21 @@ export function MessageBubble({
|
||||
}: MessageBubbleProps) {
|
||||
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||
|
||||
if (message.role === "thinking") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
{showAgentAvatar ? (
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
) : agentAvatarSpacer ? (
|
||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
||||
) : null}
|
||||
<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}>
|
||||
@@ -96,6 +113,26 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "bash") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
{showAgentAvatar ? (
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
) : agentAvatarSpacer ? (
|
||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
||||
) : null}
|
||||
<div className={`${styles.msg} ${styles.bash}`}>
|
||||
<BashOutputBlock
|
||||
command={message.bashCommand}
|
||||
content={message.content}
|
||||
exitCode={message.bashExitCode}
|
||||
streaming={message.streaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classNames = [styles.msg];
|
||||
if (message.role === "user") classNames.push(styles.user);
|
||||
if (message.role === "assistant") classNames.push(styles.assistant);
|
||||
|
||||
@@ -201,15 +201,42 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
line-height: 1.46;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
margin: 0 0 4px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eee;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.thinking {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 1.46;
|
||||
padding: 0;
|
||||
margin: 0 0 4px;
|
||||
background: #faf8fc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ece6f5;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -375,10 +402,6 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistantAvatarSpacer {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
@@ -73,14 +73,18 @@ export function MessageList() {
|
||||
<div className={styles.chat} ref={chatRef}>
|
||||
{messages.map((msg, index) => {
|
||||
const prev = messages[index - 1];
|
||||
const prevIsAgentSide =
|
||||
prev?.role === "assistant" ||
|
||||
prev?.role === "thinking" ||
|
||||
prev?.role === "tool_call" ||
|
||||
prev?.role === "bash";
|
||||
const showAgentAvatar =
|
||||
msg.role === "assistant" ||
|
||||
(msg.role === "tool_call" &&
|
||||
prev?.role !== "tool_call" &&
|
||||
prev?.role !== "assistant");
|
||||
msg.role === "thinking" ||
|
||||
((msg.role === "tool_call" || msg.role === "bash") && !prevIsAgentSide);
|
||||
const agentAvatarSpacer =
|
||||
msg.role === "tool_call" &&
|
||||
(prev?.role === "tool_call" || prev?.role === "assistant");
|
||||
(msg.role === "tool_call" || msg.role === "bash" || msg.role === "thinking") &&
|
||||
prevIsAgentSide;
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
|
||||
@@ -1,61 +1,105 @@
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.42;
|
||||
color: #5c6578;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #8b95a8;
|
||||
.barCompact {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.secondary:empty {
|
||||
.compactLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.compactLine::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compactStats {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.ctxChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.ctxChip {
|
||||
color: #1e40af;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
border: 1px solid rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.barCompact .ctxChip {
|
||||
flex: 0 0 auto;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ctxWarn {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
.ctxDanger {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.16);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bar {
|
||||
font-size: 10px;
|
||||
.barCompact {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 9px;
|
||||
.compactLine {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatFooterTokens } from "../../utils/format";
|
||||
import styles from "./SessionContextBar.module.css";
|
||||
|
||||
export function SessionContextBar() {
|
||||
interface SessionContextBarProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SessionContextBar({ compact = false }: SessionContextBarProps) {
|
||||
const { sessionState, isStreaming } = useChatContext();
|
||||
const data = sessionState;
|
||||
|
||||
@@ -11,46 +15,62 @@ export function SessionContextBar() {
|
||||
}
|
||||
|
||||
const tok = data.stats.tokens || {};
|
||||
const parts: string[] = [];
|
||||
if (tok.input) parts.push(`↑${formatFooterTokens(tok.input)}`);
|
||||
if (tok.output) parts.push(`↓${formatFooterTokens(tok.output)}`);
|
||||
if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`);
|
||||
if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`);
|
||||
const chips: string[] = [];
|
||||
if (tok.input) chips.push(`↑${formatFooterTokens(tok.input)}`);
|
||||
if (tok.output) chips.push(`↓${formatFooterTokens(tok.output)}`);
|
||||
if (tok.cacheRead) chips.push(`R${formatFooterTokens(tok.cacheRead)}`);
|
||||
if (tok.cacheWrite) chips.push(`W${formatFooterTokens(tok.cacheWrite)}`);
|
||||
|
||||
const costNum = Number(data.stats.cost ?? 0);
|
||||
parts.push(`$${costNum.toFixed(3)}`);
|
||||
chips.push(`$${costNum.toFixed(3)}`);
|
||||
|
||||
const cx = data.stats.contextUsage;
|
||||
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
||||
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 autoInd = data.autoCompactionEnabled ? " auto" : "";
|
||||
const ctxTxt =
|
||||
pctStr === "?" && cw
|
||||
? `?/${formatFooterTokens(cw)}${autoInd}`
|
||||
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
|
||||
|
||||
const liveStreaming = isStreaming || data.isStreaming;
|
||||
let sub = "";
|
||||
if (data.isCompacting) sub = "⚡ 正在整理上下文…";
|
||||
else if (liveStreaming) sub = "⋯ 回复中…";
|
||||
else if ((data.turnIndex ?? 0) > 0) sub = `✓ Turn ${data.turnIndex} complete`;
|
||||
let status = "";
|
||||
if (!compact) {
|
||||
if (data.isCompacting) status = "整理上下文";
|
||||
else if (liveStreaming) status = "生成中";
|
||||
else if ((data.turnIndex ?? 0) > 0) status = `Turn ${data.turnIndex}`;
|
||||
}
|
||||
|
||||
let ctxClass = "";
|
||||
let ctxClass = styles.ctxChip;
|
||||
if (pctNum != null) {
|
||||
if (pctNum > 90) ctxClass = styles.ctxDanger;
|
||||
else if (pctNum > 70) ctxClass = styles.ctxWarn;
|
||||
if (pctNum > 90) ctxClass = `${styles.ctxChip} ${styles.ctxDanger}`;
|
||||
else if (pctNum > 70) ctxClass = `${styles.ctxChip} ${styles.ctxWarn}`;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`${styles.bar} ${styles.barCompact}`}>
|
||||
<div className={styles.compactLine}>
|
||||
<span className={styles.compactStats}>{chips.join(" ")}</span>
|
||||
<span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.left}>
|
||||
{parts.join(" ")} <span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
<div className={styles.chips}>
|
||||
{chips.map((chip) => (
|
||||
<span key={chip} className={styles.chip}>
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
<span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
{sub ? <div className={styles.secondary}>{sub}</div> : null}
|
||||
{status ? <span className={styles.status}>{status}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,6 @@
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-wrap: wrap;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--header-shadow);
|
||||
}
|
||||
|
||||
.trailing {
|
||||
margin-left: auto;
|
||||
.titleGroup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
max-width: min(68%, 680px);
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -28,47 +24,189 @@
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #555;
|
||||
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: #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1 1 auto;
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
.titleActions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
max-width: min(72%, 760px);
|
||||
min-width: 0;
|
||||
padding: 6px 8px 6px 10px;
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: 14px;
|
||||
background: var(--toolbar-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 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: 168px;
|
||||
max-width: min(320px, 42vw);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.selectCompact {
|
||||
min-width: 72px;
|
||||
max-width: 96px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statsMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statsDesktop {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbarActions::before {
|
||||
content: "";
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
min-height: 30px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(15, 23, 42, 0.08) 18%,
|
||||
rgba(15, 23, 42, 0.08) 82%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 0 10px;
|
||||
background: rgba(254, 242, 242, 0.95);
|
||||
border: 1px solid rgba(252, 165, 165, 0.65);
|
||||
border-radius: 9px;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -76,72 +214,132 @@
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.toolbar {
|
||||
max-width: min(78%, 620px);
|
||||
}
|
||||
|
||||
.modelSelect,
|
||||
.thinkingSelect {
|
||||
height: 30px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(260px, 28vw);
|
||||
}
|
||||
|
||||
.thinkingSelect {
|
||||
width: 86px;
|
||||
}
|
||||
|
||||
.modelSelect:focus,
|
||||
.thinkingSelect:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.22);
|
||||
}
|
||||
|
||||
.modelSelect:disabled,
|
||||
.thinkingSelect:disabled {
|
||||
color: #9ca3af;
|
||||
background: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
.select {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 7px;
|
||||
}
|
||||
|
||||
.titleGroup {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
grid-template-areas: "menu title actions";
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: flex;
|
||||
grid-area: menu;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.titleActions {
|
||||
display: flex;
|
||||
grid-area: actions;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 44px;
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 72px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(100%, 260px);
|
||||
flex: 1 1 180px;
|
||||
.field {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.selectCompact {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statsDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statsMobile {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbarActions::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.abortText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.title {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,61 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { modelKey, modelLabel } from "../../utils/sessionTitle";
|
||||
import { modelKey, modelLabel, shortModelLabel } from "../../utils/sessionTitle";
|
||||
import { ExportMenu } from "../chat/ExportMenu";
|
||||
import { SessionContextBar } from "../chat/SessionContextBar";
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
export function Header() {
|
||||
const {
|
||||
connected,
|
||||
isStreaming,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
toggleSidebar,
|
||||
abortStream,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isApplyingModelSettings,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
} = useChatContext();
|
||||
|
||||
const metaText =
|
||||
headerMeta ||
|
||||
(!connected ? "未连接" : isStreaming ? "回复中" : "");
|
||||
|
||||
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 (
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={styles.info}>
|
||||
<h1>{headerTitle}</h1>
|
||||
{metaText ? <span className={styles.meta}>{metaText}</span> : null}
|
||||
</div>
|
||||
<div className={styles.trailing}>
|
||||
<div className={styles.modelControls}>
|
||||
<div className={styles.controls}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>模型</span>
|
||||
<div className={styles.selectWrap}>
|
||||
<select
|
||||
className={styles.modelSelect}
|
||||
className={`${styles.select} ${styles.selectModel}`}
|
||||
aria-label="选择模型"
|
||||
title="选择模型"
|
||||
title={currentModel ? modelLabel(currentModel) : "选择模型"}
|
||||
value={currentModelKey}
|
||||
disabled={isStreaming || isApplyingModelSettings}
|
||||
onChange={(e) => void applyModelSelection(e.target.value)}
|
||||
>
|
||||
{availableModels.length === 0 ? (
|
||||
<option value="">加载模型...</option>
|
||||
<option value="">加载中…</option>
|
||||
) : (
|
||||
availableModels.map((m) => (
|
||||
<option key={modelKey(m)} value={modelKey(m)}>
|
||||
{modelLabel(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.thinkingSelect}
|
||||
className={`${styles.select} ${styles.selectCompact}`}
|
||||
aria-label="选择推理强度"
|
||||
title="推理强度"
|
||||
value={
|
||||
@@ -80,16 +78,81 @@ export function Header() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SessionContextBar />
|
||||
<ExportMenu />
|
||||
{isStreaming ? (
|
||||
<button type="button" className={styles.abortBtn} onClick={() => void abortStream()} title="中止回复">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
中止
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbortButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={styles.abortBtn} onClick={onClick} title="中止回复">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
<span className={styles.abortText}>中止</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const {
|
||||
isStreaming,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
toggleSidebar,
|
||||
abortStream,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
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.titleGroup}>
|
||||
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className={styles.title} title={headerTitle}>
|
||||
{headerTitle}
|
||||
</h1>
|
||||
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
|
||||
<div className={styles.titleActions}>
|
||||
<ExportMenu compact />
|
||||
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<ModelControls {...controlProps} />
|
||||
<div className={styles.statsDesktop}>
|
||||
<SessionContextBar />
|
||||
</div>
|
||||
<div className={styles.statsMobile}>
|
||||
<SessionContextBar compact />
|
||||
</div>
|
||||
<div className={styles.toolbarActions}>
|
||||
<ExportMenu />
|
||||
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
.main {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 8px 4px 8px 10px;
|
||||
padding: 8px 2px 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -64,7 +64,7 @@
|
||||
color: #1a1a1a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
@@ -74,59 +74,49 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pinnedBadge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ca8a04;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pinnedBadge:hover {
|
||||
opacity: 1;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.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: ellipsis;
|
||||
text-overflow: clip;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.item:hover .actions,
|
||||
.active .actions,
|
||||
.actions:focus-within {
|
||||
opacity: 1;
|
||||
gap: 0;
|
||||
margin-right: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pinBtn,
|
||||
.renameBtn,
|
||||
.deleteBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -138,7 +128,12 @@
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.pinBtn:hover {
|
||||
.pinBtnActive {
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.pinBtn:hover,
|
||||
.pinBtnActive:hover {
|
||||
color: #ca8a04;
|
||||
background: #fef9c3;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatTimeAgo } from "../../utils/format";
|
||||
import { formatSessionTitle } from "../../utils/sessionTitle";
|
||||
@@ -6,6 +7,27 @@ 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>;
|
||||
@@ -17,6 +39,7 @@ export function SessionList() {
|
||||
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}
|
||||
@@ -24,46 +47,47 @@ export function SessionList() {
|
||||
>
|
||||
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||
<div className={styles.titleRow}>
|
||||
{isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinnedBadge}
|
||||
title="取消置顶"
|
||||
aria-label={`取消置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, false);
|
||||
{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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 3H5a1 1 0 00-.8 1.6l4.6 6.12-4.6 6.12A1 1 0 005 18h11l4 3V3l-4 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className={styles.name}>{title}</div>
|
||||
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}>
|
||||
{!isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinBtn}
|
||||
title="置顶会话"
|
||||
aria-label={`置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, true);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
) : null}
|
||||
<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}
|
||||
@@ -71,7 +95,11 @@ export function SessionList() {
|
||||
aria-label={`重命名 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void renameSession(s.path, title);
|
||||
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">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
|
||||
@@ -374,6 +374,154 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -416,11 +564,29 @@
|
||||
|
||||
.mcpToolList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
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;
|
||||
@@ -473,27 +639,38 @@
|
||||
|
||||
.mcpToolSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
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 {
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mcpToolDesc {
|
||||
|
||||
@@ -7,7 +7,25 @@ import type { ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "..
|
||||
import type { McpToolInfo } from "../types/events";
|
||||
import styles from "./SettingsPage.module.css";
|
||||
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "skills", "mcp", "extensions", "other"];
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other"];
|
||||
|
||||
function isNpmSkill(skill: SkillInfo): boolean {
|
||||
if (skill.toggleable === false) return true;
|
||||
const source = skill.source || "";
|
||||
if (source.startsWith("npm:")) return true;
|
||||
const path = skill.path || "";
|
||||
return path.includes("/node_modules/") || path.includes("\\node_modules\\");
|
||||
}
|
||||
|
||||
function isSkillToggleable(skill: SkillInfo): boolean {
|
||||
return !isNpmSkill(skill) && skill.toggleable !== false;
|
||||
}
|
||||
|
||||
function formatSkillMeta(skill: SkillInfo): string {
|
||||
const source = (skill.source || "").trim();
|
||||
if (!source || source === "auto") return "";
|
||||
return source;
|
||||
}
|
||||
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
||||
|
||||
function renderNameList(label: string, items: string[] | undefined) {
|
||||
@@ -24,7 +42,152 @@ function renderNameList(label: string, items: string[] | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function McpToolItem({ tool }: { tool: McpToolInfo }) {
|
||||
function extensionCategory(extension: ExtensionInfo): "local" | "npm" {
|
||||
if (extension.category === "npm" || extension.category === "local") {
|
||||
return extension.category;
|
||||
}
|
||||
if (extension.source?.startsWith("npm:")) return "npm";
|
||||
const pathHint = `${extension.path || ""} ${extension.location || ""}`.replace(/\\/g, "/");
|
||||
if (pathHint.includes("/.pi/agent/npm/node_modules/")) return "npm";
|
||||
return "local";
|
||||
}
|
||||
|
||||
function extensionTogglePath(extension: ExtensionInfo): string {
|
||||
return extension.resolvedPath || extension.path || "";
|
||||
}
|
||||
|
||||
function ExtensionCard({
|
||||
extension,
|
||||
toggling,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
extension: ExtensionInfo;
|
||||
toggling: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}) {
|
||||
const meta =
|
||||
extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const enabled = extension.enabled !== false;
|
||||
const togglePath = extensionTogglePath(extension);
|
||||
const counts = (
|
||||
[
|
||||
["命令", extension.commands?.length || 0],
|
||||
["工具", extension.tools?.length || 0],
|
||||
["事件", extension.handlers?.length || 0],
|
||||
["Flags", extension.flags?.length || 0],
|
||||
["快捷键", extension.shortcuts?.length || 0],
|
||||
] as const
|
||||
).filter(([, count]) => count > 0);
|
||||
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
|
||||
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${styles.extensionItem} ${enabled ? "" : styles.skillItemDisabled}`}>
|
||||
<div className={styles.extensionHeader}>
|
||||
<div className={styles.extensionTitleRow}>
|
||||
<div className={styles.itemName}>{extension.name || extension.path || "未命名"}</div>
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!togglePath || toggling || disabled}
|
||||
onChange={(e) => {
|
||||
if (!togglePath) return;
|
||||
onToggle(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
|
||||
</label>
|
||||
</div>
|
||||
{meta ? <div className={styles.extensionSubtitle}>{meta}</div> : null}
|
||||
</div>
|
||||
{counts.length ? (
|
||||
<div className={styles.extensionCounts}>
|
||||
{counts.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className={styles.extensionDetails}>
|
||||
<summary>查看注册项</summary>
|
||||
{renderNameList("命令", extension.commands)}
|
||||
{renderNameList("工具", extension.tools)}
|
||||
{renderNameList("事件", extension.handlers)}
|
||||
{renderNameList("Flags", extension.flags)}
|
||||
{renderNameList("快捷键", extension.shortcuts)}
|
||||
</details>
|
||||
) : null}
|
||||
{extension.location ? <div className={styles.itemPath}>{extension.location}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionGroup({
|
||||
title,
|
||||
items,
|
||||
emptyText,
|
||||
togglingPath,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
items: ExtensionInfo[];
|
||||
emptyText: string;
|
||||
togglingPath: string | null;
|
||||
disabled: boolean;
|
||||
onToggle: (path: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.extensionGroup}>
|
||||
<div className={styles.extensionGroupHeader}>
|
||||
<h3>{title}</h3>
|
||||
<span>{items.length} 个</span>
|
||||
</div>
|
||||
{items.length ? (
|
||||
<div className={styles.extensionGroupList}>
|
||||
{items.map((extension) => {
|
||||
const togglePath = extensionTogglePath(extension);
|
||||
return (
|
||||
<ExtensionCard
|
||||
key={togglePath || extension.location || extension.name}
|
||||
extension={extension}
|
||||
toggling={Boolean(togglePath && togglingPath === togglePath)}
|
||||
disabled={disabled}
|
||||
onToggle={(enabled) => {
|
||||
if (!togglePath) return;
|
||||
onToggle(togglePath, enabled);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>{emptyText}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function McpToolItem({
|
||||
tool,
|
||||
serverEnabled,
|
||||
toggling,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
tool: McpToolInfo;
|
||||
serverEnabled: boolean;
|
||||
toggling: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}) {
|
||||
const params = Array.isArray(tool.parameters) ? tool.parameters : [];
|
||||
const required = new Set(Array.isArray(tool.required) ? tool.required : []);
|
||||
const description = String(tool.description || "").replace(/\s+/g, " ").trim();
|
||||
@@ -33,12 +196,36 @@ function McpToolItem({ tool }: { tool: McpToolInfo }) {
|
||||
const title = [tool.name, description, params.length ? `参数: ${params.join(", ")}` : ""]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const enabled = tool.enabled !== false;
|
||||
const toolName = tool.name || "";
|
||||
|
||||
return (
|
||||
<details className={styles.mcpToolItem} title={title}>
|
||||
<details
|
||||
className={`${styles.mcpToolItem} ${enabled ? "" : styles.mcpToolItemDisabled}`}
|
||||
title={title}
|
||||
>
|
||||
<summary className={styles.mcpToolSummary}>
|
||||
<span className={styles.mcpToolName}>{tool.name || "未命名工具"}</span>
|
||||
{params.length ? <span className={styles.badge}>{params.length} 参数</span> : null}
|
||||
<span className={styles.mcpToolName}>{toolName || "未命名工具"}</span>
|
||||
<div className={styles.mcpToolSummaryActions}>
|
||||
{params.length ? <span className={styles.badge}>{params.length} 参数</span> : null}
|
||||
<label
|
||||
className={styles.skillToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!toolName || !serverEnabled || toggling || disabled}
|
||||
onChange={(e) => {
|
||||
if (!toolName || !serverEnabled) return;
|
||||
onToggle(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>{enabled ? "开" : "关"}</span>
|
||||
</label>
|
||||
</div>
|
||||
</summary>
|
||||
{shortDescription ? <div className={styles.mcpToolDesc}>{shortDescription}</div> : null}
|
||||
{params.length ? (
|
||||
@@ -61,6 +248,8 @@ export function SettingsPage() {
|
||||
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [systemPromptPath, setSystemPromptPath] = useState("");
|
||||
const [modelsConfig, setModelsConfig] = useState("");
|
||||
const [modelsConfigPath, setModelsConfigPath] = useState("");
|
||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
@@ -71,8 +260,13 @@ export function SettingsPage() {
|
||||
const [statusKind, setStatusKind] = useState<"" | "ok" | "error">("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingModels, setSavingModels] = useState(false);
|
||||
const [savingAvatars, setSavingAvatars] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [togglingSkillPath, setTogglingSkillPath] = useState<string | null>(null);
|
||||
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
|
||||
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
|
||||
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let initial: SettingsPaneId = "prompt";
|
||||
@@ -108,6 +302,8 @@ export function SettingsPage() {
|
||||
const data = await settingsApi.fetchSettings();
|
||||
setSystemPrompt(data.systemPrompt || "");
|
||||
setSystemPromptPath(data.systemPromptPath || "");
|
||||
setModelsConfig(data.modelsConfig || "");
|
||||
setModelsConfigPath(data.modelsConfigPath || "");
|
||||
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||
updateAvatars({
|
||||
@@ -140,6 +336,106 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
|
||||
setTogglingSkillPath(path);
|
||||
setStatus(enabled ? "正在启用 skill…" : "正在禁用 skill…");
|
||||
try {
|
||||
const data = await settingsApi.toggleSkill(path, enabled);
|
||||
if (data.skill) {
|
||||
setSkills((prev) =>
|
||||
prev.map((skill) => (skill.path === path ? { ...skill, ...data.skill } : skill)),
|
||||
);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(enabled ? "Skill 已启用" : "Skill 已禁用", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingSkillPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMcpServer = (entry: McpServerInfo) => {
|
||||
setMcpTools((prev) =>
|
||||
prev.map((server) => (server.name === entry.name ? { ...server, ...entry } : server)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleMcpServerEnabled = async (serverName: string, enabled: boolean) => {
|
||||
setTogglingMcpServer(serverName);
|
||||
setStatus(enabled ? "正在启用 MCP Server…" : "正在禁用 MCP Server…");
|
||||
try {
|
||||
const data = await settingsApi.toggleMcpServer(serverName, enabled);
|
||||
if (data.server) {
|
||||
updateMcpServer(data.server);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(
|
||||
enabled
|
||||
? "MCP Server 已启用,必要时可运行 /mcp reconnect 刷新连接"
|
||||
: "MCP Server 已禁用",
|
||||
"ok",
|
||||
);
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingMcpServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMcpToolEnabled = async (serverName: string, toolName: string, enabled: boolean) => {
|
||||
const toggleKey = `${serverName}/${toolName}`;
|
||||
setTogglingMcpTool(toggleKey);
|
||||
setStatus(enabled ? "正在启用 MCP工具…" : "正在禁用 MCP工具…");
|
||||
try {
|
||||
const data = await settingsApi.toggleMcpTool(serverName, toolName, enabled);
|
||||
if (data.tool) {
|
||||
setMcpTools((prev) =>
|
||||
prev.map((server) => {
|
||||
if (server.name !== serverName || !Array.isArray(server.tools)) return server;
|
||||
const tools = server.tools.map((tool) =>
|
||||
tool.name === toolName ? { ...tool, ...data.tool } : tool,
|
||||
);
|
||||
const enabledToolCount = tools.filter((tool) => tool.enabled !== false).length;
|
||||
return { ...server, tools, enabledToolCount };
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(enabled ? "MCP工具已启用" : "MCP工具已禁用", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingMcpTool(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExtensionEnabled = async (path: string, enabled: boolean) => {
|
||||
setTogglingExtensionPath(path);
|
||||
setStatus(enabled ? "正在启用扩展…" : "正在禁用扩展…");
|
||||
try {
|
||||
const data = await settingsApi.toggleExtension(path, enabled);
|
||||
if (data.extension) {
|
||||
setExtensions((prev) =>
|
||||
prev.map((extension) => {
|
||||
const key = extensionTogglePath(extension);
|
||||
return key === path ? { ...extension, ...data.extension } : extension;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(enabled ? "扩展已启用,Agent 已重新加载" : "扩展已禁用,Agent 已重新加载", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingExtensionPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSystemPrompt = async () => {
|
||||
setSaving(true);
|
||||
setStatus("保存中...");
|
||||
@@ -154,6 +450,22 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveModelsConfig = async () => {
|
||||
setSavingModels(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
const data = await settingsApi.saveModelsConfig(modelsConfig);
|
||||
if (data.modelsConfigPath) setModelsConfigPath(data.modelsConfigPath);
|
||||
const refreshed = await settingsApi.fetchSettings();
|
||||
setModelsConfig(refreshed.modelsConfig || "");
|
||||
setStatus("已保存,Agent 已重新加载", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setSavingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAvatars = async () => {
|
||||
setSavingAvatars(true);
|
||||
setStatus("保存中...");
|
||||
@@ -179,6 +491,16 @@ export function SettingsPage() {
|
||||
}, [loadSettings]);
|
||||
|
||||
const toolTotal = mcpTools.reduce((sum, server) => sum + (server.toolCount || 0), 0);
|
||||
const enabledMcpServerCount = mcpTools.filter((server) => server.enabled !== false).length;
|
||||
const enabledMcpToolCount = mcpTools.reduce(
|
||||
(sum, server) => sum + (server.enabled !== false ? server.enabledToolCount ?? 0 : 0),
|
||||
0,
|
||||
);
|
||||
const localExtensions = extensions.filter((extension) => extensionCategory(extension) === "local");
|
||||
const npmExtensions = extensions.filter((extension) => extensionCategory(extension) === "npm");
|
||||
const toggleableSkills = skills.filter(isSkillToggleable);
|
||||
const enabledSkillCount = toggleableSkills.filter((skill) => skill.enabled !== false).length;
|
||||
const enabledExtensionCount = extensions.filter((extension) => extension.enabled !== false).length;
|
||||
const statusClass =
|
||||
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
||||
|
||||
@@ -190,11 +512,10 @@ export function SettingsPage() {
|
||||
<img className={styles.logo} src="/logo.png" width={34} height={34} alt="" decoding="async" />
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>萌小芽 Agent</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/" className={`${styles.secondaryBtn} ${styles.linkBtn}`}>
|
||||
返回聊天
|
||||
返回
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
@@ -204,8 +525,9 @@ export function SettingsPage() {
|
||||
{(
|
||||
[
|
||||
["prompt", "系统提示词"],
|
||||
["models", "模型配置"],
|
||||
["skills", "Skills"],
|
||||
["mcp", "MCP 工具"],
|
||||
["mcp", "MCP工具"],
|
||||
["extensions", "插件扩展"],
|
||||
["other", "其他设置"],
|
||||
] as const
|
||||
@@ -255,6 +577,37 @@ export function SettingsPage() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "models" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "models"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>模型配置</h2>
|
||||
<p>{modelsConfigPath}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingModels}
|
||||
onClick={() => void saveModelsConfig()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
spellCheck={false}
|
||||
aria-label="模型配置"
|
||||
value={modelsConfig}
|
||||
onChange={(e) => setModelsConfig(e.target.value)}
|
||||
/>
|
||||
{statusText && activePane === "models" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "skills" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
@@ -263,7 +616,9 @@ export function SettingsPage() {
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>Skills</h2>
|
||||
<p>{skills.length} 个已加载</p>
|
||||
<p>
|
||||
{enabledSkillCount} / {toggleableSkills.length} 个已启用
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -281,14 +636,40 @@ export function SettingsPage() {
|
||||
<div className={styles.empty}>暂无已加载 skills</div>
|
||||
) : (
|
||||
skills.map((skill) => {
|
||||
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
|
||||
const meta = formatSkillMeta(skill);
|
||||
const skillPath = skill.path || "";
|
||||
const enabled = skill.enabled !== false;
|
||||
const toggleable = isSkillToggleable(skill);
|
||||
const toggling = togglingSkillPath === skillPath;
|
||||
return (
|
||||
<div key={skill.path || skill.name || skill.command} className={styles.skillItem}>
|
||||
<div
|
||||
key={skillPath || skill.name || skill.command}
|
||||
className={`${styles.skillItem} ${enabled ? "" : styles.skillItemDisabled}`}
|
||||
>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{skill.name || skill.command || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
<div className={styles.skillActions}>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
{toggleable ? (
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!skillPath || toggling || reloading}
|
||||
onChange={(e) => {
|
||||
if (!skillPath) return;
|
||||
void toggleSkillEnabled(skillPath, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>
|
||||
{enabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className={styles.itemDesc}>{skill.description}</div>
|
||||
@@ -308,9 +689,10 @@ export function SettingsPage() {
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>MCP 工具</h2>
|
||||
<h2>MCP工具</h2>
|
||||
<p>
|
||||
{mcpTools.length} 个 Server · {toolTotal} 个工具
|
||||
{enabledMcpServerCount} / {mcpTools.length} 个 Server · {enabledMcpToolCount} /{" "}
|
||||
{toolTotal} 个工具已启用
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -330,22 +712,51 @@ export function SettingsPage() {
|
||||
) : (
|
||||
mcpTools.map((server) => {
|
||||
const tools = Array.isArray(server.tools) ? server.tools : [];
|
||||
const serverName = server.name || "";
|
||||
const serverEnabled = server.enabled !== false;
|
||||
const togglingServer = togglingMcpServer === serverName;
|
||||
const status = server.cached ? "已缓存" : "未连接";
|
||||
const cachedAt = server.cachedAt
|
||||
? new Date(server.cachedAt).toLocaleString()
|
||||
: "";
|
||||
return (
|
||||
<div key={server.name} className={styles.mcpServerItem}>
|
||||
<div
|
||||
key={serverName || "unnamed"}
|
||||
className={`${styles.mcpServerItem} ${serverEnabled ? "" : styles.skillItemDisabled}`}
|
||||
>
|
||||
<div className={styles.mcpServerHead}>
|
||||
<div className={styles.mcpServerMain}>
|
||||
<div className={styles.itemName}>{server.name || "未命名 Server"}</div>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{serverName || "未命名 Server"}
|
||||
</div>
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={serverEnabled}
|
||||
disabled={!serverName || togglingServer || reloading}
|
||||
onChange={(e) => {
|
||||
if (!serverName) return;
|
||||
void toggleMcpServerEnabled(serverName, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>
|
||||
{serverEnabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{cachedAt ? (
|
||||
<div className={styles.mcpServerSub}>更新 {cachedAt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.mcpServerBadges}>
|
||||
<span className={styles.badge}>{status}</span>
|
||||
<span className={styles.badge}>{server.toolCount || 0} 工具</span>
|
||||
<span className={styles.badge}>
|
||||
{serverEnabled
|
||||
? `${server.enabledToolCount ?? tools.filter((tool) => tool.enabled !== false).length} / ${server.toolCount || tools.length} 工具`
|
||||
: `${server.toolCount || tools.length} 工具`}
|
||||
</span>
|
||||
{server.resourceCount ? (
|
||||
<span className={styles.badge}>{server.resourceCount} 资源</span>
|
||||
) : null}
|
||||
@@ -353,9 +764,23 @@ export function SettingsPage() {
|
||||
</div>
|
||||
{tools.length ? (
|
||||
<div className={styles.mcpToolList}>
|
||||
{tools.map((tool) => (
|
||||
<McpToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
{tools.map((tool) => {
|
||||
const toolName = tool.name || "";
|
||||
const toggleKey = `${serverName}/${toolName}`;
|
||||
return (
|
||||
<McpToolItem
|
||||
key={toolName || "unnamed"}
|
||||
tool={tool}
|
||||
serverEnabled={serverEnabled}
|
||||
toggling={togglingMcpTool === toggleKey}
|
||||
disabled={reloading || togglingServer}
|
||||
onToggle={(enabled) => {
|
||||
if (!serverName || !toolName) return;
|
||||
void toggleMcpToolEnabled(serverName, toolName, enabled);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>
|
||||
@@ -367,6 +792,9 @@ export function SettingsPage() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{statusText && activePane === "mcp" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -378,9 +806,9 @@ export function SettingsPage() {
|
||||
<div>
|
||||
<h2>插件扩展</h2>
|
||||
<p>
|
||||
{extensionsPath
|
||||
? `${extensions.length} 个已加载 · ${extensionsPath}`
|
||||
: `${extensions.length} 个已加载`}
|
||||
{enabledExtensionCount} / {extensions.length} 个已启用 · {localExtensions.length}{" "}
|
||||
个本地 · {npmExtensions.length} 个 npm
|
||||
{extensionsPath ? ` · ${extensionsPath}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -398,60 +826,29 @@ export function SettingsPage() {
|
||||
) : !extensions.length ? (
|
||||
<div className={styles.empty}>暂无已加载插件扩展</div>
|
||||
) : (
|
||||
extensions.map((extension) => {
|
||||
const meta =
|
||||
extension.kind ||
|
||||
[extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const counts = (
|
||||
[
|
||||
["命令", extension.commands?.length || 0],
|
||||
["工具", extension.tools?.length || 0],
|
||||
["事件", extension.handlers?.length || 0],
|
||||
["Flags", extension.flags?.length || 0],
|
||||
["快捷键", extension.shortcuts?.length || 0],
|
||||
] as const
|
||||
).filter(([, count]) => count > 0);
|
||||
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
|
||||
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={extension.location || extension.name || extension.path}
|
||||
className={styles.extensionItem}
|
||||
>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{extension.name || extension.path || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
</div>
|
||||
{counts.length ? (
|
||||
<div className={styles.extensionCounts}>
|
||||
{counts.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className={styles.extensionDetails}>
|
||||
<summary>查看注册项</summary>
|
||||
{renderNameList("命令", extension.commands)}
|
||||
{renderNameList("工具", extension.tools)}
|
||||
{renderNameList("事件", extension.handlers)}
|
||||
{renderNameList("Flags", extension.flags)}
|
||||
{renderNameList("快捷键", extension.shortcuts)}
|
||||
</details>
|
||||
) : null}
|
||||
{extension.location ? (
|
||||
<div className={styles.itemPath}>{extension.location}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<>
|
||||
<ExtensionGroup
|
||||
title="本地扩展"
|
||||
items={localExtensions}
|
||||
emptyText="暂无本地扩展"
|
||||
togglingPath={togglingExtensionPath}
|
||||
disabled={reloading}
|
||||
onToggle={(path, enabled) => void toggleExtensionEnabled(path, enabled)}
|
||||
/>
|
||||
<ExtensionGroup
|
||||
title="npm 扩展"
|
||||
items={npmExtensions}
|
||||
emptyText="暂无 npm 扩展"
|
||||
togglingPath={togglingExtensionPath}
|
||||
disabled={reloading}
|
||||
onToggle={(path, enabled) => void toggleExtensionEnabled(path, enabled)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{statusText && activePane === "extensions" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
@@ -10,6 +10,7 @@ html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: var(--font-family);
|
||||
letter-spacing: var(--font-letter-spacing);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
:root {
|
||||
--app-height: 100vh;
|
||||
--font-family: "LXGW WenKai Mono", "霞鹜文楷等宽", ui-monospace, monospace;
|
||||
--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 {
|
||||
|
||||
11
.pi/agent/extensions/webui/frontend/src/types/commands.ts
Normal file
11
.pi/agent/extensions/webui/frontend/src/types/commands.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type SlashCommandSource = "builtin" | "extension" | "prompt" | "skill";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
source: SlashCommandSource;
|
||||
}
|
||||
|
||||
export interface SlashCommandsResponse {
|
||||
commands: SlashCommand[];
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { RpcMessage } from "./message";
|
||||
|
||||
export interface ConnectedEvent {
|
||||
type: "connected";
|
||||
}
|
||||
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
}
|
||||
@@ -49,8 +45,21 @@ export interface ToolExecutionEndEvent {
|
||||
result?: { content?: unknown };
|
||||
}
|
||||
|
||||
export type SseEvent =
|
||||
| ConnectedEvent
|
||||
export interface CompactionStartEvent {
|
||||
type: "compaction_start";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CompactionEndEvent {
|
||||
type: "compaction_end";
|
||||
reason?: string;
|
||||
aborted?: boolean;
|
||||
errorMessage?: string;
|
||||
result?: { tokensBefore?: number; summary?: string };
|
||||
}
|
||||
|
||||
/** Agent stream events replayed on SSE reconnect (excludes connected / prompt_rejected). */
|
||||
export type StreamSseEvent =
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| MessageStartEvent
|
||||
@@ -58,7 +67,22 @@ export type SseEvent =
|
||||
| MessageEndEvent
|
||||
| ToolExecutionStartEvent
|
||||
| ToolExecutionUpdateEvent
|
||||
| ToolExecutionEndEvent;
|
||||
| ToolExecutionEndEvent
|
||||
| CompactionStartEvent
|
||||
| CompactionEndEvent;
|
||||
|
||||
export interface ConnectedEvent {
|
||||
type: "connected";
|
||||
isStreaming?: boolean;
|
||||
replay?: StreamSseEvent[];
|
||||
}
|
||||
|
||||
export interface PromptRejectedEvent {
|
||||
type: "prompt_rejected";
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type SseEvent = ConnectedEvent | PromptRejectedEvent | StreamSseEvent;
|
||||
|
||||
export interface SkillInfo {
|
||||
name?: string;
|
||||
@@ -67,20 +91,26 @@ export interface SkillInfo {
|
||||
path?: string;
|
||||
scope?: string;
|
||||
source?: string;
|
||||
enabled?: boolean;
|
||||
toggleable?: boolean;
|
||||
}
|
||||
|
||||
export interface McpToolInfo {
|
||||
server?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: string[];
|
||||
required?: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface McpServerInfo {
|
||||
name?: string;
|
||||
configured?: boolean;
|
||||
enabled?: boolean;
|
||||
cached?: boolean;
|
||||
toolCount?: number;
|
||||
enabledToolCount?: number;
|
||||
resourceCount?: number;
|
||||
cachedAt?: string;
|
||||
tools?: McpToolInfo[];
|
||||
@@ -89,10 +119,14 @@ export interface McpServerInfo {
|
||||
export interface ExtensionInfo {
|
||||
name?: string;
|
||||
path?: string;
|
||||
resolvedPath?: string;
|
||||
kind?: string;
|
||||
category?: "local" | "npm";
|
||||
scope?: string;
|
||||
source?: string;
|
||||
location?: string;
|
||||
version?: string;
|
||||
enabled?: boolean;
|
||||
commands?: string[];
|
||||
tools?: string[];
|
||||
handlers?: string[];
|
||||
@@ -103,6 +137,8 @@ export interface ExtensionInfo {
|
||||
export interface SettingsData {
|
||||
systemPrompt?: string;
|
||||
systemPromptPath?: string;
|
||||
modelsConfig?: string;
|
||||
modelsConfigPath?: string;
|
||||
userAvatarUrl?: string;
|
||||
agentAvatarUrl?: string;
|
||||
skills?: SkillInfo[];
|
||||
@@ -119,4 +155,4 @@ export interface AvatarSettings {
|
||||
agentAvatarUrl: string;
|
||||
}
|
||||
|
||||
export type SettingsPaneId = "prompt" | "skills" | "mcp" | "extensions" | "other";
|
||||
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool_call";
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool_call" | "bash" | "thinking";
|
||||
|
||||
export interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
toolName?: string;
|
||||
@@ -42,6 +43,8 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
images?: ImageContent[];
|
||||
toolCallId?: string;
|
||||
bashCommand?: string;
|
||||
bashExitCode?: number;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
@@ -72,6 +75,7 @@ export interface SessionState {
|
||||
error?: string;
|
||||
sessionId?: string | number;
|
||||
sessionName?: string;
|
||||
sessionFile?: string;
|
||||
model?: ModelInfo;
|
||||
thinkingLevel?: string;
|
||||
stats?: {
|
||||
|
||||
39
.pi/agent/extensions/webui/frontend/src/utils/bash.ts
Normal file
39
.pi/agent/extensions/webui/frontend/src/utils/bash.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface BashResult {
|
||||
output: string;
|
||||
exitCode?: number;
|
||||
cancelled?: boolean;
|
||||
truncated?: boolean;
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface ParsedBashInput {
|
||||
command: string;
|
||||
display: string;
|
||||
excludeFromContext: boolean;
|
||||
}
|
||||
|
||||
export function parseBashInput(text: string): ParsedBashInput | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("!")) return null;
|
||||
|
||||
const excludeFromContext = trimmed.startsWith("!!");
|
||||
const command = (excludeFromContext ? trimmed.slice(2) : trimmed.slice(1)).trim();
|
||||
if (!command) return null;
|
||||
|
||||
return {
|
||||
command,
|
||||
display: trimmed,
|
||||
excludeFromContext,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBashOutput(result: BashResult): string {
|
||||
if (result.cancelled) return "(已取消)";
|
||||
if (result.output?.trim()) {
|
||||
let output = result.output;
|
||||
if (result.truncated) output += "\n...(输出已截断)";
|
||||
return output;
|
||||
}
|
||||
if (result.exitCode === 0) return "(无输出)";
|
||||
return "(无输出)";
|
||||
}
|
||||
@@ -7,10 +7,14 @@ function roleLabel(role: ChatMessage["role"]): string {
|
||||
return "用户";
|
||||
case "assistant":
|
||||
return "助手";
|
||||
case "thinking":
|
||||
return "思考";
|
||||
case "tool_call":
|
||||
return "工具";
|
||||
case "system":
|
||||
return "系统";
|
||||
case "bash":
|
||||
return "Shell";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +32,20 @@ export function messagesToMarkdown(title: string, messages: ChatMessage[]): stri
|
||||
case "assistant":
|
||||
lines.push("## 助手", "", m.content, "");
|
||||
break;
|
||||
case "thinking":
|
||||
lines.push("## 思考", "", m.content, "");
|
||||
break;
|
||||
case "tool_call":
|
||||
lines.push("## 工具", "", "```", m.content, "```", "");
|
||||
break;
|
||||
case "system":
|
||||
lines.push(`> ${m.content}`, "");
|
||||
break;
|
||||
case "bash": {
|
||||
const cmd = m.bashCommand ? `$ ${m.bashCommand}` : "Shell";
|
||||
lines.push(`## Shell (${cmd})`, "", "```", m.content, "```", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
@@ -54,7 +66,7 @@ export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): s
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escHtml(title)}</title>
|
||||
<style>
|
||||
body{font-family:"LXGW WenKai Mono","霞鹜文楷等宽",ui-monospace,monospace;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
body{font-family:"Maple Mono CN",ui-monospace,monospace;letter-spacing:-0.05em;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||
.block{margin:0 0 1rem}
|
||||
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function isMachineSessionLabel(text: string | undefined, headerId: string
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */
|
||||
/** 首句截取(侧边栏兜底与顶栏回填,需与 backend/services/sessions.ts 保持一致逻辑) */
|
||||
export function titleFromFirstUserMessage(text: string | undefined, maxChars = 56): string {
|
||||
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!cleaned) return "";
|
||||
@@ -33,3 +33,18 @@ export function modelLabel(model: { provider: string; id: string } | undefined):
|
||||
if (!model) return "";
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
/** 顶栏模型下拉展示:[渠道名]模型id,避免同名模型无法区分 */
|
||||
export function shortModelLabel(model: { provider: string; id: string } | undefined, maxChars = 52): string {
|
||||
if (!model) return "";
|
||||
const provider = model.provider.trim();
|
||||
const id = model.id.trim();
|
||||
if (!provider) return id;
|
||||
if (!id) return `[${provider}]`;
|
||||
const prefix = `[${provider}]`;
|
||||
const combined = `${prefix}${id}`;
|
||||
if (combined.length <= maxChars) return combined;
|
||||
const idBudget = Math.max(12, maxChars - prefix.length);
|
||||
const clippedId = id.length <= idBudget ? id : id.slice(0, idBudget);
|
||||
return `${prefix}${clippedId}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SlashCommand } from "../types/commands";
|
||||
|
||||
/** Keep in sync with backend/slash/dispatch.ts WEBUI_BUILTIN_SLASH_NAMES */
|
||||
const WEBUI_BUILTIN_SLASH_NAMES = new Set([
|
||||
"compact",
|
||||
"new",
|
||||
"reload",
|
||||
"clone",
|
||||
"name",
|
||||
"model",
|
||||
"session",
|
||||
"export",
|
||||
"copy",
|
||||
]);
|
||||
|
||||
export function isWebUiSlashCommand(command: SlashCommand): boolean {
|
||||
if (command.source === "extension" || command.source === "prompt" || command.source === "skill") {
|
||||
return true;
|
||||
}
|
||||
return WEBUI_BUILTIN_SLASH_NAMES.has(command.name);
|
||||
}
|
||||
|
||||
export function filterWebUiSlashCommands(commands: SlashCommand[]): SlashCommand[] {
|
||||
return commands.filter(isWebUiSlashCommand);
|
||||
}
|
||||
|
||||
export function parseSlashCommandInput(text: string): { query: string } | null {
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
if (trimmed.includes(" ")) return null;
|
||||
return { query: trimmed.slice(1) };
|
||||
}
|
||||
|
||||
export function filterSlashCommands(commands: SlashCommand[], query: string): SlashCommand[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return commands;
|
||||
|
||||
return commands
|
||||
.filter((command) => command.name.toLowerCase().includes(normalized))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLowerCase().startsWith(normalized);
|
||||
const bStarts = b.name.toLowerCase().startsWith(normalized);
|
||||
if (aStarts !== bStarts) return aStarts ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function applySlashCompletion(current: string, commandName: string): string {
|
||||
const leadingSpaces = current.match(/^\s*/)?.[0] ?? "";
|
||||
return `${leadingSpaces}/${commandName} `;
|
||||
}
|
||||
|
||||
export function slashCommandSourceLabel(source: SlashCommand["source"]): string {
|
||||
switch (source) {
|
||||
case "builtin":
|
||||
return "内置";
|
||||
case "extension":
|
||||
return "扩展";
|
||||
case "prompt":
|
||||
return "指令";
|
||||
case "skill":
|
||||
return "技能";
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,24 @@ export function extractContent(content: string | ContentBlock[] | undefined): st
|
||||
return String(content);
|
||||
}
|
||||
|
||||
export function extractThinking(content: string | ContentBlock[] | undefined): string {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return "";
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "thinking")
|
||||
.map((c) => c.thinking ?? "")
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function deriveThinkingSummary(content: string, maxLen = 56): string {
|
||||
const oneLine = String(content || "").replace(/\s+/g, " ").trim();
|
||||
if (!oneLine) return "思考中…";
|
||||
if (oneLine.length <= maxLen) return oneLine;
|
||||
return `${oneLine.slice(0, maxLen).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function extractImages(content: string | ContentBlock[] | undefined): ImageContent[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -2,64 +2,79 @@ import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
name: "萌小芽",
|
||||
short_name: "萌小芽",
|
||||
description: "树萌芽智能 AI 助手 Web 客户端",
|
||||
theme_color: "#f7f8fb",
|
||||
background_color: "#f7f8fb",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
lang: "zh-CN",
|
||||
icons: [
|
||||
{
|
||||
src: "logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "index.html",
|
||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:19133",
|
||||
const DESKTOP_API_ORIGIN = "https://sproutclaw.shumengya.top";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDesktop = mode === "desktop";
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
name: "萌小芽",
|
||||
short_name: "萌小芽",
|
||||
description: "树萌芽智能 AI 助手 Web 客户端",
|
||||
theme_color: "#f7f8fb",
|
||||
background_color: "#f7f8fb",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
lang: "zh-CN",
|
||||
icons: [
|
||||
{
|
||||
src: "logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "index.html",
|
||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||
runtimeCaching: isDesktop
|
||||
? [
|
||||
{
|
||||
urlPattern: new RegExp(
|
||||
`^${DESKTOP_API_ORIGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/api/`,
|
||||
),
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: isDesktop ? "dist-desketop" : "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:19133",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* 用法:
|
||||
* /webui on → 启动网页服务(默认端口 19133)
|
||||
* /webui off → 停止网页服务
|
||||
* /webui down → 同 off
|
||||
* /webui reload → 重载网页服务(等同 off + on,保留当前端口)
|
||||
* /webui on 8080 → 指定端口启动
|
||||
*/
|
||||
|
||||
@@ -15,14 +17,25 @@ import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_WEBUI_PORT,
|
||||
WEBUI_SERVICE_NAME,
|
||||
ensureSystemdService,
|
||||
getSystemdDisabledReason,
|
||||
isSystemdManaged,
|
||||
syncSystemdPort,
|
||||
systemdControl,
|
||||
type SystemdServiceConfig,
|
||||
} from "./systemd/service.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = join(__dirname, ".webui.pid");
|
||||
const DIST_DIR = join(__dirname, "dist");
|
||||
const DIST_DIR = join(__dirname, "frontend", "dist");
|
||||
const FRONTEND_DIR = join(__dirname, "frontend");
|
||||
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverPort = 19133;
|
||||
let serverPort = DEFAULT_WEBUI_PORT;
|
||||
let systemdConfig: SystemdServiceConfig | null = null;
|
||||
|
||||
function findTsx(): string {
|
||||
// 从扩展所在目录向上找 repo 根目录
|
||||
@@ -93,6 +106,15 @@ function isProcessAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForStartupByProbe(port: number, timeoutMs = 10000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await probePort(port)) === "running") return;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error("webui 启动超时");
|
||||
}
|
||||
|
||||
function waitForStartup(port: number, child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -151,23 +173,62 @@ function probePort(port: number): Promise<"free" | "running" | "occupied"> {
|
||||
});
|
||||
}
|
||||
|
||||
function findNpm(): string {
|
||||
const local = join(dirname(process.execPath), "npm");
|
||||
if (existsSync(local)) return local;
|
||||
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = join(dir, "node_modules", ".bin", "npm");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return "npm";
|
||||
}
|
||||
|
||||
function runNpm(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
): { ok: boolean; status: number | null; error?: string } {
|
||||
const result = spawnSync(findNpm(), args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
if (result.error) {
|
||||
return { ok: false, status: result.status, error: result.error.message };
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return { ok: false, status: result.status };
|
||||
}
|
||||
return { ok: true, status: 0 };
|
||||
}
|
||||
|
||||
async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => void } }): Promise<boolean> {
|
||||
const indexHtml = join(DIST_DIR, "index.html");
|
||||
if (existsSync(indexHtml)) return true;
|
||||
|
||||
ctx.ui.notify("正在构建 WebUI 前端...", "info");
|
||||
ctx.ui.notify("正在构建 WebUI 前端(build:web)...", "info");
|
||||
|
||||
if (!existsSync(join(FRONTEND_DIR, "node_modules"))) {
|
||||
const install = spawnSync("npm", ["install"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||
if (install.status !== 0) {
|
||||
ctx.ui.notify("WebUI 前端依赖安装失败", "error");
|
||||
const install = runNpm(["install"], FRONTEND_DIR);
|
||||
if (!install.ok) {
|
||||
const detail = install.error ?? (install.status != null ? `exit ${install.status}` : "unknown");
|
||||
ctx.ui.notify(`WebUI 前端依赖安装失败: ${detail}`, "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const build = spawnSync("npm", ["run", "build"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||
if (build.status !== 0 || !existsSync(indexHtml)) {
|
||||
ctx.ui.notify("WebUI 前端构建失败", "error");
|
||||
const build = runNpm(["run", "build:web"], FRONTEND_DIR);
|
||||
if (!build.ok || !existsSync(indexHtml)) {
|
||||
const details: string[] = [];
|
||||
if (build.error) details.push(build.error);
|
||||
if (build.status != null && build.status !== 0) details.push(`exit ${build.status}`);
|
||||
if (!existsSync(indexHtml)) details.push(`未生成 ${indexHtml}`);
|
||||
ctx.ui.notify(`WebUI 前端构建失败${details.length ? `: ${details.join("; ")}` : ""}`, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -176,7 +237,7 @@ async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => v
|
||||
}
|
||||
|
||||
async function startServer(port: number, ctx: any): Promise<void> {
|
||||
if (serverProcess) {
|
||||
if (!isSystemdManaged() && serverProcess) {
|
||||
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
|
||||
return;
|
||||
}
|
||||
@@ -199,20 +260,40 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureBuilt(ctx))) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverPort = port;
|
||||
|
||||
if (isSystemdManaged() && systemdConfig) {
|
||||
syncSystemdPort(systemdConfig, port);
|
||||
const result = systemdControl("start");
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`systemctl start 失败: ${result.output}`, "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await waitForStartupByProbe(port);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(`网页服务已启动(systemd)→ http://smallmengya:${port}`, "info");
|
||||
notifyLanAddresses(ctx, port);
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = findRepoRoot();
|
||||
const tsxBin = findTsx();
|
||||
const serverFile = join(__dirname, "server.ts");
|
||||
const serverFile = join(__dirname, "backend", "main.ts");
|
||||
|
||||
if (!existsSync(serverFile)) {
|
||||
ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureBuilt(ctx))) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverPort = port;
|
||||
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
@@ -222,7 +303,7 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
serverProcess.on("exit", () => {
|
||||
serverProcess = null;
|
||||
clearPidFile();
|
||||
});
|
||||
@@ -233,16 +314,19 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
|
||||
try {
|
||||
await waitForStartup(port, serverProcess);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
clearPidFile();
|
||||
serverProcess = null;
|
||||
ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error");
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
|
||||
notifyLanAddresses(ctx, port);
|
||||
}
|
||||
|
||||
// 显示局域网地址
|
||||
function notifyLanAddresses(ctx: any, port: number): void {
|
||||
const { networkInterfaces } = require("node:os");
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
@@ -304,7 +388,26 @@ async function stopByPort(port: number, ctx: any): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||
async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Promise<void> {
|
||||
if (isSystemdManaged()) {
|
||||
const state = await probePort(port);
|
||||
if (state === "free") {
|
||||
clearPidFile();
|
||||
ctx.ui.notify("网页服务未运行", "info");
|
||||
return;
|
||||
}
|
||||
const result = systemdControl("stop");
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`systemctl stop 失败: ${result.output}`, "error");
|
||||
return;
|
||||
}
|
||||
await waitForPortFree(port);
|
||||
clearPidFile();
|
||||
serverPort = 0;
|
||||
ctx.ui.notify(`网页服务已停止(systemd,端口 ${port})`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverProcess) {
|
||||
serverProcess.kill("SIGTERM");
|
||||
serverProcess = null;
|
||||
@@ -338,23 +441,78 @@ async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||
ctx.ui.notify("网页服务未运行", "info");
|
||||
}
|
||||
|
||||
async function reloadServer(ctx: any, port: number): Promise<void> {
|
||||
if (isSystemdManaged() && systemdConfig) {
|
||||
if (!(await ensureBuilt(ctx))) return;
|
||||
syncSystemdPort(systemdConfig, port);
|
||||
const result = systemdControl("restart");
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`systemctl restart 失败: ${result.output}`, "error");
|
||||
return;
|
||||
}
|
||||
serverPort = port;
|
||||
try {
|
||||
await waitForStartupByProbe(port);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`网页服务重载失败: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(`网页服务已重载(systemd,端口 ${port})`, "info");
|
||||
notifyLanAddresses(ctx, port);
|
||||
return;
|
||||
}
|
||||
|
||||
await stopServer(ctx, port);
|
||||
await startServer(port, ctx);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const repoRoot = findRepoRoot();
|
||||
systemdConfig = {
|
||||
extensionDir: __dirname,
|
||||
repoRoot,
|
||||
agentDir: getAgentDir(repoRoot),
|
||||
nodeBin: process.execPath,
|
||||
tsxCli: join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
};
|
||||
|
||||
if (ensureSystemdService(systemdConfig, serverPort || DEFAULT_WEBUI_PORT)) {
|
||||
console.log(`[webui] 已注册 systemd 保活服务: ${WEBUI_SERVICE_NAME}`);
|
||||
} else {
|
||||
const reason = getSystemdDisabledReason();
|
||||
if (reason) {
|
||||
console.warn(`[webui] systemd 保活不可用,回退进程内管理: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
pi.registerCommand("webui", {
|
||||
description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面",
|
||||
description: "通过 /webui on 启动、/webui off|down 停止、/webui reload 重载 Web 聊天界面",
|
||||
handler: async (args, ctx) => {
|
||||
const trimmed = args.trim();
|
||||
const [command = "", value = ""] = trimmed.split(/\s+/, 2);
|
||||
|
||||
if (command === "off" || command === "stop" || command === "0") {
|
||||
const stopPort = value ? parseInt(value, 10) : serverPort || 19133;
|
||||
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : 19133);
|
||||
if (command === "off" || command === "stop" || command === "down" || command === "0") {
|
||||
const stopPort = value ? parseInt(value, 10) : serverPort || DEFAULT_WEBUI_PORT;
|
||||
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : DEFAULT_WEBUI_PORT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "reload" || command === "restart") {
|
||||
const portArg = value ? parseInt(value, 10) : NaN;
|
||||
const reloadPort =
|
||||
Number.isFinite(portArg) && portArg >= 1 && portArg <= 65535
|
||||
? portArg
|
||||
: serverPort || DEFAULT_WEBUI_PORT;
|
||||
ctx.ui.notify(`正在重载 WebUI(端口 ${reloadPort})…`, "info");
|
||||
await reloadServer(ctx, reloadPort);
|
||||
return;
|
||||
}
|
||||
|
||||
const portInput = command === "on" ? value : command;
|
||||
const port = portInput ? parseInt(portInput, 10) : 19133;
|
||||
const port = portInput ? parseInt(portInput, 10) : DEFAULT_WEBUI_PORT;
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error");
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 ${DEFAULT_WEBUI_PORT}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
10
.pi/agent/extensions/webui/scripts/webui-run.sh
Executable file
10
.pi/agent/extensions/webui/scripts/webui-run.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
: "${REPO_ROOT:?REPO_ROOT required}"
|
||||
: "${EXTENSION_DIR:?EXTENSION_DIR required}"
|
||||
: "${NODE_BIN:?NODE_BIN required}"
|
||||
: "${TSX_CLI:?TSX_CLI required}"
|
||||
: "${PI_CODING_AGENT_DIR:?PI_CODING_AGENT_DIR required}"
|
||||
PORT="${PORT:-19133}"
|
||||
cd "$REPO_ROOT"
|
||||
exec "$NODE_BIN" "$TSX_CLI" "$EXTENSION_DIR/backend/main.ts" --port "$PORT"
|
||||
@@ -1,928 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* pi-mono WebUI Server
|
||||
*
|
||||
* 被 webui 扩展启动,作为独立子进程运行。
|
||||
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
|
||||
*
|
||||
* 用法(由扩展自动调用):
|
||||
* tsx server.ts --port 19133
|
||||
*
|
||||
* 前端静态文件位于同目录 dist/ 下(Vite 构建产物)。
|
||||
* 兼容旧启动路径:pi 扩展未重载时 index.ts 仍可能 spawn server.ts。
|
||||
* 实际逻辑在 backend/main.ts。
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { appendFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname, resolve, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
closeWebuiDatabase,
|
||||
deleteWebuiConfig,
|
||||
getAllWebuiConfig,
|
||||
getWebuiConfig,
|
||||
initWebuiDatabase,
|
||||
prunePinnedSessionPaths,
|
||||
readWebuiAvatarSettings,
|
||||
removePinnedSessionPath,
|
||||
setSessionPinned,
|
||||
setWebuiConfig,
|
||||
setWebuiConfigMany,
|
||||
writeWebuiAvatarSettings,
|
||||
} from "./db.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, "dist");
|
||||
const PID_FILE = join(__dirname, ".webui.pid");
|
||||
|
||||
// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录)
|
||||
const REPO_ROOT = process.cwd();
|
||||
const AGENT_DIR = resolve(process.env.PI_CODING_AGENT_DIR || join(REPO_ROOT, ".pi", "agent"));
|
||||
const SESSIONS_DIR = join(AGENT_DIR, "sessions");
|
||||
const AGENT_EXTENSIONS_DIR = join(AGENT_DIR, "extensions");
|
||||
const SYSTEM_PROMPT_FILE = join(AGENT_DIR, "AGENTS.md");
|
||||
const MCP_CACHE_FILE = join(AGENT_DIR, "mcp-cache.json");
|
||||
const MCP_CONFIG_FILE = join(AGENT_DIR, "mcp.json");
|
||||
const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx");
|
||||
const PI_CLI_DIST = join(REPO_ROOT, "packages", "coding-agent", "dist", "cli.js");
|
||||
const PI_CLI_SRC = join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts");
|
||||
|
||||
function resolvePiRpcLaunch(): { command: string; args: string[]; mode: "dist" | "src" } {
|
||||
const rpcArgs = ["--mode", "rpc"];
|
||||
if (existsSync(PI_CLI_DIST)) {
|
||||
return { command: process.execPath, args: [PI_CLI_DIST, ...rpcArgs], mode: "dist" };
|
||||
}
|
||||
console.warn(`[webui] 未找到 ${PI_CLI_DIST},回退 tsx 源码启动(较慢)。请运行: npm run build --workspace=@earendil-works/pi-coding-agent`);
|
||||
return { command: TSX_BIN, args: [PI_CLI_SRC, ...rpcArgs], mode: "src" };
|
||||
}
|
||||
|
||||
// ─── 解析 CLI 参数 ─────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function getArg(flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 ? args[idx + 1] : undefined;
|
||||
}
|
||||
const PORT = parseInt(getArg("--port") || "19133", 10);
|
||||
|
||||
function writePidFile(): void {
|
||||
try {
|
||||
writeFileSync(PID_FILE, String(process.pid), "utf8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function clearPidFile(): void {
|
||||
try {
|
||||
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown(exitCode = 0): never {
|
||||
closeWebuiDatabase();
|
||||
clearPidFile();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const webuiDb = initWebuiDatabase(__dirname);
|
||||
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
|
||||
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
writePidFile();
|
||||
|
||||
// ─── 启动 pi RPC 子进程 ────────────────────────────────────────────
|
||||
|
||||
const piLaunch = resolvePiRpcLaunch();
|
||||
|
||||
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
|
||||
|
||||
const pi = spawn(piLaunch.command, piLaunch.args, {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: AGENT_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
|
||||
pi.on("exit", (code) => {
|
||||
console.log(`[webui] pi 退出, code=${code}`);
|
||||
shutdown(1);
|
||||
});
|
||||
|
||||
// ─── JSONL 行协议 ──────────────────────────────────────────────────
|
||||
|
||||
let buffer = "";
|
||||
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
||||
const sseClients = new Set<any>();
|
||||
let reqId = 0;
|
||||
|
||||
function onLine(line: string) {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.type === "response" && msg.id && pending.has(msg.id)) {
|
||||
const p = pending.get(msg.id)!;
|
||||
pending.delete(msg.id);
|
||||
p.resolve(msg);
|
||||
return;
|
||||
}
|
||||
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
||||
for (const res of sseClients) res.write(data);
|
||||
} catch {
|
||||
// 忽略非 JSON 行
|
||||
}
|
||||
}
|
||||
|
||||
pi.stdout.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) onLine(line);
|
||||
});
|
||||
|
||||
function sendCmd(command: Record<string, unknown>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++reqId}`;
|
||||
const line = JSON.stringify({ ...command, id }) + "\n";
|
||||
pending.set(id, { resolve, reject });
|
||||
pi.stdin.write(line);
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`命令超时: ${command.type}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 会话文件读取 ──────────────────────────────────────────────────
|
||||
|
||||
function listSessionFiles(): string[] {
|
||||
if (!existsSync(SESSIONS_DIR)) 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(SESSIONS_DIR);
|
||||
return files
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function readSessionSummary(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;
|
||||
}
|
||||
}
|
||||
|
||||
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 readSessionMessages(filePath: string): unknown[] {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
return content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } })
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 any).mimeType || "");
|
||||
const data = String((raw as any).data || "");
|
||||
if ((raw as any).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;
|
||||
}
|
||||
|
||||
function resolveSessionFile(filePath: string): string {
|
||||
const resolved = resolve(filePath);
|
||||
const sessionsRoot = resolve(SESSIONS_DIR);
|
||||
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
|
||||
throw new Error("无效会话路径");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function appendSessionName(filePath: string, name: string): string {
|
||||
const sessionPath = resolveSessionFile(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 readSystemPrompt(): string {
|
||||
if (!existsSync(SYSTEM_PROMPT_FILE)) return "";
|
||||
return readFileSync(SYSTEM_PROMPT_FILE, "utf8");
|
||||
}
|
||||
|
||||
function writeSystemPrompt(content: string): void {
|
||||
mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true });
|
||||
writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeConfigValue(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("配置值必须是字符串");
|
||||
if (value.length > 65536) throw new Error("配置值过长");
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSkillCommand(command: any): Record<string, unknown> {
|
||||
const rawName = String(command.name || "");
|
||||
const sourceInfo = command.sourceInfo || {};
|
||||
return {
|
||||
name: rawName.replace(/^skill:/, ""),
|
||||
command: rawName,
|
||||
description: command.description || "",
|
||||
scope: sourceInfo.scope || "",
|
||||
source: sourceInfo.source || "",
|
||||
path: sourceInfo.path || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function listLoadedSkills(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_commands" });
|
||||
if (!response.success) throw new Error(response.error || "读取 skills 失败");
|
||||
const commands = response.data?.commands || [];
|
||||
return commands
|
||||
.filter((command: any) => command?.source === "skill")
|
||||
.map(normalizeSkillCommand)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
function normalizeExtension(extension: any): Record<string, unknown> {
|
||||
const sourceInfo = extension.sourceInfo || {};
|
||||
const pathValue = String(extension.path || "");
|
||||
const source = String(sourceInfo.source || "");
|
||||
const resolvedPath = String(extension.resolvedPath || "");
|
||||
return {
|
||||
name: displayExtensionName(pathValue, source),
|
||||
rawName: extension.name || "",
|
||||
path: pathValue,
|
||||
resolvedPath,
|
||||
scope: sourceInfo.scope || "",
|
||||
source,
|
||||
sourcePath: sourceInfo.path || "",
|
||||
location: displayExtensionLocation(pathValue, resolvedPath),
|
||||
kind: displayExtensionKind(sourceInfo.scope, source),
|
||||
commands: extension.commands || [],
|
||||
tools: extension.tools || [],
|
||||
flags: extension.flags || [],
|
||||
shortcuts: extension.shortcuts || [],
|
||||
handlers: extension.handlers || [],
|
||||
};
|
||||
}
|
||||
|
||||
function isProjectExtension(extension: any): boolean {
|
||||
const pathValue = String(extension.path || extension.resolvedPath || "");
|
||||
if (!pathValue) return false;
|
||||
const resolved = resolve(pathValue);
|
||||
const agentExtensionsRoot = resolve(AGENT_EXTENSIONS_DIR);
|
||||
return resolved === agentExtensionsRoot || resolved.startsWith(`${agentExtensionsRoot}/`);
|
||||
}
|
||||
|
||||
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 cleanExtensionName(name: string): string {
|
||||
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
|
||||
}
|
||||
|
||||
function displayExtensionKind(scope: string, source: string): string {
|
||||
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知";
|
||||
if (source.startsWith("npm:")) return `${scopeText} NPM`;
|
||||
if (source === "auto") return `${scopeText}本地`;
|
||||
return source ? `${scopeText} · ${source}` : scopeText;
|
||||
}
|
||||
|
||||
function displayExtensionLocation(extensionPath: string, resolvedPath: string): string {
|
||||
const pathValue = extensionPath || resolvedPath;
|
||||
if (!pathValue) return "";
|
||||
return pathValue.replace(REPO_ROOT, ".");
|
||||
}
|
||||
|
||||
async function listLoadedExtensions(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_extensions" });
|
||||
if (!response.success) throw new Error(response.error || "读取扩展失败");
|
||||
return (response.data?.extensions || [])
|
||||
.filter(isProjectExtension)
|
||||
.map(normalizeExtension)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
function readJsonFile(filePath: string): any | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMcpConfig(): any {
|
||||
const projectConfig = readJsonFile(MCP_CONFIG_FILE);
|
||||
if (projectConfig?.mcpServers && typeof projectConfig.mcpServers === "object") {
|
||||
return projectConfig;
|
||||
}
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
function normalizeMcpTool(serverName: string, tool: any): Record<string, unknown> {
|
||||
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) : [];
|
||||
return {
|
||||
server: serverName,
|
||||
name: String(tool?.name || ""),
|
||||
description: String(tool?.description || ""),
|
||||
parameters: properties,
|
||||
required,
|
||||
hasSchema: Object.keys(schema).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function listMcpTools(): Record<string, unknown>[] {
|
||||
const config = readMcpConfig();
|
||||
const cache = readJsonFile(MCP_CACHE_FILE);
|
||||
const configuredServers = config?.mcpServers && typeof config.mcpServers === "object"
|
||||
? Object.keys(config.mcpServers)
|
||||
: [];
|
||||
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
|
||||
const serverNames = Array.from(new Set([...configuredServers, ...Object.keys(cachedServers)])).sort();
|
||||
|
||||
return serverNames.map((serverName) => {
|
||||
const entry = cachedServers[serverName] || {};
|
||||
const tools = Array.isArray(entry.tools)
|
||||
? entry.tools.map((tool: any) => normalizeMcpTool(serverName, tool)).filter((tool: any) => tool.name)
|
||||
: [];
|
||||
const resources = Array.isArray(entry.resources) ? entry.resources : [];
|
||||
return {
|
||||
name: serverName,
|
||||
configured: configuredServers.includes(serverName),
|
||||
cached: Boolean(cachedServers[serverName]),
|
||||
toolCount: tools.length,
|
||||
resourceCount: resources.length,
|
||||
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
|
||||
tools: tools.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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)),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSessionListResponse() {
|
||||
const summaries = listSessionFiles()
|
||||
.map(readSessionSummary)
|
||||
.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,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── HTTP 服务 ─────────────────────────────────────────────────────
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
function serveStatic(urlPath: string, res: any, spaFallback = false): void {
|
||||
const file = urlPath === "/" ? "/index.html" : urlPath;
|
||||
const full = join(PUBLIC_DIR, file);
|
||||
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; }
|
||||
if (!existsSync(full)) {
|
||||
if (spaFallback && !urlPath.includes(".")) {
|
||||
return serveStatic("/index.html", res, false);
|
||||
}
|
||||
res.writeHead(404); res.end("Not found"); return;
|
||||
}
|
||||
const ext = file.match(/\.\w+$/)?.[0] || ".html";
|
||||
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
||||
res.end(readFileSync(full));
|
||||
}
|
||||
|
||||
function json(res: any, data: unknown, status = 200): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function readBody(req: any): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => (body += c.toString()));
|
||||
req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost:${PORT}`);
|
||||
|
||||
// 静态文件
|
||||
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
||||
return serveStatic(url.pathname, res, true);
|
||||
}
|
||||
|
||||
// POST /api/chat
|
||||
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||
return readBody(req)
|
||||
.then(({ message, images }) => {
|
||||
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`);
|
||||
return sendCmd({ type: "prompt", message: msg, images: imgs }).then(() => json(res, { ok: true }));
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/events (SSE)
|
||||
if (req.method === "GET" && url.pathname === "/api/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write('data: {"type":"connected"}\n\n');
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/messages
|
||||
if (req.method === "POST" && url.pathname === "/api/messages") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// POST /api/new-session
|
||||
if (req.method === "POST" && url.pathname === "/api/new-session") {
|
||||
return sendCmd({ type: "new_session" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/abort
|
||||
if (req.method === "POST" && url.pathname === "/api/abort") {
|
||||
return sendCmd({ type: "abort" })
|
||||
.then(() => json(res, { ok: true }))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/models
|
||||
if (req.method === "GET" && url.pathname === "/api/models") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// POST /api/model
|
||||
if (req.method === "POST" && url.pathname === "/api/model") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// POST /api/thinking
|
||||
if (req.method === "POST" && url.pathname === "/api/thinking") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// GET /api/avatars
|
||||
if (req.method === "GET" && url.pathname === "/api/avatars") {
|
||||
return json(res, readWebuiAvatarSettings());
|
||||
}
|
||||
|
||||
// GET /api/webui/config
|
||||
if (req.method === "GET" && url.pathname === "/api/webui/config") {
|
||||
const key = url.searchParams.get("key");
|
||||
if (key) {
|
||||
const normalized = normalizeConfigKey(key);
|
||||
return json(res, { key: normalized, value: getWebuiConfig(normalized) });
|
||||
}
|
||||
return json(res, { config: getAllWebuiConfig(), dbPath: webuiDb.dbPath });
|
||||
}
|
||||
|
||||
// POST /api/webui/config
|
||||
if (req.method === "POST" && url.pathname === "/api/webui/config") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// POST /api/webui/config/delete
|
||||
if (req.method === "POST" && url.pathname === "/api/webui/config/delete") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// GET /api/settings
|
||||
if (req.method === "GET" && url.pathname === "/api/settings") {
|
||||
return Promise.all([listLoadedSkills(), listLoadedExtensions()])
|
||||
.then(([skills, extensions]) => json(res, {
|
||||
...readWebuiAvatarSettings(),
|
||||
webuiDbPath: webuiDb.dbPath,
|
||||
systemPrompt: readSystemPrompt(),
|
||||
systemPromptPath: SYSTEM_PROMPT_FILE,
|
||||
extensionsPath: AGENT_EXTENSIONS_DIR,
|
||||
mcpCachePath: MCP_CACHE_FILE,
|
||||
mcpConfigPath: MCP_CONFIG_FILE,
|
||||
skills,
|
||||
extensions,
|
||||
mcpTools: listMcpTools(),
|
||||
}))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/reload
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/reload") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// POST /api/settings/system-prompt
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") {
|
||||
return readBody(req)
|
||||
.then(async ({ systemPrompt }) => {
|
||||
if (typeof systemPrompt !== "string") {
|
||||
throw new Error("systemPrompt 必须是字符串");
|
||||
}
|
||||
writeSystemPrompt(systemPrompt);
|
||||
const reload = await sendCmd({ type: "reload" });
|
||||
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
|
||||
json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/avatars
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/avatars") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
// GET /api/sessions
|
||||
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
||||
return json(res, buildSessionListResponse());
|
||||
}
|
||||
|
||||
// POST /api/sessions/history
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/history") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const messages = readSessionMessages(sp as string);
|
||||
const summary = readSessionSummary(sp as string);
|
||||
json(res, { messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/delete
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const sessionPath = resolveSessionFile(sp as string);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
unlinkSync(sessionPath);
|
||||
removePinnedSessionPath(sessionPath);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/pin
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/pin") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp, pinned }) => {
|
||||
const sessionPath = resolveSessionFile(sp as string);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
const pinnedPaths = setSessionPinned(sessionPath, Boolean(pinned));
|
||||
json(res, { ok: true, path: sessionPath, pinned: Boolean(pinned), pinnedPaths });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/load
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/load") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
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(sp as string);
|
||||
json(res, { messages: mr.data.messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/activate
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/activate") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
if (!sw.success) throw new Error(sw.error);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/name
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/name") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp, name }) => {
|
||||
if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
|
||||
const savedName = appendSessionName(sp, typeof name === "string" ? name : "");
|
||||
const sessionPath = resolveSessionFile(sp);
|
||||
const state = await sendCmd({ type: "get_state" });
|
||||
if (state.success && 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));
|
||||
}
|
||||
|
||||
// GET /api/session-state
|
||||
if (req.method === "GET" && url.pathname === "/api/session-state") {
|
||||
return 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));
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
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://smallmengya:${PORT}`);
|
||||
});
|
||||
import "./backend/main.ts";
|
||||
|
||||
168
.pi/agent/extensions/webui/systemd/service.ts
Normal file
168
.pi/agent/extensions/webui/systemd/service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export const WEBUI_SERVICE_NAME = "sproutclaw-webui.service";
|
||||
const SYSTEM_UNIT_PATH = `/etc/systemd/system/${WEBUI_SERVICE_NAME}`;
|
||||
export const DEFAULT_WEBUI_PORT = 19133;
|
||||
|
||||
export interface SystemdServiceConfig {
|
||||
extensionDir: string;
|
||||
repoRoot: string;
|
||||
agentDir: string;
|
||||
nodeBin: string;
|
||||
tsxCli: string;
|
||||
}
|
||||
|
||||
let systemdReady = false;
|
||||
let systemdDisabledReason: string | null = null;
|
||||
|
||||
function runSystemctl(args: string[]): { ok: boolean; output: string } {
|
||||
const result = spawnSync("systemctl", args, { encoding: "utf8" });
|
||||
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
||||
return { ok: result.status === 0, output };
|
||||
}
|
||||
|
||||
function isSystemctlAvailable(): boolean {
|
||||
return spawnSync("systemctl", ["--version"], { encoding: "utf8" }).status === 0;
|
||||
}
|
||||
|
||||
function isServiceRegistered(): boolean {
|
||||
const result = runSystemctl(["list-unit-files", WEBUI_SERVICE_NAME, "--no-legend"]);
|
||||
return result.ok && result.output.length > 0 && !result.output.includes("0 unit files");
|
||||
}
|
||||
|
||||
function envFilePath(extensionDir: string): string {
|
||||
return join(extensionDir, "data", "webui-service.env");
|
||||
}
|
||||
|
||||
function runScriptPath(extensionDir: string): string {
|
||||
return join(extensionDir, "scripts", "webui-run.sh");
|
||||
}
|
||||
|
||||
function writeIfChanged(path: string, content: string): boolean {
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
if (readFileSync(path, "utf8") === content) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, content, "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildEnvContent(config: SystemdServiceConfig, port: number): string {
|
||||
return [
|
||||
`REPO_ROOT=${config.repoRoot}`,
|
||||
`EXTENSION_DIR=${config.extensionDir}`,
|
||||
`NODE_BIN=${config.nodeBin}`,
|
||||
`TSX_CLI=${config.tsxCli}`,
|
||||
`PI_CODING_AGENT_DIR=${config.agentDir}`,
|
||||
`PORT=${port}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRunScript(): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
: "\${REPO_ROOT:?REPO_ROOT required}"
|
||||
: "\${EXTENSION_DIR:?EXTENSION_DIR required}"
|
||||
: "\${NODE_BIN:?NODE_BIN required}"
|
||||
: "\${TSX_CLI:?TSX_CLI required}"
|
||||
: "\${PI_CODING_AGENT_DIR:?PI_CODING_AGENT_DIR required}"
|
||||
PORT="\${PORT:-19133}"
|
||||
cd "\$REPO_ROOT"
|
||||
exec "\$NODE_BIN" "\$TSX_CLI" "\$EXTENSION_DIR/backend/main.ts" --port "\$PORT"
|
||||
`;
|
||||
}
|
||||
|
||||
function buildUnitFile(config: SystemdServiceConfig): string {
|
||||
const envFile = envFilePath(config.extensionDir);
|
||||
const runScript = runScriptPath(config.extensionDir);
|
||||
return `[Unit]
|
||||
Description=SproutClaw WebUI HTTP Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-${envFile}
|
||||
ExecStart=${runScript}
|
||||
WorkingDirectory=${config.repoRoot}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`;
|
||||
}
|
||||
|
||||
export function isSystemdManaged(): boolean {
|
||||
return systemdReady;
|
||||
}
|
||||
|
||||
export function getSystemdDisabledReason(): string | null {
|
||||
return systemdDisabledReason;
|
||||
}
|
||||
|
||||
export function syncSystemdPort(config: SystemdServiceConfig, port: number): void {
|
||||
mkdirSync(join(config.extensionDir, "data"), { recursive: true });
|
||||
writeIfChanged(envFilePath(config.extensionDir), `${buildEnvContent(config, port)}\n`);
|
||||
}
|
||||
|
||||
export function ensureSystemdService(
|
||||
config: SystemdServiceConfig,
|
||||
port = DEFAULT_WEBUI_PORT,
|
||||
): boolean {
|
||||
if (!isSystemctlAvailable()) {
|
||||
systemdDisabledReason = "systemctl 不可用";
|
||||
systemdReady = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(config.nodeBin)) {
|
||||
throw new Error(`Node 未找到: ${config.nodeBin}`);
|
||||
}
|
||||
if (!existsSync(config.tsxCli)) {
|
||||
throw new Error(`tsx 未找到: ${config.tsxCli}`);
|
||||
}
|
||||
|
||||
mkdirSync(join(config.extensionDir, "data"), { recursive: true });
|
||||
mkdirSync(join(config.extensionDir, "scripts"), { recursive: true });
|
||||
|
||||
const envChanged = writeIfChanged(envFilePath(config.extensionDir), `${buildEnvContent(config, port)}\n`);
|
||||
const scriptChanged = writeIfChanged(runScriptPath(config.extensionDir), buildRunScript());
|
||||
chmodSync(runScriptPath(config.extensionDir), 0o755);
|
||||
const unitChanged = writeIfChanged(SYSTEM_UNIT_PATH, buildUnitFile(config));
|
||||
|
||||
if (unitChanged || scriptChanged || envChanged || !isServiceRegistered()) {
|
||||
runSystemctl(["daemon-reload"]);
|
||||
}
|
||||
|
||||
if (!isServiceRegistered()) {
|
||||
const enable = runSystemctl(["enable", WEBUI_SERVICE_NAME]);
|
||||
if (!enable.ok) {
|
||||
throw new Error(enable.output || "systemctl enable 失败");
|
||||
}
|
||||
}
|
||||
|
||||
systemdReady = true;
|
||||
systemdDisabledReason = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
systemdReady = false;
|
||||
systemdDisabledReason = err instanceof Error ? err.message : String(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function systemdControl(action: "start" | "stop" | "restart"): { ok: boolean; output: string } {
|
||||
if (!systemdReady) {
|
||||
return { ok: false, output: systemdDisabledReason ?? "systemd 未就绪" };
|
||||
}
|
||||
return runSystemctl([action, WEBUI_SERVICE_NAME]);
|
||||
}
|
||||
54
.pi/agent/prompts/cl.md
Normal file
54
.pi/agent/prompts/cl.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Find the last release tag:**
|
||||
```bash
|
||||
git tag --sort=-version:refname | head -1
|
||||
```
|
||||
|
||||
2. **List all commits since that tag:**
|
||||
```bash
|
||||
git log <tag>..HEAD --oneline
|
||||
```
|
||||
|
||||
3. **Read each package's [Unreleased] section:**
|
||||
- packages/ai/CHANGELOG.md
|
||||
- packages/tui/CHANGELOG.md
|
||||
- packages/coding-agent/CHANGELOG.md
|
||||
|
||||
4. **For each commit, check:**
|
||||
- Skip: changelog updates, doc-only changes, release housekeeping
|
||||
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
|
||||
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
||||
- Verify a changelog entry exists in the affected package(s)
|
||||
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
||||
|
||||
5. **Cross-package duplication rule:**
|
||||
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
||||
|
||||
6. **Add New Features section after changelog fixes:**
|
||||
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
||||
- Propose the top new features to the user for confirmation before writing them.
|
||||
- Link to relevant docs and sections whenever possible.
|
||||
|
||||
7. **Report:**
|
||||
- List commits with missing entries
|
||||
- List entries that need cross-package duplication
|
||||
- Add any missing entries directly
|
||||
|
||||
## Changelog Format Reference
|
||||
|
||||
Sections (in order):
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
Attribution:
|
||||
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
|
||||
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`
|
||||
1
.pi/agent/prompts/代码注释改中文.md
Normal file
1
.pi/agent/prompts/代码注释改中文.md
Normal file
@@ -0,0 +1 @@
|
||||
代码注释给我默认使用中文注释,不要使用英文注释,如果原本有英文注释,请给我改成中文注释
|
||||
1
.pi/agent/prompts/构建项目启动脚本.md
Normal file
1
.pi/agent/prompts/构建项目启动脚本.md
Normal file
@@ -0,0 +1 @@
|
||||
给我在项目根目录写一键启动本地开发前后端bat脚本和bash脚本,脚本名叫"dev",和构建前端脚本,脚本名叫:"build",注意保持代码精简不啰嗦,注意在Windows上显示前后端要两个窗口显示
|
||||
1
.pi/agent/prompts/项目起名.md
Normal file
1
.pi/agent/prompts/项目起名.md
Normal file
@@ -0,0 +1 @@
|
||||
给我这个项目起个简单通俗易懂的英文名
|
||||
39
.pi/agent/skills/README.md
Normal file
39
.pi/agent/skills/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Skills
|
||||
|
||||
这里是本机通用 Skills 目录,给大模型统一放可复用的技能包、流程说明和模板。
|
||||
|
||||
## 目录约定
|
||||
|
||||
- 每个 Skill 单独一个子目录
|
||||
- 入口文件通常使用 `SKILL.md`
|
||||
- 可按需包含 `references/`、`scripts/`、`assets/` 等辅助目录
|
||||
- Skill 内尽量写清楚触发条件、输入输出、约束和操作步骤
|
||||
|
||||
## 推荐结构
|
||||
|
||||
```text
|
||||
/root/skills/
|
||||
your-skill/
|
||||
SKILL.md
|
||||
references/
|
||||
scripts/
|
||||
assets/
|
||||
```
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 目前这个目录已作为小萌芽本机的日常开发主目录使用
|
||||
- 先在这里维护、测试和迭代 Skill,确认稳定后再同步到 `~/.codex/skills/` 和 `~/.claude/skills/`
|
||||
- 当前已放入的自定义 Skill:
|
||||
- `command-help-zh-skill`
|
||||
- `mengya-mail-skill`
|
||||
- `mengya-search-skill`
|
||||
- `linux-ssh-operator-skill`
|
||||
- 前两个 Skill 的实现分别对接:
|
||||
- `/shumengya/project/python/mengya-mail-api/src/mengya_mail_api/`
|
||||
- `/shumengya/project/python/mengya-search-api/src/mengya_search_api/`
|
||||
- `linux-ssh-operator-skill` 的脚本与说明位于:
|
||||
- `/shumengya/project/skills/linux-ssh-operator-skill/`
|
||||
- 如果某些 Skill 需要接入特定工具或工作流,建议在 `SKILL.md` 顶部先写清适用范围
|
||||
|
||||
当前入口请直接走 `skills/...` 和对应的 `python/*-api/`。
|
||||
48
.pi/agent/skills/command-help-zh-skill/SKILL.md
Normal file
48
.pi/agent/skills/command-help-zh-skill/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: command-help-zh-skill
|
||||
description: 为任意 CLI 命令创建独立、无依赖的 Bash 包装脚本,将帮助文本翻译成中文,并只把说明性描述高亮为黄色。适用于用户希望获得本地化的 `--help` 体验,同时保持命令名称和参数不着色的场景。
|
||||
---
|
||||
|
||||
# 命令帮助中文高亮
|
||||
|
||||
## 适用场景
|
||||
|
||||
用户要把任意命令的 `--help` / `-h` 帮助页做成中文版本,并且要求:
|
||||
|
||||
- 只高亮“介绍 / 描述”文字
|
||||
- 命令名、参数名、选项名保持普通颜色
|
||||
- 脚本必须是单文件 `bash`
|
||||
- 不能依赖复杂工具链,优先只用 `printf`、`case`、`cat`
|
||||
|
||||
## 处理流程
|
||||
|
||||
1. 先确认目标命令名,例如 `codex`、`git`、`tea`、`gh`,或由用户提供 `--help` 输出。
|
||||
2. 如果没有原始帮助内容,就先运行 `<command> --help` 获取输出。
|
||||
3. 翻译成自然、简洁的中文。
|
||||
4. 保留原始结构:
|
||||
- 标题
|
||||
- 简介
|
||||
- 用法
|
||||
- 命令
|
||||
- 参数
|
||||
- 选项
|
||||
5. 输出时只让说明文字使用 ANSI 黄色。
|
||||
- 例如 `\033[1;33m`
|
||||
- 结束后恢复默认颜色
|
||||
6. 命令名、参数、选项、括号内容保持普通颜色,不要整行染色。
|
||||
7. 生成可直接放到全局 PATH 的脚本,默认命名为 `<command>-help`;如果用户要求或命名冲突,再用 `<command>-help-zh`。
|
||||
|
||||
## 实现要求
|
||||
|
||||
- 必须是独立脚本,不能依赖仓库内外的其他文件。
|
||||
- 脚本开头应固定目标命令名,执行时再调用原始命令的 `--help`。
|
||||
- 推荐使用 `printf` 逐行输出,避免把整段文本一起染色。
|
||||
- 如果需要分隔“左侧标签”和“右侧说明”,先打印标签,再单独打印黄色说明。
|
||||
- 保持输出整洁,避免多余前导空格。
|
||||
|
||||
## 输出标准
|
||||
|
||||
- 中文表达自然,优先清晰准确。
|
||||
- 说明部分高亮黄色。
|
||||
- 可以直接复制到 `/usr/local/bin/` 之类的位置使用。
|
||||
- 如果用户指定了固定命令名,按用户要求命名。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Command Help Chinese Highlight"
|
||||
short_description: "Translate any CLI help text into Chinese with yellow-highlighted descriptions only"
|
||||
default_prompt: "Create a standalone no-dependency Bash help wrapper for the specified command. Translate its help text into Chinese, keep command names and arguments uncolored, and highlight only descriptions in yellow."
|
||||
42
.pi/agent/skills/frontend-design/SKILL.md
Normal file
42
.pi/agent/skills/frontend-design/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user