From cf5edd639451fce920308287940f9abad29805f3 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 10 Jun 2026 16:57:08 +0800 Subject: [PATCH] feat(sproutclaw): modularize webui, extensions, and agent config layout 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 --- .gitignore | 11 + .pi/agent/AGENTS.md | 45 +- .../index.ts} | 0 .../index.ts} | 0 .../{redraws.ts => redraws/index.ts} | 0 .pi/agent/extensions/sproutclaw-setup.ts | 63 - .../extensions/sproutclaw-setup/index.ts | 107 ++ .../index.ts} | 15 +- .../{status-line.ts => status-line/index.ts} | 0 .pi/agent/extensions/{tps.ts => tps/index.ts} | 0 .pi/agent/extensions/webui/.gitignore | 6 +- .../extensions/webui/backend/config/cli.ts | 9 + .../extensions/webui/backend/config/paths.ts | 56 + .../webui/{db.ts => backend/db/index.ts} | 0 .../extensions/webui/backend/http/cors.ts | 16 + .../extensions/webui/backend/http/request.ts | 23 + .../extensions/webui/backend/http/server.ts | 36 + .../extensions/webui/backend/http/static.ts | 83 ++ .pi/agent/extensions/webui/backend/main.ts | 83 ++ .../extensions/webui/backend/routes/chat.ts | 76 ++ .../webui/backend/routes/commands.ts | 19 + .../extensions/webui/backend/routes/models.ts | 43 + .../webui/backend/routes/sessions.ts | 124 ++ .../webui/backend/routes/settings.ts | 206 +++ .../webui/backend/routes/webui-config.ts | 70 + .../extensions/webui/backend/rpc/pi-client.ts | 203 +++ .../webui/backend/services/avatars.ts | 32 + .../webui/backend/services/chat-images.ts | 24 + .../backend/services/extensions-display.ts | 196 +++ .../webui/backend/services/models-config.ts | 24 + .../webui/backend/services/sessions.ts | 250 ++++ .../webui/backend/services/slash-commands.ts | 51 + .../webui/backend/services/system-prompt.ts | 13 + .../backend/settings/extension-settings.ts | 203 +++ .../backend/settings/extensions-paths.ts | 102 ++ .../webui/backend/settings/mcp-settings.ts | 245 ++++ .../webui/backend/settings/skills-settings.ts | 452 +++++++ .../webui/backend/slash/dispatch.ts | 246 ++++ .../extensions/webui/backend/types/context.ts | 31 + .../extensions/webui/frontend/.gitignore | 2 + .../extensions/webui/frontend/index.html | 9 - .../webui/frontend/package-lock.json | 16 +- .../extensions/webui/frontend/package.json | 5 +- .../extensions/webui/frontend/src/App.tsx | 23 +- .../extensions/webui/frontend/src/api/base.ts | 13 + .../extensions/webui/frontend/src/api/chat.ts | 20 +- .../webui/frontend/src/api/client.ts | 4 +- .../webui/frontend/src/api/commands.ts | 6 + .../webui/frontend/src/api/sessions.ts | 18 +- .../webui/frontend/src/api/settings.ts | 32 +- .../chat/BashOutputBlock.module.css | 84 ++ .../src/components/chat/BashOutputBlock.tsx | 34 + .../src/components/chat/ChatInput.tsx | 119 +- .../src/components/chat/ExportMenu.module.css | 59 +- .../src/components/chat/ExportMenu.tsx | 60 +- .../src/components/chat/MessageBubble.tsx | 37 + .../components/chat/MessageList.module.css | 37 +- .../src/components/chat/MessageList.tsx | 14 +- .../chat/SessionContextBar.module.css | 118 +- .../src/components/chat/SessionContextBar.tsx | 60 +- .../chat/SlashCommandMenu.module.css | 67 + .../src/components/chat/SlashCommandMenu.tsx | 49 + .../components/chat/ThinkingBlock.module.css | 80 ++ .../src/components/chat/ThinkingBlock.tsx | 23 + .../components/chat/ToolCallBlock.module.css | 4 +- .../src/components/layout/Header.module.css | 366 +++-- .../frontend/src/components/layout/Header.tsx | 163 ++- .../components/session/SessionList.module.css | 65 +- .../src/components/session/SessionList.tsx | 96 +- .../frontend/src/context/ChatContext.tsx | 1187 +++++++++++++---- .../extensions/webui/frontend/src/main.tsx | 1 + .../src/routes/SettingsPage.module.css | 195 ++- .../frontend/src/routes/SettingsPage.tsx | 547 ++++++-- .../webui/frontend/src/styles/global.css | 1 + .../webui/frontend/src/styles/variables.css | 13 +- .../webui/frontend/src/types/commands.ts | 11 + .../webui/frontend/src/types/events.ts | 52 +- .../webui/frontend/src/types/message.ts | 6 +- .../webui/frontend/src/utils/bash.ts | 39 + .../frontend/src/utils/exportConversation.ts | 14 +- .../webui/frontend/src/utils/sessionTitle.ts | 17 +- .../webui/frontend/src/utils/slashCommands.ts | 64 + .../webui/frontend/src/utils/toolCall.ts | 18 + .../webui/frontend/src/vite-env.d.ts | 8 + .../extensions/webui/frontend/vite.config.ts | 133 +- .pi/agent/extensions/webui/index.ts | 212 ++- .../extensions/webui/scripts/webui-run.sh | 10 + .pi/agent/extensions/webui/server.ts | 929 +------------ .pi/agent/extensions/webui/systemd/service.ts | 168 +++ .pi/agent/prompts/cl.md | 54 + .pi/{ => agent}/prompts/is.md | 0 .pi/{ => agent}/prompts/pr.md | 0 .pi/{ => agent}/prompts/wr.md | 0 .pi/agent/prompts/代码注释改中文.md | 1 + .pi/agent/prompts/构建项目启动脚本.md | 1 + .pi/agent/prompts/项目起名.md | 1 + .pi/agent/skills/README.md | 39 + .../skills/command-help-zh-skill/SKILL.md | 48 + .../command-help-zh-skill/agents/openai.yaml | 4 + .pi/agent/skills/frontend-design/SKILL.md | 42 + .../skills/gitea-tea-skill/.gitattributes | 1 + .pi/agent/skills/gitea-tea-skill/.gitignore | 4 + .pi/agent/skills/gitea-tea-skill/SKILL.md | 232 ++++ .pi/agent/skills/gitea-tea-skill/reference.md | 244 ++++ .../gitea-tea-skill/scripts/gitea_api.py | 260 ++++ .../gitea-tea-skill/scripts/tea_helper.py | 190 +++ .../skills/github-gh-skill/.gitattributes | 1 + .pi/agent/skills/github-gh-skill/.gitignore | 4 + .pi/agent/skills/github-gh-skill/SKILL.md | 86 ++ .pi/agent/skills/github-gh-skill/reference.md | 95 ++ .../github-gh-skill/scripts/check_gh.py | 84 ++ .../skills/linux-ssh-operator-skill/SKILL.md | 140 ++ .../linux-ssh-operator-skill/SKILL.zh-CN.md | 204 +++ .../agents/openai.yaml | 3 + .../references/ssh-playbook.md | 103 ++ .../scripts/ssh_alias_setup.sh | 208 +++ .../scripts/ssh_copy.sh | 131 ++ .../scripts/ssh_run.sh | 156 +++ .../skills}/skills/add-llm-provider.md | 0 README.md | 19 +- package-lock.json | 7 + packages/agent/src/harness/skills.ts | 12 +- packages/agent/test/harness/skills.test.ts | 16 + packages/coding-agent/npm-shrinkwrap.json | 6 +- packages/coding-agent/src/core/skills.ts | 13 +- .../src/modes/interactive/interactive-mode.ts | 190 ++- .../skills/skills-dir-readme/README.md | 3 + .../skills-dir-readme/nested-skill/SKILL.md | 6 + .../test/interactive-mode-status.test.ts | 105 +- packages/coding-agent/test/skills.test.ts | 11 + pi-built.sh | 64 + scripts/update-source-imports-to-ts.sh | 0 132 files changed, 9288 insertions(+), 1971 deletions(-) rename .pi/agent/extensions/{exit-command.ts => exit-command/index.ts} (100%) rename .pi/agent/extensions/{prompt-url-widget.ts => prompt-url-widget/index.ts} (100%) rename .pi/agent/extensions/{redraws.ts => redraws/index.ts} (100%) delete mode 100644 .pi/agent/extensions/sproutclaw-setup.ts create mode 100644 .pi/agent/extensions/sproutclaw-setup/index.ts rename .pi/agent/extensions/{startup-chinese.ts => startup-chinese/index.ts} (94%) rename .pi/agent/extensions/{status-line.ts => status-line/index.ts} (100%) rename .pi/agent/extensions/{tps.ts => tps/index.ts} (100%) create mode 100644 .pi/agent/extensions/webui/backend/config/cli.ts create mode 100644 .pi/agent/extensions/webui/backend/config/paths.ts rename .pi/agent/extensions/webui/{db.ts => backend/db/index.ts} (100%) create mode 100644 .pi/agent/extensions/webui/backend/http/cors.ts create mode 100644 .pi/agent/extensions/webui/backend/http/request.ts create mode 100644 .pi/agent/extensions/webui/backend/http/server.ts create mode 100644 .pi/agent/extensions/webui/backend/http/static.ts create mode 100644 .pi/agent/extensions/webui/backend/main.ts create mode 100644 .pi/agent/extensions/webui/backend/routes/chat.ts create mode 100644 .pi/agent/extensions/webui/backend/routes/commands.ts create mode 100644 .pi/agent/extensions/webui/backend/routes/models.ts create mode 100644 .pi/agent/extensions/webui/backend/routes/sessions.ts create mode 100644 .pi/agent/extensions/webui/backend/routes/settings.ts create mode 100644 .pi/agent/extensions/webui/backend/routes/webui-config.ts create mode 100644 .pi/agent/extensions/webui/backend/rpc/pi-client.ts create mode 100644 .pi/agent/extensions/webui/backend/services/avatars.ts create mode 100644 .pi/agent/extensions/webui/backend/services/chat-images.ts create mode 100644 .pi/agent/extensions/webui/backend/services/extensions-display.ts create mode 100644 .pi/agent/extensions/webui/backend/services/models-config.ts create mode 100644 .pi/agent/extensions/webui/backend/services/sessions.ts create mode 100644 .pi/agent/extensions/webui/backend/services/slash-commands.ts create mode 100644 .pi/agent/extensions/webui/backend/services/system-prompt.ts create mode 100644 .pi/agent/extensions/webui/backend/settings/extension-settings.ts create mode 100644 .pi/agent/extensions/webui/backend/settings/extensions-paths.ts create mode 100644 .pi/agent/extensions/webui/backend/settings/mcp-settings.ts create mode 100644 .pi/agent/extensions/webui/backend/settings/skills-settings.ts create mode 100644 .pi/agent/extensions/webui/backend/slash/dispatch.ts create mode 100644 .pi/agent/extensions/webui/backend/types/context.ts create mode 100644 .pi/agent/extensions/webui/frontend/src/api/base.ts create mode 100644 .pi/agent/extensions/webui/frontend/src/api/commands.ts create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.module.css create mode 100644 .pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.tsx create mode 100644 .pi/agent/extensions/webui/frontend/src/types/commands.ts create mode 100644 .pi/agent/extensions/webui/frontend/src/utils/bash.ts create mode 100644 .pi/agent/extensions/webui/frontend/src/utils/slashCommands.ts create mode 100755 .pi/agent/extensions/webui/scripts/webui-run.sh create mode 100644 .pi/agent/extensions/webui/systemd/service.ts create mode 100644 .pi/agent/prompts/cl.md rename .pi/{ => agent}/prompts/is.md (100%) rename .pi/{ => agent}/prompts/pr.md (100%) rename .pi/{ => agent}/prompts/wr.md (100%) create mode 100644 .pi/agent/prompts/代码注释改中文.md create mode 100644 .pi/agent/prompts/构建项目启动脚本.md create mode 100644 .pi/agent/prompts/项目起名.md create mode 100644 .pi/agent/skills/README.md create mode 100644 .pi/agent/skills/command-help-zh-skill/SKILL.md create mode 100644 .pi/agent/skills/command-help-zh-skill/agents/openai.yaml create mode 100644 .pi/agent/skills/frontend-design/SKILL.md create mode 100644 .pi/agent/skills/gitea-tea-skill/.gitattributes create mode 100644 .pi/agent/skills/gitea-tea-skill/.gitignore create mode 100644 .pi/agent/skills/gitea-tea-skill/SKILL.md create mode 100644 .pi/agent/skills/gitea-tea-skill/reference.md create mode 100755 .pi/agent/skills/gitea-tea-skill/scripts/gitea_api.py create mode 100644 .pi/agent/skills/gitea-tea-skill/scripts/tea_helper.py create mode 100644 .pi/agent/skills/github-gh-skill/.gitattributes create mode 100644 .pi/agent/skills/github-gh-skill/.gitignore create mode 100644 .pi/agent/skills/github-gh-skill/SKILL.md create mode 100644 .pi/agent/skills/github-gh-skill/reference.md create mode 100644 .pi/agent/skills/github-gh-skill/scripts/check_gh.py create mode 100644 .pi/agent/skills/linux-ssh-operator-skill/SKILL.md create mode 100644 .pi/agent/skills/linux-ssh-operator-skill/SKILL.zh-CN.md create mode 100644 .pi/agent/skills/linux-ssh-operator-skill/agents/openai.yaml create mode 100644 .pi/agent/skills/linux-ssh-operator-skill/references/ssh-playbook.md create mode 100644 .pi/agent/skills/linux-ssh-operator-skill/scripts/ssh_alias_setup.sh create mode 100644 .pi/agent/skills/linux-ssh-operator-skill/scripts/ssh_copy.sh create mode 100755 .pi/agent/skills/linux-ssh-operator-skill/scripts/ssh_run.sh rename .pi/{ => agent/skills}/skills/add-llm-provider.md (100%) create mode 100644 packages/coding-agent/test/fixtures/skills/skills-dir-readme/README.md create mode 100644 packages/coding-agent/test/fixtures/skills/skills-dir-readme/nested-skill/SKILL.md create mode 100755 pi-built.sh mode change 100644 => 100755 scripts/update-source-imports-to-ts.sh diff --git a/.gitignore b/.gitignore index 98ff27a9..6407e0bb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.pi/agent/AGENTS.md b/.pi/agent/AGENTS.md index 5d3904bb..4f99a217 100755 --- a/.pi/agent/AGENTS.md +++ b/.pi/agent/AGENTS.md @@ -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,避免过度堆叠 diff --git a/.pi/agent/extensions/exit-command.ts b/.pi/agent/extensions/exit-command/index.ts similarity index 100% rename from .pi/agent/extensions/exit-command.ts rename to .pi/agent/extensions/exit-command/index.ts diff --git a/.pi/agent/extensions/prompt-url-widget.ts b/.pi/agent/extensions/prompt-url-widget/index.ts similarity index 100% rename from .pi/agent/extensions/prompt-url-widget.ts rename to .pi/agent/extensions/prompt-url-widget/index.ts diff --git a/.pi/agent/extensions/redraws.ts b/.pi/agent/extensions/redraws/index.ts similarity index 100% rename from .pi/agent/extensions/redraws.ts rename to .pi/agent/extensions/redraws/index.ts diff --git a/.pi/agent/extensions/sproutclaw-setup.ts b/.pi/agent/extensions/sproutclaw-setup.ts deleted file mode 100644 index 451d9c94..00000000 --- a/.pi/agent/extensions/sproutclaw-setup.ts +++ /dev/null @@ -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"); - } - }, - }); -} diff --git a/.pi/agent/extensions/sproutclaw-setup/index.ts b/.pi/agent/extensions/sproutclaw-setup/index.ts new file mode 100644 index 00000000..600f089e --- /dev/null +++ b/.pi/agent/extensions/sproutclaw-setup/index.ts @@ -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 = { + 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"); + } + }, + }); +} diff --git a/.pi/agent/extensions/startup-chinese.ts b/.pi/agent/extensions/startup-chinese/index.ts similarity index 94% rename from .pi/agent/extensions/startup-chinese.ts rename to .pi/agent/extensions/startup-chinese/index.ts index ddb878d9..b3a82173 100644 --- a/.pi/agent/extensions/startup-chinese.ts +++ b/.pi/agent/extensions/startup-chinese/index.ts @@ -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); diff --git a/.pi/agent/extensions/status-line.ts b/.pi/agent/extensions/status-line/index.ts similarity index 100% rename from .pi/agent/extensions/status-line.ts rename to .pi/agent/extensions/status-line/index.ts diff --git a/.pi/agent/extensions/tps.ts b/.pi/agent/extensions/tps/index.ts similarity index 100% rename from .pi/agent/extensions/tps.ts rename to .pi/agent/extensions/tps/index.ts diff --git a/.pi/agent/extensions/webui/.gitignore b/.pi/agent/extensions/webui/.gitignore index 2438123d..45a4f775 100644 --- a/.pi/agent/extensions/webui/.gitignore +++ b/.pi/agent/extensions/webui/.gitignore @@ -1,4 +1,8 @@ -dist/ +frontend/dist/ +frontend/dist-desketop/ +frontend/.env.desktop data/ +webui-service.env *.db webui-settings.json.migrated +.webui.pid diff --git a/.pi/agent/extensions/webui/backend/config/cli.ts b/.pi/agent/extensions/webui/backend/config/cli.ts new file mode 100644 index 00000000..4b16b1e8 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/config/cli.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/config/paths.ts b/.pi/agent/extensions/webui/backend/config/paths.ts new file mode 100644 index 00000000..e85dc0d4 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/config/paths.ts @@ -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" }; +} diff --git a/.pi/agent/extensions/webui/db.ts b/.pi/agent/extensions/webui/backend/db/index.ts similarity index 100% rename from .pi/agent/extensions/webui/db.ts rename to .pi/agent/extensions/webui/backend/db/index.ts diff --git a/.pi/agent/extensions/webui/backend/http/cors.ts b/.pi/agent/extensions/webui/backend/http/cors.ts new file mode 100644 index 00000000..d508c596 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/http/cors.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/http/request.ts b/.pi/agent/extensions/webui/backend/http/request.ts new file mode 100644 index 00000000..2b483b9e --- /dev/null +++ b/.pi/agent/extensions/webui/backend/http/request.ts @@ -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> { + 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); + }); +} diff --git a/.pi/agent/extensions/webui/backend/http/server.ts b/.pi/agent/extensions/webui/backend/http/server.ts new file mode 100644 index 00000000..95d856a2 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/http/server.ts @@ -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"); + }); +} diff --git a/.pi/agent/extensions/webui/backend/http/static.ts b/.pi/agent/extensions/webui/backend/http/static.ts new file mode 100644 index 00000000..971f84ee --- /dev/null +++ b/.pi/agent/extensions/webui/backend/http/static.ts @@ -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 = { + ".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 = { + "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); +} diff --git a/.pi/agent/extensions/webui/backend/main.ts b/.pi/agent/extensions/webui/backend/main.ts new file mode 100644 index 00000000..8d79a20b --- /dev/null +++ b/.pi/agent/extensions/webui/backend/main.ts @@ -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}`); +}); diff --git a/.pi/agent/extensions/webui/backend/routes/chat.ts b/.pi/agent/extensions/webui/backend/routes/chat.ts new file mode 100644 index 00000000..b483b1e1 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/routes/chat.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/routes/commands.ts b/.pi/agent/extensions/webui/backend/routes/commands.ts new file mode 100644 index 00000000..3d071231 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/routes/commands.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/routes/models.ts b/.pi/agent/extensions/webui/backend/routes/models.ts new file mode 100644 index 00000000..e4fe5932 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/routes/models.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/routes/sessions.ts b/.pi/agent/extensions/webui/backend/routes/sessions.ts new file mode 100644 index 00000000..06f14ced --- /dev/null +++ b/.pi/agent/extensions/webui/backend/routes/sessions.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/routes/settings.ts b/.pi/agent/extensions/webui/backend/routes/settings.ts new file mode 100644 index 00000000..a61bb8bd --- /dev/null +++ b/.pi/agent/extensions/webui/backend/routes/settings.ts @@ -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[] { + 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(); + 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; +} diff --git a/.pi/agent/extensions/webui/backend/routes/webui-config.ts b/.pi/agent/extensions/webui/backend/routes/webui-config.ts new file mode 100644 index 00000000..c21282e2 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/routes/webui-config.ts @@ -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 = {}; + for (const [rawKey, rawValue] of Object.entries(entries as Record)) { + 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; +} diff --git a/.pi/agent/extensions/webui/backend/rpc/pi-client.ts b/.pi/agent/extensions/webui/backend/rpc/pi-client.ts new file mode 100644 index 00000000..5fb72c57 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/rpc/pi-client.ts @@ -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 void; reject: (error: Error) => void }>(); + const sseClients = new Set(); + let reqId = 0; + + let isStreaming = false; + let sessionFile: string | undefined; + let turnEventBuffer: Record[] = []; + + function getRunSnapshot(): RunSnapshot { + return { + isStreaming, + sessionFile, + replay: [...turnEventBuffer], + }; + } + + function broadcastSse(msg: Record): void { + const data = `data: ${JSON.stringify(msg)}\n\n`; + for (const res of sseClients) res.write(data); + } + + function trackAgentEvent(msg: Record): 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; + 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, + }; +} diff --git a/.pi/agent/extensions/webui/backend/services/avatars.ts b/.pi/agent/extensions/webui/backend/services/avatars.ts new file mode 100644 index 00000000..81bed61a --- /dev/null +++ b/.pi/agent/extensions/webui/backend/services/avatars.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/services/chat-images.ts b/.pi/agent/extensions/webui/backend/services/chat-images.ts new file mode 100644 index 00000000..f29eadb2 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/services/chat-images.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/backend/services/extensions-display.ts b/.pi/agent/extensions/webui/backend/services/extensions-display.ts new file mode 100644 index 00000000..e37b40e3 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/services/extensions-display.ts @@ -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 { + return readConfiguredNpmPackageNames(paths.agentSettingsFile); +} + +function getExtensionPath(extension: any): string { + return String(extension.path || extension.resolvedPath || ""); +} + +function classifyExtension( + paths: WebUiPaths, + extension: any, + configuredPackages: Set, +): "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, +): Record { + 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> { + const configuredPackages = getConfiguredNpmPackages(paths); + const response = await sendCmd({ type: "get_extensions" }); + if (!response.success) throw new Error(response.error || "读取扩展失败"); + const loadedByPath = new Map(); + 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[]> { + const configuredPackages = getConfiguredNpmPackages(paths); + const resolved = await listExtensionSettings(paths.repoRoot, paths.agentDir); + let loadedByPath = new Map(); + 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, +): Record { + 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); +} diff --git a/.pi/agent/extensions/webui/backend/services/models-config.ts b/.pi/agent/extensions/webui/backend/services/models-config.ts new file mode 100644 index 00000000..fe689c9f --- /dev/null +++ b/.pi/agent/extensions/webui/backend/services/models-config.ts @@ -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"); +} diff --git a/.pi/agent/extensions/webui/backend/services/sessions.ts b/.pi/agent/extensions/webui/backend/services/sessions.ts new file mode 100644 index 00000000..b68e7423 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/services/sessions.ts @@ -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 | 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(); + 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>, + pinnedPaths: string[], +): Array> { + 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>, + pinnedPaths: string[], +): Array> { + 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>; + 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 }; +} diff --git a/.pi/agent/extensions/webui/backend/services/slash-commands.ts b/.pi/agent/extensions/webui/backend/services/slash-commands.ts new file mode 100644 index 00000000..99b43c2a --- /dev/null +++ b/.pi/agent/extensions/webui/backend/services/slash-commands.ts @@ -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 { + 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)); +} diff --git a/.pi/agent/extensions/webui/backend/services/system-prompt.ts b/.pi/agent/extensions/webui/backend/services/system-prompt.ts new file mode 100644 index 00000000..ba1a9611 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/services/system-prompt.ts @@ -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"); +} diff --git a/.pi/agent/extensions/webui/backend/settings/extension-settings.ts b/.pi/agent/extensions/webui/backend/settings/extension-settings.ts new file mode 100644 index 00000000..57459299 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/settings/extension-settings.ts @@ -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).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 { + 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 { + 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); +} diff --git a/.pi/agent/extensions/webui/backend/settings/extensions-paths.ts b/.pi/agent/extensions/webui/backend/settings/extensions-paths.ts new file mode 100644 index 00000000..57150935 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/settings/extensions-paths.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; + +export function readConfiguredNpmPackageNames(settingsPath: string): Set { + const names = new Set(); + 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, +): boolean { + const packageName = extractNpmPackageNameFromPath(nodeModulesRoot, pathValue); + return packageName !== null && configuredPackages.has(packageName); +} + +export function getExtensionCategoryFromPath( + pathValue: string, + agentExtensionsDir: string, + nodeModulesRoot: string, + configuredPackages: Set, +): "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; + } +} diff --git a/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts b/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts new file mode 100644 index 00000000..da26e096 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts @@ -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; + +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 { + 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): 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): Record { + const existing = raw.mcpServers ?? raw["mcp-servers"]; + if (!existing || typeof existing !== "object" || Array.isArray(existing)) { + return {}; + } + return existing as Record; +} + +function getDisabledServersObject(raw: Record): Record { + const existing = raw[DISABLED_SERVERS_KEY]; + if (!existing || typeof existing !== "object" || Array.isArray(existing)) { + return {}; + } + return existing as Record; +} + +function setServersObject(raw: Record, servers: Record): void { + delete raw["mcp-servers"]; + raw.mcpServers = servers; +} + +function setDisabledServersObject(raw: Record, servers: Record): 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([ + 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; +} diff --git a/.pi/agent/extensions/webui/backend/settings/skills-settings.ts b/.pi/agent/extensions/webui/backend/settings/skills-settings.ts new file mode 100644 index 00000000..d84f03c6 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/settings/skills-settings.ts @@ -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(); + 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; + 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): 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 { + const patterns = new Set(); + 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(); + 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 { + 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 { + 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, + }; +} diff --git a/.pi/agent/extensions/webui/backend/slash/dispatch.ts b/.pi/agent/extensions/webui/backend/slash/dispatch.ts new file mode 100644 index 00000000..e8be9f0a --- /dev/null +++ b/.pi/agent/extensions/webui/backend/slash/dispatch.ts @@ -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) => Promise; + +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 = { + 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 { + 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; + } +} diff --git a/.pi/agent/extensions/webui/backend/types/context.ts b/.pi/agent/extensions/webui/backend/types/context.ts new file mode 100644 index 00000000..8ec90944 --- /dev/null +++ b/.pi/agent/extensions/webui/backend/types/context.ts @@ -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) => Promise; + +export interface SubmitPromptOptions { + message: string; + images?: Array<{ type: "image"; data: string; mimeType: string }>; +} + +export interface RunSnapshot { + isStreaming: boolean; + sessionFile?: string; + replay: Record[]; +} + +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; +} diff --git a/.pi/agent/extensions/webui/frontend/.gitignore b/.pi/agent/extensions/webui/frontend/.gitignore index 3c3629e6..91391561 100644 --- a/.pi/agent/extensions/webui/frontend/.gitignore +++ b/.pi/agent/extensions/webui/frontend/.gitignore @@ -1 +1,3 @@ node_modules +dist/ +dist-desketop/ diff --git a/.pi/agent/extensions/webui/frontend/index.html b/.pi/agent/extensions/webui/frontend/index.html index dde57f3f..38d63cb2 100644 --- a/.pi/agent/extensions/webui/frontend/index.html +++ b/.pi/agent/extensions/webui/frontend/index.html @@ -12,15 +12,6 @@ 萌小芽 - - -
diff --git a/.pi/agent/extensions/webui/frontend/package-lock.json b/.pi/agent/extensions/webui/frontend/package-lock.json index 743af604..1b785a0d 100644 --- a/.pi/agent/extensions/webui/frontend/package-lock.json +++ b/.pi/agent/extensions/webui/frontend/package-lock.json @@ -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", diff --git a/.pi/agent/extensions/webui/frontend/package.json b/.pi/agent/extensions/webui/frontend/package.json index 8a313ae6..3fe915d3 100644 --- a/.pi/agent/extensions/webui/frontend/package.json +++ b/.pi/agent/extensions/webui/frontend/package.json @@ -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", diff --git a/.pi/agent/extensions/webui/frontend/src/App.tsx b/.pi/agent/extensions/webui/frontend/src/App.tsx index dae7f466..244f79e5 100644 --- a/.pi/agent/extensions/webui/frontend/src/App.tsx +++ b/.pi/agent/extensions/webui/frontend/src/App.tsx @@ -12,20 +12,15 @@ export function App() { - - - - - } - > - } /> - - } /> - - + + + }> + } /> + + } /> + + + diff --git a/.pi/agent/extensions/webui/frontend/src/api/base.ts b/.pi/agent/extensions/webui/frontend/src/api/base.ts new file mode 100644 index 00000000..3943a09c --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/api/base.ts @@ -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; +} diff --git a/.pi/agent/extensions/webui/frontend/src/api/chat.ts b/.pi/agent/extensions/webui/frontend/src/api/chat.ts index ddddaaa5..eb8fd294 100644 --- a/.pi/agent/extensions/webui/frontend/src/api/chat.ts +++ b/.pi/agent/extensions/webui/frontend/src/api/chat.ts @@ -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 { return apiPost("/api/chat", { message, images }); } +export function runBash(command: string): Promise { + 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 { return apiPost("/api/new-session"); } diff --git a/.pi/agent/extensions/webui/frontend/src/api/client.ts b/.pi/agent/extensions/webui/frontend/src/api/client.ts index 2ab4b47b..1cfabec4 100644 --- a/.pi/agent/extensions/webui/frontend/src/api/client.ts +++ b/.pi/agent/extensions/webui/frontend/src/api/client.ts @@ -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(url: string, init?: RequestInit): Promise { - 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); diff --git a/.pi/agent/extensions/webui/frontend/src/api/commands.ts b/.pi/agent/extensions/webui/frontend/src/api/commands.ts new file mode 100644 index 00000000..7c3144d6 --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/api/commands.ts @@ -0,0 +1,6 @@ +import { apiGet } from "./client"; +import type { SlashCommandsResponse } from "../types/commands"; + +export function fetchSlashCommands(): Promise { + return apiGet("/api/commands"); +} diff --git a/.pi/agent/extensions/webui/frontend/src/api/sessions.ts b/.pi/agent/extensions/webui/frontend/src/api/sessions.ts index de592292..f62f181b 100644 --- a/.pi/agent/extensions/webui/frontend/src/api/sessions.ts +++ b/.pi/agent/extensions/webui/frontend/src/api/sessions.ts @@ -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 { - const activateRes = await fetch("/api/sessions/activate", { +export async function activateSessionBackend(path: string): Promise { + 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 { if (!fallbackRes.ok || fallbackData.error) { throw new Error(fallbackData.error || fallbackRes.statusText); } + return null; } export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> { diff --git a/.pi/agent/extensions/webui/frontend/src/api/settings.ts b/.pi/agent/extensions/webui/frontend/src/api/settings.ts index e4337eb8..e9d3e605 100644 --- a/.pi/agent/extensions/webui/frontend/src/api/settings.ts +++ b/.pi/agent/extensions/webui/frontend/src/api/settings.ts @@ -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 { 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 { 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 { return apiPost("/api/settings/avatars", avatars); } diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css new file mode 100644 index 00000000..cfc2ffa2 --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css @@ -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; + } +} diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx new file mode 100644 index 00000000..2296ebf8 --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx @@ -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 ( +
+
+ + $ + {command || "shell"} +
+
+				{hasOutput ? content : streaming ? "执行中..." : "(无输出)"}
+			
+ {showExit ?
退出码 {exitCode}
: null} +
+ ); +} diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/ChatInput.tsx b/.pi/agent/extensions/webui/frontend/src/components/chat/ChatInput.tsx index e611d643..7b4415ff 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/chat/ChatInput.tsx +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/ChatInput.tsx @@ -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([]); const [dragOver, setDragOver] = useState(false); + const [slashCommands, setSlashCommands] = useState([]); + const [selectedSlashIndex, setSelectedSlashIndex] = useState(0); const inputRef = useRef(null); const fileInputRef = useRef(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) => { 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() { ))} ) : null} + {slashMenuOpen ? ( + + ) : null}
{open ? (
diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/MessageBubble.tsx b/.pi/agent/extensions/webui/frontend/src/components/chat/MessageBubble.tsx index 6ce79438..206086ad 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/chat/MessageBubble.tsx +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/MessageBubble.tsx @@ -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 ( +
+ {showAgentAvatar ? ( + + ) : agentAvatarSpacer ? ( + + ); + } + if (message.role === "tool_call") { return (
@@ -96,6 +113,26 @@ export function MessageBubble({ ); } + if (message.role === "bash") { + return ( +
+ {showAgentAvatar ? ( + + ) : agentAvatarSpacer ? ( + + ); + } + const classNames = [styles.msg]; if (message.role === "user") classNames.push(styles.user); if (message.role === "assistant") classNames.push(styles.assistant); diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.module.css b/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.module.css index 97a54605..1afcc594 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.module.css +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.module.css @@ -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; } diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.tsx b/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.tsx index 90a94b47..cbd07201 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.tsx +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/MessageList.tsx @@ -73,14 +73,18 @@ export function MessageList() {
{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 ( 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 ( +
+
+ {chips.join(" ")} + {ctxTxt} +
+
+ ); } return (
-
-
- {parts.join(" ")} {ctxTxt} -
+
+ {chips.map((chip) => ( + + {chip} + + ))} + {ctxTxt}
- {sub ?
{sub}
: null} + {status ? {status} : null}
); } diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.module.css b/.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.module.css new file mode 100644 index 00000000..4c8ffff3 --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.module.css @@ -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; + } +} diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.tsx b/.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.tsx new file mode 100644 index 00000000..c8e00fee --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.tsx @@ -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(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 ( +
+
+ {commands.map((command, index) => ( + + ))} +
+
+ ); +} diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.module.css b/.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.module.css new file mode 100644 index 00000000..6a48cad2 --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.module.css @@ -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; + } +} diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.tsx b/.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.tsx new file mode 100644 index 00000000..31bcf4c6 --- /dev/null +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.tsx @@ -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 ( +
+ + 思考 + {summary} + +
+
{content}
+
+
+ ); +} diff --git a/.pi/agent/extensions/webui/frontend/src/components/chat/ToolCallBlock.module.css b/.pi/agent/extensions/webui/frontend/src/components/chat/ToolCallBlock.module.css index 4a096222..3822f150 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/chat/ToolCallBlock.module.css +++ b/.pi/agent/extensions/webui/frontend/src/components/chat/ToolCallBlock.module.css @@ -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; } diff --git a/.pi/agent/extensions/webui/frontend/src/components/layout/Header.module.css b/.pi/agent/extensions/webui/frontend/src/components/layout/Header.module.css index d63ace3d..88ed7543 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/layout/Header.module.css +++ b/.pi/agent/extensions/webui/frontend/src/components/layout/Header.module.css @@ -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; } } diff --git a/.pi/agent/extensions/webui/frontend/src/components/layout/Header.tsx b/.pi/agent/extensions/webui/frontend/src/components/layout/Header.tsx index bbe53e07..f96688b5 100644 --- a/.pi/agent/extensions/webui/frontend/src/components/layout/Header.tsx +++ b/.pi/agent/extensions/webui/frontend/src/components/layout/Header.tsx @@ -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["availableModels"]; + currentModelKey: string; + currentModel: ReturnType["availableModels"][number] | undefined; + thinkingLevel: string; + availableThinkingLevels: ReturnType["availableThinkingLevels"]; + isStreaming: boolean; + isApplyingModelSettings: boolean; + applyModelSelection: (key: string) => Promise; + applyThinkingSelection: (level: string) => Promise; +}) { return ( -
- -
-

{headerTitle}

- {metaText ? {metaText} : null} -
-
-
+
+ + +
@@ -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} +