Author SHA1 Message Date
luckyyzh 32496d24a2 fix: canonicalize missing managed paths 2026-07-31 03:28:46 +08:00
luckyyzh 6c2f88e44a feat: prepare self-contained open-source release 2026-07-31 03:18:50 +08:00
45 changed files with 3282 additions and 10957 deletions
-22
View File
@@ -1,22 +0,0 @@
---
name: vision
description: 视觉子代理 —— 读取并描述图片(截图/图表/文档/照片),输出完整结构化描述(OCR/版式/语义),供不支持图片输入的主模型(如 DeepSeek)推理使用。后端可配置(本地 Ollama 或 OpenAI 兼容视觉 API),初始未配置需先在 WebUI 视觉面板或环境变量中设置
tools: vision
subagentOnlyExtensions: ./resources/extensions/vision.ts
thinking: false
systemPromptMode: replace
inheritProjectContext: false
inheritSkills: false
defaultProgress: true
---
你是视觉子代理。主会话会把一个或多个图片文件路径交给你,你调用 `vision` 工具让视觉模型看图并返回文本描述。
工作规则:
- 对每个图片路径调用一次 `vision`;相关图片可一次传入多张。
- 工具返回的是视觉模型的转录:忠实转达,OCR 文字逐字保留,不要改写或脑补。
- 工具报错时(文件不存在 / 后端未配置 / 模型未拉取)如实报告,并给出明确的修复提示(如安装 Ollama 并拉取视觉模型,或检查 `VISION_OPENAI_*` 环境变量)。
- 输出保持结构化:多图按图分组,先给结论性总结,再附关键细节;文字类图片保证转录完整。
主会话(通常是 DeepSeek 这类纯文本模型)看不到图片,完全依赖你的描述,完整性优先。
+1 -29
View File
@@ -5,14 +5,6 @@
SEARXNG_URL=
SEARXNG_TOKEN=
# Optional public URL reported by pi-agent-public.ps1 (e.g. your own tunnel domain).
PI_AGENT_PUBLIC_URL=
# Optional SSH reverse-tunnel command for public access, run as a scheduled task
# by pi-agent-public.ps1 (registered only when set). Example for an "aliyun" host
# in ~/.ssh/config forwarding remote 8090 to local 30141:
# PI_AGENT_TUNNEL_COMMAND=C:\WINDOWS\System32\OpenSSH\ssh.exe -n -N -T -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R 127.0.0.1:8090:127.0.0.1:30141 aliyun
# Optional higher Context7 quota. Context7 also works at its public unauthenticated limit.
CONTEXT7_API_KEY=
@@ -21,25 +13,5 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GEMINI_API_KEY=
# pi-memory runs in lightweight Markdown-only mode by default. This keeps
# durable facts, daily logs, scratchpad, and recovery without qmd/model downloads.
# Set both values below only after installing and configuring qmd yourself.
# PI_MEMORY_NO_SEARCH=0
# PI_MEMORY_QMD_UPDATE=background
# Optional: move mutable runtime data while retaining the same isolation model.
# macOS/Linux: PI_AGENT_DATA_DIR=/path/to/pi-agent-data
# Windows PowerShell: PI_AGENT_DATA_DIR=D:\path\to\pi-agent-data
# macOS setup does not install or enable Playwright by default. Add it manually
# through the MCP panel if browser automation is needed.
# Optional storage ceilings. Automatic maintenance runs before Pi Web starts,
# removes only rebuildable caches that exceed these limits, compacts Rewind Git
# data, and removes orphan checkpoints after the grace period.
# PI_STORAGE_AUTO_MAINTENANCE=1
# PI_STORAGE_NEXT_DEV_MAX_MB=640
# PI_STORAGE_NEXT_CACHE_MAX_MB=256
# PI_STORAGE_NPM_CACHE_MAX_MB=256
# PI_STORAGE_ORPHAN_GRACE_DAYS=7
# PI_STORAGE_REWIND_GC_OBJECTS=1000
# PI_AGENT_DATA_DIR=D:\path\to\pi-agent-data
+1 -21
View File
@@ -1,7 +1,6 @@
---
name: CI
"on":
on:
push:
branches: [main]
pull_request:
@@ -29,22 +28,3 @@ jobs:
- run: npm run setup
- run: npm run typecheck
- run: npm run test:managed
macos:
strategy:
matrix:
os: [macos-15, macos-15-intel]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.19.0
cache: npm
cache-dependency-path: |
pi/package-lock.json
pi-web/package-lock.json
- run: npm run setup
- run: npm run typecheck
- run: npm run test:managed
+1 -4
View File
@@ -1,7 +1,4 @@
/data/*
# Local tools are code (shareable) — supervisor / public-access scripts for
# the managed Web service. Everything else under data/ stays untracked.
!/data/local-tools/
/data/
# Local configuration and credentials. Keep the shareable template only.
.env
+2 -2
View File
@@ -7,8 +7,8 @@ Contributions are welcome. The project is Windows-first and keeps Pi, Pi Web, ma
## Development setup / 开发环境
```powershell
git clone https://github.com/luckyyzh/pi-agent-integrated.git
cd pi-agent-integrated
git clone https://github.com/luckyyzh/pi-agent-new.git
cd pi-agent-new
npm run setup
npm run dev
```
+31 -204
View File
@@ -1,8 +1,8 @@
# Pi Agent Integrated
[中文](#中文) · [English](#english) · [🌐 项目介绍页](https://pi.llm-local.cloud)
[中文](#中文) · [English](#english)
A Windows/macOS, repository-local distribution that connects the [Pi](https://github.com/earendil-works/pi) agent runtime to the [Pi Web](https://github.com/agegr/pi-web) browser UI and adds a managed plugin/tool ecosystem.
A Windows-first, repository-local distribution that connects the [Pi](https://github.com/earendil-works/pi) agent runtime to the [Pi Web](https://github.com/agegr/pi-web) browser UI and adds a managed plugin/tool ecosystem.
---
@@ -20,38 +20,27 @@ Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆
| 默认工作目录 | 当前终端目录 | 在用户主目录创建日期目录 | `data/workspaces/default/` |
| 扩展生态 | 支持 Skill、扩展和包 | 提供管理界面 | 固定版本插件、MCP、Skill、提示词和主题形成项目闭环 |
| 网络搜索 | 无项目专属搜索 | 无项目专属搜索 | 可选 SearXNG `web_search` 扩展 |
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Windows 默认使用 Edge;macOS 默认不启用,按需配置 |
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Playwright MCP,使用系统 Edge,按需启动 |
| 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 |
| 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 |
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart |
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows restart |
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 或 macOS 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
### 环境要求
- Windows 10/11 或 macOS(Intel/Apple Silicon)
- Windows 10/11(当前唯一实机验证平台)
- Node.js `22.19.0` 或更高版本
- npm 与 Git
- 首次安装时可访问 npm registry
- Microsoft Edge(仅 Windows 默认 Playwright 浏览器工具需要;macOS 默认不启用 Playwright,不会自动下载浏览器)
- Microsoft Edge(仅 Playwright 浏览器工具需要;不会额外下载 Chromium)
- 至少一个由用户自行配置的模型供应商、订阅登录或兼容 API
### 快速开始
Windows PowerShell:
```powershell
git clone https://github.com/luckyyzh/pi-agent-integrated.git
cd pi-agent-integrated
npm run setup
npm run dev
```
macOS:
```bash
git clone https://github.com/luckyyzh/pi-agent-integrated.git
cd pi-agent-integrated
git clone https://github.com/luckyyzh/pi-agent-new.git
cd pi-agent-new
npm run setup
npm run dev
```
@@ -64,10 +53,10 @@ npm run dev
2. 安装并构建本地 Pi 源码;
3. 安装 Pi Web 并连接本地 Pi 包;
4. 安装、固定并加载检查默认插件;
5. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置;
5. 预缓存 Playwright MCP 包并确认系统 Edge 可用;
6. 检查前后端版本和构建产物。
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,Windows PowerShell 设置 `$env:PI_SETUP_USE_PROXY = "1"`,macOS/Linux 使用 `PI_SETUP_USE_PROXY=1 npm run setup`。
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,先设置 `$env:PI_SETUP_USE_PROXY = "1"`。
### 首次模型配置
@@ -84,14 +73,6 @@ npm run dev
需要可选服务时,复制模板:
macOS/Linux:
```bash
cp .env.example .env
```
Windows PowerShell:
```powershell
Copy-Item .env.example .env
```
@@ -129,75 +110,22 @@ data/workspaces/default/ 默认工作目录
### 内置插件与工具
版本固定在 `config/` 下的平台默认配置文件中;Windows 使用 `mcp.default.json`,macOS 使用不含 Playwright 的 `mcp.macos.default.json`。
所有版本固定在 `config/settings.default.json` 和 `config/mcp.default.json`。
| 插件/工具 | 功能 | 自动行为与用法 |
| --- | --- | --- |
| `pi-mcp-adapter@2.15.0` | 用一个紧凑代理工具接入 MCP | 模型使用 `mcp` 搜索并调用 MCP;`/mcp` 查看状态。外部宿主配置发现默认关闭 |
| `@playwright/mcp@0.0.78` | 真实网页导航、点击、表单、快照和截图 | Windows 通过 MCP 按需启动并使用系统 Edge;macOS 默认不安装,需手动通过 MCP 配置启用 |
| `@playwright/mcp@0.0.78` | 真实网页导航、点击、表单、快照和截图 | 通过 MCP 按需启动,空闲 5 分钟退出;使用无痕 Headless 系统 Edge |
| `pi-lens@3.8.73` | LSP、AST、符号检索和项目诊断 | 模型按需激活代码智能工具;可用 `/lens-health`、`/lens-tools`、`/lens-map` 检查 |
| `pi-memory@0.4.0` | Markdown 长期记忆、日志、临时工作区和恢复记录 | 默认轻量模式保留读写、状态和恢复工具,但不注册依赖 qmd 的 `memory_search`;文件位于 `data/agent/memory/` |
| `pi-memory@0.4.0` | Markdown 长期记忆、日志、临时工作区和恢复记录 | 模型按需使用 memory 工具;文件位于 `data/agent/memory/`,默认不启用向量索引 |
| `pi-subagents@0.37.2` | 创建研究、规划或执行子代理 | 简单任务不委派,跨模块或可并行复杂任务自动判断;也可使用 `/run`、`/parallel` |
| `pi-smart-fetch@0.3.17` | 抓取单个或批量 URL 内容 | 模型按需使用 `web_fetch` / `batch_web_fetch`,也提供给研究子代理 |
| `@ayulab/pi-rewind@0.4.6` | 每轮前后创建代码检查点 | `/rewind` 恢复代码、会话或两者;`/checkpoint` 管理存储。自动恢复文件默认关闭 |
| `@upstash/context7-pi@0.1.2` | 查询当前库、框架、SDK 和 API 文档 | 模型先解析库 ID,再按需查询文档;无 Key 可使用公共限额 |
| `@narumitw/pi-retry@0.31.0` | 识别瞬时供应商错误和卡住的流 | 复用 Pi 内置重试;默认 180 秒无事件视为停滞,不增加正常请求的模型调用 |
| `resources/extensions/searxng-search.ts` | 用户自有 SearXNG 的 `web_search` | 配置 `SEARXNG_URL` 与 `SEARXNG_TOKEN` 后,模型对时效性或明确搜索请求自动调用 |
| `resources/extensions/vision.ts` | 文本主模型(如 DeepSeek)的识图工具 `vision`(双后端) | 派 `vision` 子代理或直接让模型调用工具,返回 OCR/版式/语义文本;后端初始未配置(本地 Ollama 或 OpenAI 兼容视觉 API 任选),需在「视觉」标签页或环境变量中自行设置 |
Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。
#### 视觉子代理(vision)
DeepSeek 等纯文本模型不能接收图片。仓库内置 `vision` 子代理(`.agents/vision.md`):它通过 `vision` 工具调用视觉模型读取图片,把完整 OCR、版式结构与语义描述返回给主模型,主模型基于文本继续推理。视觉后端可插拔(本地 Ollama 或任意 OpenAI 兼容视觉 API)——仓库**不预设默认后端**,首次使用前需自行选择并配置。
配置入口:WebUI 左下角「模型」面板内的「视觉」标签页(写入 `data/agent/vision.json`),保存后**下次识图请求立即生效**,无需重启;环境变量优先级高于面板配置。
> 注意:视觉后端初始**未预设默认值**。未配置时识图会报错并提示配置入口;两种后端二选一即可。
**后端一:本地 Ollama(免费私密)**
- 前置:本机安装 [Ollama](https://ollama.com) 并拉取一个支持视觉的模型(如 `ollama pull qwen3-vl:8b`)。
- 环境变量:`OLLAMA_HOST`(默认 `http://localhost:11434`)、`OLLAMA_VISION_MODEL`(**必填**,指定视觉模型)、`OLLAMA_VISION_KEEP_ALIVE`(默认 `-1` 常驻显存,避免每次识图冷加载大模型;也可设 `30m` 等时长)。
**后端二:OpenAI 兼容视觉 API**
- 设置 `VISION_BACKEND=openai`,并配置 `VISION_OPENAI_BASE_URL`(如 `https://api.openai.com/v1`)、`VISION_OPENAI_API_KEY`、`VISION_OPENAI_MODEL`(如 `gpt-4o-mini`、`glm-4.5v`、`qwen-vl-max`)。
**自动转录(WebUI 上传即用)**
纯文本主模型(如 DeepSeek)无法接收图片,直接在 WebUI 上传会让请求失败(DeepSeek API 返回 HTTP 400)。`vision` 扩展注册了 `before_provider_request` 钩子:请求发出前检测到图片附件时,自动调用配置的视觉后端生成文本描述并替换进消息,主模型直接基于描述继续推理——上传即用,无需手动操作。支持图片的主模型则原样透传,不受影响。
描述按**单张图片**缓存并**持久化到磁盘**(`data/agent/vision-cache.json`,上限 64 条):只有新上传的图片会调用视觉模型,历史图片(含重启后)秒回缓存。自动转录使用**精简模板**(约百字摘要;`vision` 工具仍返回完整 OCR),并显式 `keep_alive: -1` 让模型常驻显存。每轮请求会把历史图片的描述文本一并注入上下文以保持主模型的记忆——上下文体积会随历史图片数增长,属已知取舍(Ollama 端已显式提升 `num_ctx`,DeepSeek 前缀缓存可摊薄费用)。
- WebUI 上传 JPEG 会自动压缩(长边 >1600px 时缩放至 1600px、质量 0.85):相机照片从数 MB 降到几百 KB,会话文件不膨胀、加载与转录更快;PNG/WebP/GIF 原样保留(无损/动画)。
- 用法:对主模型说“用 vision 子代理看 <图片路径>”即可;也可 `/run vision`(子代理用于主动深度分析多图;上传自动转录已覆盖日常识图)。
- 主会话直用:重启 pi 后 `vision` 工具在主会话也可用,可对磁盘上的图片主动调用。
- 单次调用可覆盖后端与模型:工具参数 `backend`、`model`。
为什么不让 pi 直接连接 Ollama 视觉模型:Ollama 的 OpenAI 兼容端点(`/v1`)会把 qwen3 系列模型的推理内容放进 `reasoning` 字段、`content` 留空,pi 会判定为空回复。Ollama 后端改走原生 `/api/chat` 并传 `think: false` 关闭思考,实测稳定可靠。
MCP 服务器可通过 Web UI 左下角的 MCP 按钮可视化配置(写入 `data/agent/mcp.json`):支持 stdio(命令 + 参数)与 HTTP(URL + 请求头 + OAuth/Bearer)两种传输、环境变量键值编辑、工作目录、生命周期与超时设置,另保留原始 JSON 编辑兜底。保存后重启 pi(或 /reload)生效。
#### 插件与扩展
Web UI 左下角的「插件」和「扩展」是两个独立面板:插件面板管理 npm/git 插件包的安装、更新和启停;扩展面板只展示直接加载的 `.ts`/`.js` 扩展文件,不展示插件包内的资源。扩展面板会按项目、内置和应用范围显示扩展状态、来源与路径;未信任项目中的 `.pi/extensions` 会标记为阻止而不会执行。共享扩展放在 `resources/extensions/`,项目扩展放在项目的 `.pi/extensions/`,全局扩展位于 Pi Profile 的 `extensions/` 目录。
Web UI 右上角的会话信息栏会汇总 Token 使用情况;当模型返回缓存读写数据时,还会显示按 Token 加权计算的缓存命中率:`cacheRead / (input + cacheRead + cacheWrite)`,不计输出 Token。
#### 记忆模式
默认设置 `PI_MEMORY_NO_SEARCH=1` 和 `PI_MEMORY_QMD_UPDATE=off`。这不会削弱 Markdown 记忆、每日日志、scratchpad 或恢复记录,但会跳过 qmd 探测、安装提示和 `memory_search` 工具,因此全新安装不会自动下载 qmd 的本地模型。集成补丁会在 `setup` 以及每次启动前自动检查并重放,受管插件重装后无需手工修改。
如果确实需要跨全部记忆文件的关键词、语义或深度搜索,请先自行安装并配置 qmd,然后在 `.env` 中设置:
```dotenv
PI_MEMORY_NO_SEARCH=0
PI_MEMORY_QMD_UPDATE=background
```
随后运行 `npm run memory:configure` 或直接重启应用。qmd 及其模型不随本仓库分发。
Playwright 不下载独立 Chromium。首次 `setup` 只缓存 MCP 的 Node.js 包;浏览器执行使用系统 Edge。
### Profile、迁移和自定义扩展
@@ -223,9 +151,8 @@ npm run profile:migrate -- --from D:\old-pi\agent --skills-from D:\old-skills
```powershell
npm run setup # 完整安装、构建和 Profile 插件校验
npm run profile:packages # 安装缺失或版本不匹配的受管插件
npm run memory:configure # 重新应用受管 pi-memory 轻量集成
npm run dev # 127.0.0.1:30141 开发服务
npm run restart # Windows/macOS:停止本项目旧开发进程并重新启动
npm run restart # Windows:停止本项目旧开发进程并重新启动
npm run dev:lan # 局域网监听;仅在可信网络使用
npm run build # 构建 Pi Web 生产产物
npm run start # 启动已构建的本机生产服务
@@ -241,24 +168,6 @@ npm run smoke:search -- "关键词" # 使用真实 SearXNG;需要配置
除 `smoke:search` 会访问用户配置的 SearXNG 外,以上验证不会发起付费模型请求。
### 自动磁盘维护
每次 `dev`、`restart`、`build` 或 `start` 在启动 Pi Web 前都会检查项目内的可变数据。维护只在 30141 端口没有运行中的 Pi Web 时执行,并采用以下无损策略:
- `.next/dev`、`.next/cache` 或项目 npm 缓存超过默认上限后才删除;下一次使用时会自动重建;
- Rewind 检查点包含大量松散 Git 对象时自动执行压缩,但保留所有有效检查点;
- 已找不到对应会话的孤儿 Rewind 检查点保留 7 天后自动删除;
- 会话、记忆、凭据、模型配置、已安装插件和仍有关联的检查点不会被自动删除。
- 手动清理:`node scripts/cleanup-cache.mjs`(默认预览,加 `--apply` 执行)——清 npm/opengrep 缓存、按天数移走过期会话、把旧会话图片替换为占位符瘦身;删除先进回收目录。
```powershell
npm run storage:status # 只查看受管缓存大小
npm run storage:maintain # 立即执行默认阈值维护
npm run storage:clean # 清空可重建缓存并删除全部孤儿检查点
```
运行中的服务不会被维护命令修改;如需在重启时自动回收,直接使用 `npm run restart`。可在 `.env` 中通过 `PI_STORAGE_*` 变量调整上限、宽限期或关闭自动维护,缺省值见 `.env.example`。
### 上游、更新和许可证
- Pi 基线:`0.83.0`,提交 `bb226f9c1f38d3c029156a690e97bbfc602336b9`
@@ -282,38 +191,36 @@ Pi Agent Integrated combines the Pi backend and Pi Web frontend into one indepen
| Default workspace | Current terminal directory | Creates a dated home directory | `data/workspaces/default/` |
| Extension ecosystem | Supports skills, extensions, packages | Management UI | Pinned plugins, MCP, skills, prompts, and themes form a managed ecosystem |
| Web search | No project-specific search | No project-specific search | Optional SearXNG `web_search` extension |
| Browser automation | User-integrated | No default browser MCP | Windows uses Edge by default; macOS leaves Playwright opt-in |
| Browser automation | User-integrated | No default browser MCP | Lazy Playwright MCP using system Edge |
| Recovery and durable context | Session tree and core state | Session UI | Rewind checkpoints, repository-local memory, automatic retry |
| Multi-agent workflow | Extensible core | Renders tool calls | Automatic complexity policy for `pi-subagents` |
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows/macOS restart |
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows restart |
This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows or macOS user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line.
This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line.
### Requirements
- Windows 10/11 or macOS (Intel/Apple Silicon)
- Windows 10/11, the only currently validated platform
- Node.js `22.19.0` or newer
- npm and Git
- npm registry access during initial setup
- Microsoft Edge for the Windows default Playwright browser; macOS leaves Playwright disabled by default and downloads no browser
- Microsoft Edge for Playwright browser tools; no separate Chromium is downloaded
- At least one user-configured model provider, subscription login, or compatible API
### Quick start
Windows PowerShell or macOS Terminal:
```bash
git clone https://github.com/luckyyzh/pi-agent-integrated.git
cd pi-agent-integrated
```powershell
git clone https://github.com/luckyyzh/pi-agent-new.git
cd pi-agent-new
npm run setup
npm run dev
```
Open <http://127.0.0.1:30141>.
Setup initializes the repository-local profile, builds Pi, links Pi Web to the local packages, installs and loads the pinned plugins, caches the Playwright MCP Node package and verifies system Edge on Windows, skips Playwright on macOS, and checks integration artifacts.
Setup initializes the repository-local profile, builds Pi, links Pi Web to the local packages, installs and loads the pinned plugins, caches the Playwright MCP Node package without downloading a browser, verifies system Edge, and checks integration artifacts.
Child npm operations ignore `HTTP_PROXY` and `HTTPS_PROXY` by default to avoid stale proxy configuration. Set `$env:PI_SETUP_USE_PROXY = "1"` on Windows, or run `PI_SETUP_USE_PROXY=1 npm run setup` on macOS/Linux, when registry access requires the proxy.
Child npm operations ignore `HTTP_PROXY` and `HTTPS_PROXY` by default to avoid stale proxy configuration. Set `$env:PI_SETUP_USE_PROXY = "1"` first when registry access requires the proxy.
### First model configuration
@@ -328,14 +235,6 @@ Device-specific model configuration remains in ignored `data/` or `.env` files.
### Optional environment configuration
macOS/Linux:
```bash
cp .env.example .env
```
Windows PowerShell:
```powershell
Copy-Item .env.example .env
```
@@ -373,75 +272,22 @@ data/workspaces/default/ Default workspace
### Included plugins and tools
Versions are pinned in the platform defaults under `config/`: Windows uses `mcp.default.json`, while macOS uses `mcp.macos.default.json` without Playwright.
Versions are pinned in `config/settings.default.json` and `config/mcp.default.json`.
| Plugin/tool | Function | Automatic behavior and usage |
| --- | --- | --- |
| `pi-mcp-adapter@2.15.0` | Compact MCP proxy | The model searches and invokes MCP through `mcp`; `/mcp` shows status. Host-config discovery is off |
| `@playwright/mcp@0.0.78` | Real navigation, clicks, forms, snapshots, screenshots | Windows starts it on demand with isolated headless system Edge; macOS does not install it by default and requires manual MCP configuration |
| `@playwright/mcp@0.0.78` | Real navigation, clicks, forms, snapshots, screenshots | Starts on demand, exits after five idle minutes, uses isolated headless system Edge |
| `pi-lens@3.8.73` | LSP, AST, symbols, project diagnostics | The model activates code intelligence on demand; inspect with `/lens-health`, `/lens-tools`, `/lens-map` |
| `pi-memory@0.4.0` | Markdown durable facts, logs, scratchpad, recovery | Lightweight mode retains read/write, status, and recovery tools but does not register qmd-dependent `memory_search`; files live under `data/agent/memory/` |
| `pi-memory@0.4.0` | Markdown durable facts, logs, scratchpad, recovery | The model uses memory tools on demand; files live under `data/agent/memory/`; vector indexing is off by default |
| `pi-subagents@0.37.2` | Research, planning, execution subagents | Simple tasks stay local; complex or parallel work may delegate automatically; `/run` and `/parallel` remain available |
| `pi-smart-fetch@0.3.17` | Single and batched URL retrieval | Provides `web_fetch` and `batch_web_fetch` to the main and research agents |
| `@ayulab/pi-rewind@0.4.6` | Per-turn code checkpoints | `/rewind` restores code, conversation, or both; `/checkpoint` manages storage. Automatic file restore is off |
| `@upstash/context7-pi@0.1.2` | Current library, framework, SDK, API docs | Resolves a library ID and queries docs when needed; public quota works without a key |
| `@narumitw/pi-retry@0.31.0` | Transient provider and stalled-stream classification | Uses Pi's built-in retry path; 180 seconds without events is a stall; no extra normal model calls |
| `resources/extensions/searxng-search.ts` | `web_search` against a user-owned SearXNG proxy | After `SEARXNG_URL` and `SEARXNG_TOKEN` are set, the model calls it for current or explicit search requests |
| `resources/extensions/vision.ts` | `vision` — image description for text-only models (e.g. DeepSeek), dual backend | Ask the `vision` subagent or call the tool directly; returns OCR/layout/semantics as text; no backend is preconfigured (local Ollama or any OpenAI-compatible vision API) — set one in the Vision tab or via env vars |
On Windows, Playwright never downloads a standalone Chromium: setup caches only its Node package and browser execution uses system Edge. On macOS, setup does not install or enable Playwright; add it manually through the MCP panel if browser automation is needed.
#### Vision subagent
Text-only models such as DeepSeek cannot receive image attachments. The repository ships a `vision` subagent (`.agents/vision.md`) that calls a vision model through the `vision` tool and returns a full OCR, layout, and semantic description the main model can reason over. The vision backend is pluggable (local Ollama or any OpenAI-compatible vision API) — the repository does **not** ship a default backend; pick and configure one before first use.
Configuration: the “Vision” tab inside the “Models” panel in the lower-left Web UI (writes `data/agent/vision.json`). Saved config takes effect on the **next image request** — no restart needed; environment variables take precedence over the panel.
> Note: no backend is preconfigured by default. Image requests fail with a configuration hint until you pick one — choose either backend below.
**Backend 1: local Ollama (free and private)**
- Prerequisite: install [Ollama](https://ollama.com) and pull a vision-capable model (e.g. `ollama pull qwen3-vl:8b`).
- Env: `OLLAMA_HOST` (default `http://localhost:11434`), `OLLAMA_VISION_MODEL` (**required** — the vision model), `OLLAMA_VISION_KEEP_ALIVE` (default `-1` — keep the model resident in VRAM to avoid cold-loading it on every transcription; can be set to e.g. `30m`).
**Backend 2: OpenAI-compatible vision API**
- Set `VISION_BACKEND=openai` and configure `VISION_OPENAI_BASE_URL` (e.g. `https://api.openai.com/v1`), `VISION_OPENAI_API_KEY`, `VISION_OPENAI_MODEL` (e.g. `gpt-4o-mini`, `glm-4.5v`, `qwen-vl-max`).
**Automatic transcription (upload-and-go)**
A text-only main model such as DeepSeek cannot receive images — uploading one in the Web UI fails the request (DeepSeek API returns HTTP 400). The `vision` extension registers a `before_provider_request` hook: when it detects image attachments, it transcribes them through the configured vision backend and replaces them with text before the request is sent, so the main model keeps reasoning seamlessly. Vision-capable main models pass through untouched.
Descriptions are cached **per image** and **persisted to disk** (`data/agent/vision-cache.json`, capped at 64 entries): only genuinely new uploads call the vision model, while previously seen images — including after a restart — resolve from cache instantly. The automatic transcription pipeline uses a **concise prompt** (~100-character summary; the `vision` tool still returns full OCR) and sends `keep_alive: -1` so the model stays resident in VRAM. Every request also re-injects the accumulated image descriptions so the main model keeps its memory of them — a known trade-off where context grows with the number of images (the Ollama backend raises `num_ctx` explicitly, and DeepSeek prefix caching keeps the cost modest).
- Web UI uploads auto-compress JPEGs (downscaled to 1600px long edge at quality 0.85 when larger): camera photos drop from several MB to a few hundred KB, so session files stop growing and load/transcribe faster; PNG/WebP/GIF pass through untouched (lossless/animated).
- Usage: ask the main model to “use the vision subagent to look at <path>”, or run `/run vision` (the subagent is for proactive deep analysis of many images; everyday image reading is covered by automatic transcription).
- Main-session use: after restarting pi, the `vision` tool is also available in the main session for images on disk.
- Per-call overrides: tool parameters `backend` and `model`.
Why not point pi directly at an Ollama vision model: Ollama's OpenAI-compatible `/v1` endpoint moves qwen3-family reasoning into the `reasoning` field with an empty `content`, which pi treats as an empty reply. The Ollama backend uses the native `/api/chat` with `think: false` instead, which works reliably.
MCP servers can be configured visually from the MCP button in the lower-left Web UI (writes `data/agent/mcp.json`): stdio (command + args) or HTTP (URL + headers + OAuth/Bearer) transport, environment-variable row editing, working directory, lifecycle and timeout options, plus raw JSON editing as a fallback. Changes take effect after restarting pi (or /reload).
#### Plugins and extensions
The lower-left Web UI has separate “Plugins” and “Extensions” panels. The Plugins panel manages npm/git plugin packages, including install, update, enable, and disable actions. The Extensions panel only lists directly loaded `.ts`/`.js` extension files and never lists resources supplied by plugin packages. It groups extensions by project, built-in, and app scope and shows their status, source, and path; extensions in an untrusted project `.pi/extensions` directory are shown as blocked and are not executed. Shared extensions belong in `resources/extensions/`, project extensions in `.pi/extensions/`, and global extensions in the Pi Profile `extensions/` directory.
The Web UI session-info panel in the upper-right summarizes Token usage. When a model reports cache read/write data, it also shows the token-weighted cache hit rate: `cacheRead / (input + cacheRead + cacheWrite)`, excluding output Tokens.
#### Memory modes
The managed launcher defaults to `PI_MEMORY_NO_SEARCH=1` and `PI_MEMORY_QMD_UPDATE=off`. Markdown memory, daily logs, scratchpad, and recovery stay available, while qmd detection, its installation notice, and the `memory_search` tool are skipped. A deterministic integration patch is checked during setup and before every launch, so reinstalling managed packages needs no manual repair.
To opt into keyword, semantic, or deep search across every memory file, install and configure qmd separately, then add the following to `.env`:
```dotenv
PI_MEMORY_NO_SEARCH=0
PI_MEMORY_QMD_UPDATE=background
```
Run `npm run memory:configure` or restart the application afterward. qmd and its local models are not bundled with this repository.
Playwright never downloads a standalone Chromium in this project. Setup caches only its Node package; browser execution uses system Edge.
### Profile, migration, and extension
@@ -460,9 +306,8 @@ Migration copies without deleting source data and removes absolute resource path
```powershell
npm run setup # Full install, build, and managed-profile validation
npm run profile:packages # Install missing or version-mismatched managed plugins
npm run memory:configure # Reapply the managed pi-memory lightweight integration
npm run dev # Development server on 127.0.0.1:30141
npm run restart # Windows/macOS: stop this project's old dev process and restart
npm run restart # Windows: stop this project's old dev process and restart
npm run dev:lan # Listen on the LAN; trusted networks only
npm run build # Build the Pi Web production output
npm run start # Start an existing local production build
@@ -478,24 +323,6 @@ npm run smoke:search -- "query" # Real configured SearXNG request
Except for `smoke:search`, validation does not make paid model requests.
### Automatic storage maintenance
Before `dev`, `restart`, `build`, or `start` launches Pi Web, the integrated launcher checks repository-local mutable storage. Maintenance runs only when no Pi Web process is listening on port 30141 and follows lossless defaults:
- `.next/dev`, `.next/cache`, and the managed npm cache are removed only after exceeding their size ceilings and are rebuilt on demand;
- Rewind repositories with many loose Git objects are compacted without dropping valid checkpoints;
- orphan Rewind checkpoints whose session no longer exists are removed after a seven-day grace period;
- sessions, memory, credentials, model configuration, installed plugins, and linked checkpoints are never automatically deleted.
- Manual cleanup: `node scripts/cleanup-cache.mjs` (dry-run by default; add `--apply` to execute) — clears npm/opengrep caches, moves expired sessions aside by age, and can shrink old sessions by replacing images with placeholders; deletions go to a trash directory first.
```powershell
npm run storage:status # Report managed cache sizes without changing data
npm run storage:maintain # Apply the normal thresholds immediately
npm run storage:clean # Remove rebuildable caches and all orphan checkpoints
```
Maintenance refuses to modify a running service. Use `npm run restart` to reclaim space safely during a restart. `PI_STORAGE_*` variables in `.env` can tune the ceilings and grace period or disable automatic maintenance; `.env.example` documents the defaults.
### Upstream, updates, and licensing
- Pi baseline: `0.83.0`, commit `bb226f9c1f38d3c029156a690e97bbfc602336b9`
+1 -1
View File
@@ -12,4 +12,4 @@ Secrets belong in `.env`, environment variables, or the ignored `data/` profile.
密钥应放在 `.env`、系统环境变量或被忽略的 `data/` Profile 中,不要写入公开配置、资源、截图、Issue 或日志。
Report vulnerabilities through GitHub private vulnerability reporting when available: <https://github.com/luckyyzh/pi-agent-integrated/security/advisories/new>. Do not include active secrets in a public issue.
Report vulnerabilities through GitHub private vulnerability reporting when available: <https://github.com/luckyyzh/pi-agent-new/security/advisories/new>. Do not include active secrets in a public issue.
+2 -4
View File
@@ -1,7 +1,5 @@
# Managed profile defaults
`settings.default.json` and `models.example.json` seed a new local profile.
`mcp.default.json` is the Windows MCP profile; `mcp.macos.default.json` leaves
Playwright opt-in on macOS. The launcher copies the platform-appropriate files
into `data/agent/` only when the destination is missing, so upgrades never
overwrite a user's settings, model configuration, or custom MCP configuration.
The launcher copies them into `data/agent/` only when the destination file is
missing, so upgrades never overwrite a user's settings or model configuration.
-7
View File
@@ -1,7 +0,0 @@
{
"settings": {
"hostConfigDiscovery": "off",
"idleTimeout": 5
},
"mcpServers": {}
}
-141
View File
@@ -1,141 +0,0 @@
[CmdletBinding()]
param(
[ValidateSet('status', 'start', 'restart', 'stop')]
[string]$Action = 'status',
[switch]$NoTunnel
)
$ErrorActionPreference = 'Stop'
$webTaskName = 'PiAgentIntegratedWeb'
$tunnelTaskName = 'PiAgentIntegratedTunnel'
$localUrl = 'http://127.0.0.1:30141/'
# local-tools 位于 data/ 下,上两级才是项目根
$projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
# 加载项目 .env(独立运行的脚本不会自动加载,需手动解析)
$envFile = Join-Path $projectRoot '.env'
if (Test-Path $envFile) {
Get-Content $envFile -Encoding UTF8 | ForEach-Object {
if ($_ -match '^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$') {
$envName = $matches[1]
$envValue = $matches[2].Trim()
if (-not [string]::IsNullOrWhiteSpace($envValue) -and -not [Environment]::GetEnvironmentVariable($envName, 'Process')) {
Set-Item -Path "Env:$envName" -Value $envValue
}
}
}
}
# 隧道命令从环境变量读取(.env 中设置 PI_AGENT_TUNNEL_COMMAND),例如:
# C:\WINDOWS\System32\OpenSSH\ssh.exe -n -N -T -o BatchMode=yes -R 127.0.0.1:8090:127.0.0.1:30141 aliyun
$tunnelCommand = $env:PI_AGENT_TUNNEL_COMMAND
# 公网地址从环境变量读取(.env 中设置 PI_AGENT_PUBLIC_URL),不在代码里写死个人域名
$publicUrl = $env:PI_AGENT_PUBLIC_URL
if ([string]::IsNullOrWhiteSpace($publicUrl)) {
$publicUrl = '(未配置 PI_AGENT_PUBLIC_URL)'
}
function Ensure-Tasks {
# Web supervisor 任务:必需,总是用最新定义覆盖注册(-Force 不中断运行中的实例)
Write-Host " 注册/更新计划任务 $webTaskName ..."
$supervisorPath = Join-Path $PSScriptRoot 'pi-agent-web-supervisor.ps1'
# 单引号拼接避免转义歧义(`\" 不是 PowerShell 转义引号)
$argument = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $supervisorPath + '"'
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argument -WorkingDirectory $projectRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn
# 显式允许电池运行(默认 DisallowStartIfOnBatteries=True 会导致笔记本不插电时任务 Queued 不启动)
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-DisallowStartIfOnBatteries:$false -StopIfGoingOnBatteries:$false
# -Force 总是用最新定义覆盖(含设置修正),不中断已运行实例
Register-ScheduledTask -TaskName $webTaskName -Action $action -Trigger $trigger `
-Settings $settings -Description 'Pi Agent Integrated Web supervisor' -Force | Out-Null
# Tunnel 任务:可选,仅在配置了 PI_AGENT_TUNNEL_COMMAND 时注册;-NoTunnel 明确跳过
if ($NoTunnel) {
Write-Host " - 已跳过隧道任务(-NoTunnel)"
return
}
if (Get-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue) { return }
if ([string]::IsNullOrWhiteSpace($tunnelCommand)) {
Write-Host " - 未配置 PI_AGENT_TUNNEL_COMMAND,跳过隧道任务"
return
}
Write-Host " 注册计划任务 $tunnelTaskName ..."
$parts = $tunnelCommand.Trim() -split '\s+', 2
$action = New-ScheduledTaskAction -Execute $parts[0] -Argument $parts[1]
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-DisallowStartIfOnBatteries:$false -StopIfGoingOnBatteries:$false
Register-ScheduledTask -TaskName $tunnelTaskName -Action $action -Trigger $trigger `
-Settings $settings -Description 'Pi Agent Integrated public tunnel' -Force | Out-Null
}
function Test-IsAdmin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-PublicAccessStatus {
$webTask = Get-ScheduledTask -TaskName $webTaskName -ErrorAction SilentlyContinue
$tunnelTask = Get-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue
$listener = Get-NetTCPConnection -State Listen -LocalPort 30141 -ErrorAction SilentlyContinue
$httpStatus = 'unavailable'
try {
$response = Invoke-WebRequest -Uri $localUrl -Method Head -TimeoutSec 10
$httpStatus = [string][int]$response.StatusCode
}
catch {
if ($_.Exception.Response) {
$httpStatus = [string][int]$_.Exception.Response.StatusCode
}
}
[pscustomobject]@{
WebTask = if ($webTask) { [string]$webTask.State } else { 'missing' }
TunnelTask = if ($tunnelTask) { [string]$tunnelTask.State } else { 'missing' }
Port30141 = if ($listener) { 'listening' } else { 'closed' }
LocalHttp = $httpStatus
PublicUrl = $publicUrl
} | Format-List
}
switch ($Action) {
'status' {
Get-PublicAccessStatus
}
'start' {
# 注册计划任务需要管理员权限;非管理员时自动提权重启
if (-not (Test-IsAdmin)) {
$argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", 'start')
if ($NoTunnel) { $argList += '-NoTunnel' }
Write-Host "注册计划任务需要管理员权限,正在提权重启(请确认 UAC 提示)..."
Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $argList -WorkingDirectory $projectRoot
exit
}
Ensure-Tasks
if (-not $NoTunnel) { Start-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue }
Start-ScheduledTask -TaskName $webTaskName
Start-Sleep -Seconds 3
Get-PublicAccessStatus
}
'restart' {
Stop-ScheduledTask -TaskName $webTaskName -ErrorAction SilentlyContinue
Stop-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
if (-not $NoTunnel) { Start-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue }
Start-ScheduledTask -TaskName $webTaskName
Start-Sleep -Seconds 3
Get-PublicAccessStatus
}
'stop' {
Stop-ScheduledTask -TaskName $webTaskName -ErrorAction SilentlyContinue
Stop-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue
Get-PublicAccessStatus
}
}
@@ -1,213 +0,0 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$logsDirectory = Join-Path $projectRoot 'data\logs'
$standardOutputLog = Join-Path $logsDirectory 'pi-web-service.out.log'
$standardErrorLog = Join-Path $logsDirectory 'pi-web-service.err.log'
$supervisorLog = Join-Path $logsDirectory 'pi-web-supervisor.log'
$restartRequestPath = Join-Path $projectRoot 'data\agent\restart-request.json'
$webUrl = 'http://127.0.0.1:30141/'
$restartRequestVersion = 1
New-Item -ItemType Directory -Path $logsDirectory -Force | Out-Null
New-Item -ItemType Directory -Path (Split-Path -Parent $restartRequestPath) -Force | Out-Null
Set-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) supervisor started"
function Write-SupervisorLog {
param([string]$Message)
Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message"
}
function Get-RestartRequest {
if (-not (Test-Path -LiteralPath $restartRequestPath -PathType Leaf)) {
return $null
}
try {
# The extension writes the request with Node (UTF-8 without BOM);
# Windows PowerShell 5.1 Get-Content defaults to ANSI and would corrupt
# non-ASCII testInstructions, breaking ConvertFrom-Json silently.
$request = Get-Content -LiteralPath $restartRequestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ([int]$request.version -ne $restartRequestVersion) {
return $null
}
if ([string]::IsNullOrWhiteSpace([string]$request.requestId)) {
return $null
}
if ([string]::IsNullOrWhiteSpace([string]$request.sessionId)) {
return $null
}
if ([string]::IsNullOrWhiteSpace([string]$request.testInstructions)) {
return $null
}
if ([string]$request.state -eq 'ready') {
return $request
}
# session_shutdown normally changes requested -> ready. If that event
# was interrupted, allow a stale request to proceed after a grace period.
if ([string]$request.state -eq 'requested') {
$createdAt = [DateTimeOffset]::MinValue
if ([DateTimeOffset]::TryParse([string]$request.createdAt, [ref]$createdAt)) {
$ageSeconds = ([DateTimeOffset]::UtcNow - $createdAt.ToUniversalTime()).TotalSeconds
if ($ageSeconds -ge 15) {
return $request
}
}
}
}
catch {
# The extension writes the request atomically. A transient parse failure
# means the file is still being replaced; retry on the next poll.
}
return $null
}
function Remove-RestartRequest {
Remove-Item -LiteralPath $restartRequestPath -Force -ErrorAction SilentlyContinue
}
function Stop-ProcessTree {
param([System.Diagnostics.Process]$Process)
if (-not $Process -or $Process.HasExited) {
return
}
Write-SupervisorLog "stopping Web process tree for restart request (PID $($Process.Id))"
& taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null
}
function Test-WebReady {
try {
$response = Invoke-WebRequest -Uri $webUrl -Method Get -TimeoutSec 3 -UseBasicParsing
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 400
}
catch {
return $false
}
}
function Get-RequestField {
param(
[object]$Request,
[string]$Name
)
$property = $Request.PSObject.Properties[$Name]
if ($property) {
return [string]$property.Value
}
if ($Request -is [System.Collections.IDictionary] -and $Request.Contains($Name)) {
return [string]$Request[$Name]
}
return ''
}
function Resume-AgentSession {
param([object]$Request)
$requestId = Get-RequestField $Request 'requestId'
$sessionId = Get-RequestField $Request 'sessionId'
$testInstructions = Get-RequestField $Request 'testInstructions'
$encodedSessionId = [Uri]::EscapeDataString($sessionId)
$resumeMessage = @"
[Pi Web restart complete]
Pi Web was restarted by the external supervisor and the previous Agent session was restored. Request ID: $requestId
Continue the previous task without repeating completed edits.
Post-restart verification:
$testInstructions
"@
$body = @{
type = 'prompt'
message = $resumeMessage.Trim()
} | ConvertTo-Json -Compress
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($body)
$response = Invoke-RestMethod `
-Uri "http://127.0.0.1:30141/api/agent/$encodedSessionId" `
-Method Post `
-ContentType 'application/json; charset=utf-8' `
-Body $bodyBytes `
-TimeoutSec 30
if (-not $response.success) {
throw "Agent resume API returned an unsuccessful response"
}
Write-SupervisorLog "resumed Agent session $sessionId for request $requestId"
Remove-RestartRequest
}
$pendingRequest = Get-RestartRequest
while ($true) {
$process = $null
try {
$npmCommand = (Get-Command npm.cmd -ErrorAction Stop).Source
Write-SupervisorLog 'starting npm run restart'
$process = Start-Process `
-FilePath $npmCommand `
-ArgumentList @('run', 'restart') `
-WorkingDirectory $projectRoot `
-WindowStyle Hidden `
-RedirectStandardOutput $standardOutputLog `
-RedirectStandardError $standardErrorLog `
-PassThru
$startedAt = Get-Date
$nextResumeAttempt = Get-Date
while (-not $process.HasExited) {
if ($pendingRequest -and (Get-Date) -ge $nextResumeAttempt) {
# Give npm/Next a moment to finish binding the port before the
# first resume attempt. Failed attempts remain pending and retry.
if (((Get-Date) - $startedAt).TotalSeconds -ge 2 -and (Test-WebReady)) {
try {
Resume-AgentSession $pendingRequest
$pendingRequest = $null
}
catch {
Write-SupervisorLog "Agent resume failed: $($_.Exception.Message)"
$nextResumeAttempt = (Get-Date).AddSeconds(3)
}
}
}
if (-not $pendingRequest) {
$candidate = Get-RestartRequest
if ($candidate) {
$pendingRequest = $candidate
Write-SupervisorLog "restart request $($candidate.requestId) detected"
Stop-ProcessTree $process
break
}
}
$null = $process.WaitForExit(500)
}
if (-not $process.HasExited) {
$null = $process.WaitForExit(10000)
}
if ($process.HasExited) {
Write-SupervisorLog "web process exited with code $($process.ExitCode)"
}
}
catch {
Write-SupervisorLog "supervisor error: $($_.Exception.Message)"
}
Start-Sleep -Seconds 5
}
+3 -7
View File
@@ -1,18 +1,17 @@
{
"name": "pi-agent-integrated",
"version": "0.2.0",
"version": "0.1.0",
"private": true,
"description": "Integrated local development launcher for Pi and Pi Web",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/luckyyzh/pi-agent-integrated.git"
"url": "https://github.com/luckyyzh/pi-agent-new.git"
},
"scripts": {
"setup": "node scripts/setup.mjs",
"profile:init": "node scripts/init-profile.mjs",
"profile:packages": "node scripts/install-managed-packages.mjs",
"memory:configure": "node scripts/configure-pi-memory.mjs",
"profile:migrate": "node scripts/migrate-profile.mjs",
"check": "node scripts/check-integration.mjs",
"check:profile": "node scripts/check-managed-profile.mjs",
@@ -27,12 +26,9 @@
"build": "node scripts/run.mjs build",
"start": "node scripts/run.mjs start",
"start:lan": "node scripts/run.mjs start:lan",
"storage:status": "node scripts/storage-maintenance.mjs status",
"storage:maintain": "node scripts/storage-maintenance.mjs auto",
"storage:clean": "node scripts/storage-maintenance.mjs clean",
"typecheck": "node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json",
"lint": "npm --prefix pi-web run lint",
"test:managed": "node --test scripts/configure-pi-memory.test.mjs scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/searxng-extension.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs"
"test:managed": "node --test scripts/migrate-profile.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/searxng-extension.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs"
},
"engines": {
"node": ">=22.19.0"
-127
View File
@@ -1,127 +0,0 @@
import { existsSync, readdirSync, statSync } from "fs";
import { basename, dirname, extname, join, relative, resolve } from "path";
import { DefaultPackageManager, getAgentDir, type ResolvedResource } from "@earendil-works/pi-coding-agent";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { getManagedRuntimePaths, isManagedRuntime, createAppSettingsManager } from "@/lib/app-runtime";
import { getProjectTrustStatus } from "@/lib/project-trust";
import type { ExtensionInfo, ExtensionsResponse, PluginDiagnostic } from "@/lib/api-types";
export const dynamic = "force-dynamic";
function extensionName(path: string): string {
const file = basename(path);
const extension = extname(file);
if (/^index\.(ts|js)$/.test(file)) return basename(dirname(path));
return extension ? file.slice(0, -extension.length) : file;
}
function extensionFiles(root: string): string[] {
if (!existsSync(root)) return [];
try {
const stats = statSync(root);
if (stats.isFile()) return /\.(?:ts|js)$/.test(root) ? [root] : [];
if (!stats.isDirectory()) return [];
} catch {
return [];
}
const files: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory()) files.push(...extensionFiles(path));
else if (entry.isFile() && /\.(?:ts|js)$/.test(entry.name)) files.push(path);
}
return files;
}
function infoFromResource(resource: ResolvedResource, scopeOverride?: ExtensionInfo["scope"]): ExtensionInfo {
const baseDir = resource.metadata.baseDir ?? dirname(resource.path);
const rel = relative(baseDir, resource.path);
return {
name: extensionName(resource.path),
path: resource.path,
relativePath: rel && !rel.startsWith("..") ? rel : resource.path,
source: scopeOverride === "builtin" ? "resources" : resource.metadata.source,
scope: scopeOverride ?? (resource.metadata.scope === "project" ? "project" : "global"),
status: resource.enabled ? "enabled" : "disabled",
};
}
function blockedInfo(path: string): ExtensionInfo {
return {
name: extensionName(path),
path,
relativePath: relative(dirname(dirname(path)), path),
source: "project",
scope: "project",
status: "blocked",
};
}
export async function GET(req: Request) {
let cwd: string | null;
try {
cwd = new URL(req.url).searchParams.get("cwd");
} catch {
return Response.json({ error: "invalid request URL" }, { status: 400 });
}
if (!cwd) return Response.json({ error: "cwd required" }, { status: 400 });
try {
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return Response.json({ error: "Access denied" }, { status: 403 });
}
const agentDir = getAgentDir();
const trust = getProjectTrustStatus(cwd, agentDir);
const settingsManager = createAppSettingsManager(cwd, agentDir, trust.trusted);
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const diagnostics: PluginDiagnostic[] = [];
const byPath = new Map<string, ExtensionInfo>();
try {
const resolved = await packageManager.resolve(async () => "skip");
for (const resource of resolved.extensions) {
// Package-provided extensions belong to the plugin panel, not here.
if (resource.metadata.origin !== "top-level") continue;
byPath.set(resource.path, infoFromResource(resource));
}
} catch (error) {
diagnostics.push({ type: "error", message: error instanceof Error ? error.message : String(error) });
}
if (isManagedRuntime()) {
const resourcesRoot = resolve(getManagedRuntimePaths().resourcesDir, "extensions");
const builtIn = await packageManager.resolveExtensionSources([resourcesRoot], { temporary: true });
for (const resource of builtIn.extensions) {
byPath.set(resource.path, infoFromResource(resource, "builtin"));
}
}
const projectExtensionsRoot = join(cwd, ".pi", "extensions");
if (trust.requiresTrust && !trust.trusted) {
for (const path of extensionFiles(projectExtensionsRoot)) {
byPath.set(path, blockedInfo(path));
}
if (byPathHasScope(byPath, "project")) {
diagnostics.push({ type: "warning", source: "project", message: "Project extensions are blocked until this project is trusted." });
}
}
const scopeOrder: Record<ExtensionInfo["scope"], number> = { project: 0, builtin: 1, global: 2 };
const extensions = [...byPath.values()].sort((a, b) => (
scopeOrder[a.scope] - scopeOrder[b.scope] || a.name.localeCompare(b.name) || a.path.localeCompare(b.path)
));
return Response.json({ extensions, diagnostics, projectResourcesLoaded: trust.trusted } satisfies ExtensionsResponse);
} catch (error) {
return Response.json({ error: String(error) }, { status: 500 });
}
}
function byPathHasScope(entries: Map<string, ExtensionInfo>, scope: ExtensionInfo["scope"]): boolean {
for (const entry of entries.values()) {
if (entry.scope === scope) return true;
}
return false;
}
+1 -92
View File
@@ -17,9 +17,6 @@ import {
getDocumentMime,
getFileExt,
getImageMime,
isAudioPath,
isDocumentPreviewPath,
isImagePath,
} from "@/lib/file-types";
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
@@ -42,7 +39,6 @@ const IGNORED_SUFFIXES = [".pyc"];
const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
type FileRequestType = typeof FILE_REQUEST_TYPES[number];
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);
const MAX_WRITE_BODY_BYTES = TEXT_PREVIEW_MAX_BYTES;
const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024;
// Multipart boundaries and headers are not file bytes, but must be bounded too.
@@ -126,46 +122,6 @@ function parseUploadFileNames(value: unknown): string[] | null {
return value;
}
async function getWritableFile(
segments: string[],
sessionId: string | null,
): Promise<{ filePath: string } | { response: NextResponse }> {
const filePath = filePathFromSegments(segments);
const allowedRoots = await getAllowedFileRoots();
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
const allowedBySessionReference =
!allowedByRoot &&
await isFilePathReferencedBySession(filePath, sessionId);
if (!allowedByRoot && !allowedBySessionReference) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
// The target must already exist as a regular file that resolves inside an
// allowed root. Resolving before the write prevents a symlink (possibly
// swapped in by a race) from redirecting the payload outside the roots, and
// the non-existing-path check above excludes files the session or current
// roots no longer own.
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return { response: NextResponse.json({ error: "Not found" }, { status: 404 }) };
}
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
if (!stat.isFile() || stat.isSymbolicLink()) {
return { response: NextResponse.json({ error: "Not a writable file" }, { status: 403 }) };
}
return { filePath };
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
@@ -176,57 +132,10 @@ export async function POST(
try {
const { path: segments } = await params;
const type = request.nextUrl.searchParams.get("type") ?? "upload";
const sessionId = request.nextUrl.searchParams.get("sessionId");
if (type === "write") {
const writable = await getWritableFile(segments, sessionId);
if ("response" in writable) return writable.response;
const { filePath } = writable;
const readonlyFile = isImagePath(filePath) || isAudioPath(filePath) || isDocumentPreviewPath(filePath);
if (readonlyFile) {
return NextResponse.json({ error: "This file type cannot be edited as text" }, { status: 400 });
}
const body = await request.json().catch(() => null) as { content?: unknown } | null;
if (!body || typeof body.content !== "string") {
return NextResponse.json({ error: "content must be a string" }, { status: 400 });
}
const content = body.content;
const contentBytes = Buffer.byteLength(content, "utf-8");
if (contentBytes > MAX_WRITE_BODY_BYTES) {
return NextResponse.json(
{ error: "File too large to save (>256KB)" },
{ status: 413 },
);
}
// Write to a temp file in the same directory then rename, so a crash or
// an error mid-write never leaves a truncated file behind.
const directory = path.dirname(filePath);
const tmpPath = path.join(
directory,
`.pi-write-${path.basename(filePath)}-${process.pid}-${Date.now()}.tmp`,
);
try {
fs.writeFileSync(tmpPath, content, "utf-8");
fs.renameSync(tmpPath, filePath);
} catch (writeError) {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
return NextResponse.json(
{ error: writeError instanceof Error ? writeError.message : String(writeError) },
{ status: 500 },
);
}
const stat = fs.statSync(filePath);
return NextResponse.json({ size: stat.size });
}
const uploadDirectory = await getUploadDirectory(segments);
if ("response" in uploadDirectory) return uploadDirectory.response;
const { directory } = uploadDirectory;
const type = request.nextUrl.searchParams.get("type") ?? "upload";
if (type === "upload-check") {
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
-58
View File
@@ -1,58 +0,0 @@
import { NextResponse } from "next/server";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
export const dynamic = "force-dynamic";
function getMcpPath(): string {
return join(getAgentDir(), "mcp.json");
}
interface McpConfigFile {
mcpServers: Record<string, Record<string, unknown>>;
settings?: Record<string, unknown>;
imports?: string[];
}
function readMcpJson(): McpConfigFile {
const path = getMcpPath();
if (!existsSync(path)) return { mcpServers: {} };
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as McpConfigFile;
if (!parsed || typeof parsed !== "object" || typeof parsed.mcpServers !== "object") {
return { mcpServers: {} };
}
return parsed;
} catch {
return { mcpServers: {} };
}
}
function writeMcpJson(data: McpConfigFile): void {
const path = getMcpPath();
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
}
export function GET() {
return NextResponse.json({ config: readMcpJson(), path: getMcpPath() });
}
export async function PUT(req: Request) {
try {
const body = (await req.json()) as McpConfigFile;
if (!body || typeof body !== "object" || typeof body.mcpServers !== "object" || body.mcpServers === null) {
return NextResponse.json({ error: "mcpServers must be an object" }, { status: 400 });
}
// 只保留已知顶层字段,避免写入无关键
const out: McpConfigFile = { mcpServers: body.mcpServers };
if (body.settings && typeof body.settings === "object") out.settings = body.settings;
if (Array.isArray(body.imports)) out.imports = body.imports;
writeMcpJson(out);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
-65
View File
@@ -1,65 +0,0 @@
import { NextResponse } from "next/server";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
export const dynamic = "force-dynamic";
interface VisionConfigFile {
backend?: "ollama" | "openai";
ollama?: { host?: string; model?: string };
openai?: { baseUrl?: string; apiKey?: string; model?: string };
}
function getVisionPath(): string {
return join(getAgentDir(), "vision.json");
}
function readVisionJson(): VisionConfigFile {
const path = getVisionPath();
if (!existsSync(path)) return {};
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as VisionConfigFile;
if (!parsed || typeof parsed !== "object") return {};
return parsed;
} catch {
return {};
}
}
function writeVisionJson(data: VisionConfigFile): void {
const path = getVisionPath();
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
}
export function GET() {
return NextResponse.json({ config: readVisionJson(), path: getVisionPath() });
}
export async function PUT(req: Request) {
try {
const body = (await req.json()) as VisionConfigFile;
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "body must be an object" }, { status: 400 });
}
const out: VisionConfigFile = {};
if (body.backend === "ollama" || body.backend === "openai") out.backend = body.backend;
if (body.ollama && typeof body.ollama === "object") {
out.ollama = {};
if (typeof body.ollama.host === "string" && body.ollama.host.trim()) out.ollama.host = body.ollama.host.trim();
if (typeof body.ollama.model === "string" && body.ollama.model.trim()) out.ollama.model = body.ollama.model.trim();
}
if (body.openai && typeof body.openai === "object") {
out.openai = {};
if (typeof body.openai.baseUrl === "string" && body.openai.baseUrl.trim()) out.openai.baseUrl = body.openai.baseUrl.trim();
if (typeof body.openai.apiKey === "string" && body.openai.apiKey.trim()) out.openai.apiKey = body.openai.apiKey.trim();
if (typeof body.openai.model === "string" && body.openai.model.trim()) out.openai.model = body.openai.model.trim();
}
writeVisionJson(out);
return NextResponse.json({ success: true, path: getVisionPath() });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
File diff suppressed because it is too large Load Diff
+14 -16
View File
@@ -6,7 +6,6 @@ import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft
import {
MAX_ATTACHED_IMAGE_BYTES,
MAX_ATTACHED_IMAGES,
compressImageFile,
isBase64ImageWithinLimits,
} from "@/lib/image-attachments";
import {
@@ -391,21 +390,20 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
pendingImageCountRef.current += imageFiles.length;
try {
const newImages = await Promise.all(
imageFiles.map(async (file) => {
// 大 JPEG 先压缩(长边 1600/q0.85),会话不再内嵌数 MB base64,加载/传输/转录都更快
const compressed = await compressImageFile(file);
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve((reader.result as string).split(",")[1]);
reader.onerror = reject;
reader.readAsDataURL(compressed);
});
return {
data: base64,
mimeType: compressed.type,
previewUrl: URL.createObjectURL(compressed),
};
})
imageFiles.map(
(file) =>
new Promise<AttachedImage>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// result is "data:<mime>;base64,<data>"
const base64 = result.split(",")[1];
resolve({ data: base64, mimeType: file.type, previewUrl: URL.createObjectURL(file) });
};
reader.onerror = reject;
reader.readAsDataURL(file);
})
)
);
setAttachedImages((prev) => {
const accepted = newImages.slice(0, Math.max(0, MAX_ATTACHED_IMAGES - prev.length));
-193
View File
@@ -1,193 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useI18n } from "@/hooks/useI18n";
import type { ExtensionInfo, ExtensionsResponse } from "@/lib/api-types";
function shortenPath(path: string): string {
return path.replace(/^\/(?:Users|home)\/[^/]+/, "~");
}
function statusColor(status: ExtensionInfo["status"]): string {
if (status === "enabled") return "var(--accent)";
if (status === "blocked") return "#d97706";
return "var(--text-dim)";
}
function statusLabel(status: ExtensionInfo["status"], t: ReturnType<typeof useI18n>["t"]): string {
if (status === "enabled") return t("extensions.enabled");
if (status === "blocked") return t("extensions.blocked");
return t("extensions.disabled");
}
function scopeLabel(scope: ExtensionInfo["scope"], t: ReturnType<typeof useI18n>["t"]): string {
if (scope === "project") return t("extensions.project");
if (scope === "builtin") return t("extensions.builtin");
return t("extensions.app");
}
function extensionKey(extension: ExtensionInfo): string {
return `${extension.scope}:${extension.path}`;
}
export function ExtensionsConfig({ cwd, onCloseAction }: { cwd: string; onCloseAction: () => void }) {
const isMobile = useIsMobile();
const { t } = useI18n();
const [data, setData] = useState<ExtensionsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const loadExtensions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/extensions?cwd=${encodeURIComponent(cwd)}`);
const next = (await response.json()) as ExtensionsResponse & { error?: string };
if (!response.ok || next.error) throw new Error(next.error ?? `HTTP ${response.status}`);
setData(next);
setSelected((current) => (
current && next.extensions.some((extension) => extensionKey(extension) === current)
? current
: next.extensions[0] ? extensionKey(next.extensions[0]) : null
));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [cwd]);
useEffect(() => {
void loadExtensions();
}, [loadExtensions]);
const extensions = useMemo(() => data?.extensions ?? [], [data?.extensions]);
const selectedExtension = extensions.find((extension) => extensionKey(extension) === selected) ?? null;
const enabledCount = extensions.filter((extension) => extension.status === "enabled").length;
const blockedCount = extensions.filter((extension) => extension.status === "blocked").length;
const disabledCount = extensions.filter((extension) => extension.status === "disabled").length;
const hasBlockedProjectExtensions = blockedCount > 0 && !data?.projectResourcesLoaded;
const groupedExtensions = useMemo(() => {
const groups: Array<{ scope: ExtensionInfo["scope"]; extensions: ExtensionInfo[] }> = [];
for (const scope of ["project", "builtin", "global"] as const) {
const scoped = extensions.filter((extension) => extension.scope === scope);
if (scoped.length > 0) groups.push({ scope, extensions: scoped });
}
return groups;
}, [extensions]);
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={(event) => {
if (event.target === event.currentTarget) onCloseAction();
}}
>
<div
style={{
width: isMobile ? "calc(100vw - 16px)" : 860,
maxWidth: "calc(100vw - 16px)",
height: isMobile ? "calc(100dvh - 16px)" : "76vh",
maxHeight: "calc(100dvh - 16px)",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 10,
display: "flex",
flexDirection: "column",
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("common.extensions")}</span>
<code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{shortenPath(cwd)}
</code>
</div>
<button type="button" onClick={onCloseAction} aria-label={t("i18n.close")} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>
×
</button>
</div>
{hasBlockedProjectExtensions && (
<div style={{ padding: "8px 18px", borderBottom: "1px solid var(--border)", color: "#d97706", fontSize: 11 }}>
{t("extensions.projectResourcesBlocked")}
</div>
)}
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
<div style={{ width: isMobile ? "100%" : 245, maxHeight: isMobile ? "40vh" : undefined, borderRight: isMobile ? "none" : "1px solid var(--border)", borderBottom: isMobile ? "1px solid var(--border)" : "none", display: "flex", flexDirection: "column", flexShrink: 0, background: "var(--bg-panel)" }}>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
{loading ? (
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.loading")}</div>
) : error ? (
<div style={{ padding: "10px 8px", fontSize: 11, color: "#ef4444" }}>{error}</div>
) : groupedExtensions.length === 0 ? (
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>{t("extensions.noExtensions")}</div>
) : (
groupedExtensions.map((group) => (
<div key={group.scope} style={{ marginBottom: 6 }}>
<div style={{ padding: "4px 8px 3px", fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase" }}>
{scopeLabel(group.scope, t)}
</div>
{group.extensions.map((extension) => {
const key = extensionKey(extension);
const isSelected = selected === key;
return (
<div
key={key}
onClick={() => setSelected(key)}
style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
onMouseEnter={(event) => { if (!isSelected) event.currentTarget.style.background = "var(--bg-hover)"; }}
onMouseLeave={(event) => { if (!isSelected) event.currentTarget.style.background = "none"; }}
>
<span style={{ flexShrink: 0, width: 7, height: 7, borderRadius: "50%", background: statusColor(extension.status) }} />
<span style={{ minWidth: 0, flex: 1, fontSize: 12, fontWeight: isSelected ? 600 : 400, color: extension.status === "disabled" ? "var(--text-dim)" : "var(--text)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{extension.name}
</span>
</div>
);
})}
</div>
))
)}
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
{selectedExtension ? (
<div style={{ display: "flex", flexDirection: "column", gap: 20, maxWidth: 680 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor(selectedExtension.status) }} />
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--text)", fontFamily: "var(--font-mono)" }}>{selectedExtension.name}</span>
<span style={{ fontSize: 10, padding: "1px 5px", borderRadius: 3, background: "rgba(120,120,120,0.12)", color: "var(--text-dim)" }}>{scopeLabel(selectedExtension.scope, t)}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(100px, 130px) minmax(0, 1fr)", gap: "9px 14px", fontSize: 12, lineHeight: 1.45 }}>
<div style={{ color: "var(--text-dim)" }}>{t("extensions.status")}</div>
<div style={{ color: statusColor(selectedExtension.status) }}>{statusLabel(selectedExtension.status, t)}</div>
<div style={{ color: "var(--text-dim)" }}>{t("extensions.source")}</div>
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>{selectedExtension.source}</div>
<div style={{ color: "var(--text-dim)" }}>{t("extensions.path")}</div>
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>{shortenPath(selectedExtension.path)}</div>
</div>
</div>
) : !loading && !error ? (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>{t("extensions.noExtensions")}</div>
) : null}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{data ? `${extensions.length} ${t("extensions.total")} · ${enabledCount} ${t("extensions.enabled")} · ${disabledCount} ${t("extensions.disabled")} · ${blockedCount} ${t("extensions.blocked")}${data.diagnostics.length ? ` · ${data.diagnostics.length} ${t("extensions.diagnostics")}` : ""}` : ""}
</div>
<button type="button" onClick={() => void loadExtensions()} disabled={loading} style={{ padding: "6px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: loading ? "not-allowed" : "pointer", opacity: loading ? 0.5 : 1, fontSize: 12 }}>{t("i18n.refresh")}</button>
<button type="button" onClick={onCloseAction} style={{ padding: "6px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", fontSize: 12 }}>{t("i18n.close")}</button>
</div>
</div>
</div>
);
}
+6 -292
View File
@@ -31,7 +31,6 @@ interface Props {
sourceSessionId?: string | null;
onOpenFile?: (filePath: string) => void;
onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void;
onEditorSaved?: (filePath: string) => void;
gitRefreshKey?: number;
initialDisplayMode?: DisplayMode;
}
@@ -208,16 +207,6 @@ function getFileApiUrl(
return `/api/files/${encoded}?${searchParams.toString()}`;
}
function getFileWriteApiUrl(
filePath: string,
sourceSessionId?: string | null,
): string {
const encoded = encodeFilePathForApi(filePath);
const searchParams = new URLSearchParams({ type: "write" });
if (sourceSessionId) searchParams.set("sessionId", sourceSessionId);
return `/api/files/${encoded}?${searchParams.toString()}`;
}
function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) {
const { t } = useI18n();
return (
@@ -794,7 +783,7 @@ function DocumentViewer({ filePath, cwd, sourceSessionId }: Props) {
);
}
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) {
if (isImagePath(filePath)) {
return <ImageViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
}
@@ -804,10 +793,10 @@ export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMenti
if (isDocumentPreviewPath(filePath)) {
return <DocumentViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
}
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} onEditorSaved={onEditorSaved} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
}
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) {
const { isDark } = useTheme();
const { t } = useI18n();
const [data, setData] = useState<FileData | null>(null);
@@ -821,13 +810,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
const esRef = useRef<EventSource | null>(null);
const gitDiffRequestRef = useRef(0);
const contentRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const [selectedLineRange, setSelectedLineRange] = useState<SelectedLineRange | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<string>("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const fetchContent = useCallback((filePath: string) => {
return fetch(getFileApiUrl(filePath, "read", sourceSessionId))
@@ -986,88 +969,6 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
mentionLineRange(selectedLineRange);
}, [mentionLineRange, selectedLineRange]);
const isDeleted = isDeletedDiff;
// Every text file routed to this viewer (i.e. non-image/audio/document) can
// be edited; the backend independently rejects binary/read-only types.
const editable = !isDeleted && !!data;
const dirty = isEditing && draft !== (data?.content ?? "");
const stopEditing = useCallback(() => {
setConfirmDiscard(false);
setIsEditing(false);
setSaveError(null);
setDraft("");
}, []);
const exitEditingGuard = useCallback((handler: () => void) => {
if (dirty) {
setConfirmDiscard(true);
return;
}
stopEditing();
handler();
}, [dirty, stopEditing]);
const startEditing = useCallback(() => {
if (!data) return;
setDraft(data.content);
setSaveError(null);
setConfirmDiscard(false);
setIsEditing(true);
requestAnimationFrame(() => editorRef.current?.focus());
}, [data]);
const saveFile = useCallback(async () => {
if (saving || !dirty) {
if (!dirty && isEditing) stopEditing();
return;
}
setSaving(true);
setSaveError(null);
try {
const response = await fetch(
getFileWriteApiUrl(filePath, sourceSessionId),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: draft }),
},
);
const result = await response.json().catch(() => ({})) as { size?: number; error?: string };
if (!response.ok) {
setSaveError(result.error ?? `HTTP ${response.status}`);
return;
}
setData((prev) => (prev ? { ...prev, content: draft, size: result.size ?? prev.size } : prev));
stopEditing();
void fetchGitDiff(filePath);
onEditorSaved?.(filePath);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}, [dirty, draft, filePath, fetchGitDiff, isEditing, onEditorSaved, saving, sourceSessionId, stopEditing]);
// Reset edit state whenever the file path or content source changes.
useEffect(() => {
stopEditing();
setSaveError(null);
setSaving(false);
}, [filePath, sourceSessionId, stopEditing]);
// Ctrl/Cmd+S saves while editing.
useEffect(() => {
if (!isEditing) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat || event.key.toLowerCase() !== "s" || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return;
event.preventDefault();
void saveFile();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isEditing, saveFile]);
useEffect(() => {
if (!onMentionLines || displayMode !== "source") return;
@@ -1168,7 +1069,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
<button
key={mode}
type="button"
onClick={() => exitEditingGuard(() => setDisplayMode(mode))}
onClick={() => setDisplayMode(mode)}
title={mode === "diff" ? t("i18n.compareHead") : undefined}
aria-pressed={active}
className="file-viewer-mode-button"
@@ -1185,65 +1086,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
)}
<div className="file-viewer-actions">
{effectiveDisplayMode === "source" && !isEditing && editable && (
<button
type="button"
onClick={startEditing}
title={t("i18n.editFile")}
aria-label={t("i18n.editFile")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</button>
)}
{isEditing && (
<>
<button
type="button"
onClick={() => void saveFile()}
disabled={saving || !dirty}
title={t("i18n.saveShortcut")}
aria-label={t("i18n.saveFile")}
className="file-viewer-icon-button"
style={{
color: dirty ? "#22c55e" : "var(--text-muted)",
opacity: saving || !dirty ? 0.6 : 1,
}}
>
{saving ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite" }} aria-hidden="true">
<path d="M21 12a9 9 0 1 1-5.7-8.4" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
<path d="M17 21v-8H7v8" />
<path d="M7 3v5h8" />
</svg>
)}
</button>
<button
type="button"
onClick={() => {
if (dirty) setConfirmDiscard(true);
else stopEditing();
}}
title={t("i18n.stopEditing")}
aria-label={t("i18n.stopEditing")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</>
)}
{effectiveDisplayMode === "source" && !isEditing && (
{effectiveDisplayMode === "source" && (
<>
<button
type="button"
@@ -1285,88 +1128,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
{/* Content area */}
<div ref={contentRef} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
{isEditing ? (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
{saveError && (
<div
role="alert"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
fontSize: 11,
color: "#f87171",
background: "color-mix(in srgb, #ef4444 8%, var(--bg))",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("i18n.saveError", { error: saveError })}
</span>
<button
type="button"
onClick={() => setSaveError(null)}
title={t("i18n.close")}
style={{ marginLeft: "auto", background: "none", border: "none", color: "var(--text-dim)", cursor: "pointer", padding: 2, display: "flex" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="m6 6 12 12" />
<path d="M18 6 6 18" />
</svg>
</button>
</div>
)}
<textarea
ref={editorRef}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
aria-label={t("i18n.editFile")}
style={{
flex: 1,
width: "100%",
minHeight: 0,
resize: "none",
border: "none",
outline: "none",
background: "var(--bg)",
color: "var(--text)",
padding: "14px 16px",
fontFamily: "var(--font-mono)",
fontSize: 13,
lineHeight: 1.6,
whiteSpace: "pre",
overflow: "auto",
tabSize: 2,
}}
onKeyDown={(e) => {
// Tab inserts spaces instead of moving focus.
if (e.key === "Tab") {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const indent = " ";
const next = draft.slice(0, start) + indent + draft.slice(end);
setDraft(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + indent.length;
});
}
}}
/>
</div>
) : effectiveDisplayMode === "diff" && hasGitDiff ? (
{effectiveDisplayMode === "diff" && hasGitDiff ? (
<DiffView patch={gitDiff.patch!} />
) : isHtml && effectiveDisplayMode === "preview" ? (
<iframe
@@ -1475,54 +1237,6 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
</SyntaxHighlighter>
)}
</div>
{confirmDiscard && (
<div
role="alertdialog"
aria-modal="true"
aria-label={t("i18n.discardChanges")}
style={{
position: "absolute",
inset: 0,
zIndex: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.32)",
}}
>
<div
style={{
width: "min(360px, calc(100% - 48px))",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: "16px 16px 12px",
boxShadow: "0 12px 32px rgba(0,0,0,0.28)",
}}
>
<div style={{ fontSize: 13, lineHeight: 1.45, color: "var(--text)" }}>
{t("i18n.closeWithoutSaving")}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginTop: 14 }}>
<button
type="button"
onClick={() => setConfirmDiscard(false)}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "var(--bg)", color: "var(--text)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.cancel")}
</button>
<button
type="button"
onClick={stopEditing}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "transparent", color: "var(--text-muted)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.discardChanges")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
-723
View File
@@ -1,723 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useI18n } from "@/hooks/useI18n";
interface McpServerEntry {
command?: string;
args?: string[];
socket?: string;
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
auth?: "oauth" | "bearer" | false;
bearerToken?: string;
bearerTokenEnv?: string;
lifecycle?: "keep-alive" | "lazy" | "lazy-keep-alive" | "eager";
idleTimeout?: number;
requestTimeoutMs?: number;
exposeResources?: boolean;
directTools?: boolean | string[];
includeTools?: string[];
excludeTools?: string[];
debug?: boolean;
trace?: boolean;
disabled?: boolean;
[key: string]: unknown;
}
interface McpConfigFile {
mcpServers: Record<string, McpServerEntry>;
settings?: Record<string, unknown>;
imports?: string[];
}
type TransportKind = "stdio" | "http";
function entrySummary(entry: McpServerEntry): string {
if (entry.url) {
try {
return new URL(entry.url).host || entry.url;
} catch {
return entry.url;
}
}
if (entry.socket) return entry.socket;
const cmd = entry.command ?? "";
const args = entry.args?.length ? ` ${entry.args.join(" ")}` : "";
return `${cmd}${args}`;
}
function transportOf(entry: McpServerEntry): TransportKind {
return entry.url ? "http" : "stdio";
}
function envToRows(env?: Record<string, string>): Array<{ key: string; value: string }> {
return Object.entries(env ?? {}).map(([key, value]) => ({ key, value }));
}
function rowsToEnv(rows: Array<{ key: string; value: string }>): Record<string, string> {
const env: Record<string, string> = {};
for (const row of rows) {
const key = row.key.trim();
if (key) env[key] = row.value;
}
return env;
}
// ── Form helpers ─────────────────────────────────────────────────────────────
const inputStyle: React.CSSProperties = {
padding: "6px 9px",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 5,
color: "var(--text)",
fontSize: 12,
outline: "none",
width: "100%",
boxSizing: "border-box",
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<label style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}>{label}</label>
{children}
</div>
);
}
function TextInput({ value, onChange, placeholder, mono }: { value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{ ...inputStyle, fontFamily: mono ? "var(--font-mono)" : "inherit" }}
/>
);
}
function TextArea({
value,
onChange,
rows,
placeholder,
mono,
}: {
value: string;
onChange: (v: string) => void;
rows: number;
placeholder?: string;
mono?: boolean;
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
placeholder={placeholder}
spellCheck={false}
style={{
...inputStyle,
resize: "vertical",
lineHeight: 1.5,
fontFamily: mono ? "var(--font-mono)" : "inherit",
}}
/>
);
}
function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
return <input type="number" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} style={inputStyle} />;
}
function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: readonly string[] }) {
const { t } = useI18n();
return (
<select value={value} onChange={(e) => onChange(e.target.value)} style={{ ...inputStyle, color: value ? "var(--text)" : "var(--text-dim)" }}>
<option value="">— {t("i18n.default")} —</option>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
}
function buttonStyle(disabled?: boolean, danger?: boolean): React.CSSProperties {
return {
padding: "6px 12px",
background: danger ? "rgba(239,68,68,0.08)" : "none",
border: "1px solid var(--border)",
borderRadius: 6,
color: danger ? "#ef4444" : "var(--text-muted)",
cursor: disabled ? "not-allowed" : "pointer",
fontSize: 12,
opacity: disabled ? 0.5 : 1,
};
}
// ── Env row editor ───────────────────────────────────────────────────────────
function EnvEditor({ rows, onChange }: { rows: Array<{ key: string; value: string }>; onChange: (rows: Array<{ key: string; value: string }>) => void }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<{ key: string; value: string }>) => {
onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{rows.map((row, i) => (
<div key={i} style={{ display: "flex", gap: 6 }}>
<input
value={row.key}
onChange={(e) => update(i, { key: e.target.value })}
placeholder="NAME"
spellCheck={false}
style={{ ...inputStyle, width: "38%", fontFamily: "var(--font-mono)" }}
/>
<input
value={row.value}
onChange={(e) => update(i, { value: e.target.value })}
placeholder="value"
spellCheck={false}
style={{ ...inputStyle, flex: 1, fontFamily: "var(--font-mono)" }}
/>
<button
type="button"
onClick={() => onChange(rows.filter((_, j) => j !== i))}
title={t("i18n.remove")}
style={{ ...buttonStyle(false, true), padding: "0 9px", flexShrink: 0 }}
>
✕
</button>
</div>
))}
<button type="button" onClick={() => onChange([...rows, { key: "", value: "" }])} style={{ ...buttonStyle(false), alignSelf: "flex-start" }}>
+ {t("mcp.addEnv")}
</button>
</div>
);
}
// ── Server detail form ───────────────────────────────────────────────────────
function ServerDetail({
name,
entry,
onRename,
onChange,
onDelete,
}: {
name: string;
entry: McpServerEntry;
onRename: (newName: string) => void;
onChange: (entry: McpServerEntry) => void;
onDelete: () => void;
}) {
const { t } = useI18n();
const transport = transportOf(entry);
const [rawJson, setRawJson] = useState(() => JSON.stringify(entry, null, 2));
const [jsonError, setJsonError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [headersDraft, setHeadersDraft] = useState(() => JSON.stringify(entry.headers ?? {}, null, 2));
const [headersInvalid, setHeadersInvalid] = useState(false);
const patch = (p: Partial<McpServerEntry>) => onChange({ ...entry, ...p });
const num = (v: string): number | undefined => (v.trim() === "" ? undefined : Number(v));
const applyJson = () => {
try {
const parsed = JSON.parse(rawJson) as McpServerEntry;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("object expected");
setJsonError(null);
onChange(parsed);
setAdvancedOpen(false);
} catch (e) {
setJsonError(e instanceof Error ? e.message : String(e));
}
};
const setTransport = (kind: TransportKind) => {
if (kind === transport) return;
if (kind === "stdio") {
const rest: McpServerEntry = { ...entry };
delete rest.url;
delete rest.headers;
delete rest.auth;
delete rest.bearerToken;
delete rest.bearerTokenEnv;
onChange(rest);
} else {
onChange({ ...entry, url: entry.url ?? "https://example.com/mcp" });
}
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18, maxWidth: 680 }}>
{/* Header row: name + enable + delete */}
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<Field label={t("mcp.serverName")}>
<div style={{ display: "flex", gap: 6 }}>
<input
value={name}
onChange={(e) => onRename(e.target.value)}
spellCheck={false}
style={{ ...inputStyle, width: 200, fontFamily: "var(--font-mono)", fontWeight: 600 }}
/>
<button type="button" onClick={onDelete} style={{ ...buttonStyle(false, true), flexShrink: 0 }}>
{t("i18n.remove")}
</button>
</div>
</Field>
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "var(--text-dim)" }}>{t("mcp.enabled")}</span>
<input
type="checkbox"
checked={!entry.disabled}
onChange={(e) => patch({ disabled: !e.target.checked })}
style={{ width: 14, height: 14, accentColor: "var(--accent)", cursor: "pointer" }}
/>
</div>
</div>
{/* Transport */}
<Field label={t("mcp.transport")}>
<div style={{ display: "inline-flex", border: "1px solid var(--border)", borderRadius: 7, overflow: "hidden", alignSelf: "flex-start" }}>
{(["stdio", "http"] as const).map((kind) => (
<button
key={kind}
type="button"
onClick={() => setTransport(kind)}
style={{
padding: "5px 14px",
border: "none",
cursor: "pointer",
fontSize: 12,
background: transport === kind ? "var(--accent)" : "transparent",
color: transport === kind ? "#fff" : "var(--text-muted)",
}}
>
{kind.toUpperCase()}
</button>
))}
</div>
</Field>
{transport === "stdio" ? (
<>
<Field label={t("mcp.command")}>
<TextInput value={entry.command ?? ""} onChange={(v) => patch({ command: v || undefined })} placeholder="npx" mono />
</Field>
<Field label={t("mcp.args")}>
<TextArea
value={(entry.args ?? []).join("\n")}
onChange={(v) => patch({ args: v.split("\n").map((s) => s.trim()).filter(Boolean) })}
rows={4}
mono
/>
</Field>
</>
) : (
<>
<Field label={t("mcp.url")}>
<TextInput value={entry.url ?? ""} onChange={(v) => patch({ url: v || undefined })} placeholder="https://…/mcp" mono />
</Field>
<Field label={t("mcp.headers")}>
<TextArea
value={headersDraft}
onChange={(v) => {
setHeadersDraft(v);
try {
const parsed = JSON.parse(v) as Record<string, string>;
setHeadersInvalid(false);
patch({ headers: parsed });
} catch {
setHeadersInvalid(true);
}
}}
rows={3}
mono
/>
{headersInvalid && <div style={{ fontSize: 11, color: "#ef4444" }}>{t("mcp.invalidJson")}</div>}
</Field>
<Field label={t("mcp.auth")}>
<Select
value={entry.auth === undefined ? "" : String(entry.auth)}
onChange={(v) => {
if (v === "false") patch({ auth: false });
else if (v === "") patch({ auth: undefined });
else patch({ auth: v as "oauth" | "bearer" });
}}
options={["oauth", "bearer", "false"]}
/>
</Field>
{entry.auth === "bearer" && (
<Field label={t("mcp.bearerToken")}>
<TextInput value={entry.bearerToken ?? ""} onChange={(v) => patch({ bearerToken: v || undefined })} mono />
</Field>
)}
</>
)}
{/* env */}
<EnvEditor rows={envToRows(entry.env)} onChange={(rows) => patch({ env: rowsToEnv(rows) })} />
{/* Optional common fields */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0 14px" }}>
<Field label={t("mcp.cwd")}>
<TextInput value={entry.cwd ?? ""} onChange={(v) => patch({ cwd: v || undefined })} placeholder="${PI_AGENT_APP_ROOT}" mono />
</Field>
<Field label={t("mcp.lifecycle")}>
<Select value={entry.lifecycle ?? ""} onChange={(v) => patch({ lifecycle: (v || undefined) as McpServerEntry["lifecycle"] })} options={["lazy", "keep-alive", "lazy-keep-alive", "eager"]} />
</Field>
<Field label={t("mcp.idleTimeout")}>
<NumInput value={entry.idleTimeout === undefined ? "" : String(entry.idleTimeout)} onChange={(v) => patch({ idleTimeout: num(v) })} placeholder="5" />
</Field>
<Field label={t("mcp.requestTimeout")}>
<NumInput value={entry.requestTimeoutMs === undefined ? "" : String(entry.requestTimeoutMs)} onChange={(v) => patch({ requestTimeoutMs: num(v) })} placeholder="120000" />
</Field>
</div>
{/* Advanced raw JSON */}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<button
type="button"
onClick={() => setAdvancedOpen((o) => !o)}
style={{ ...buttonStyle(false), alignSelf: "flex-start" }}
>
{advancedOpen ? "▾ " : "▸ "} {t("mcp.advanced")}
</button>
{advancedOpen && (
<>
<TextArea value={rawJson} onChange={(v) => { setRawJson(v); setJsonError(null); }} rows={12} mono />
{jsonError && <div style={{ fontSize: 11, color: "#ef4444" }}>{t("mcp.invalidJson")}: {jsonError}</div>}
<div>
<button type="button" onClick={applyJson} style={{ ...buttonStyle(false), background: "var(--accent)", color: "#fff", borderColor: "var(--accent)" }}>
{t("mcp.applyJson")}
</button>
</div>
</>
)}
</div>
</div>
);
}
// ── Main panel ───────────────────────────────────────────────────────────────
export function McpConfig({ onClose }: { onClose: () => void }) {
const isMobile = useIsMobile();
const { t } = useI18n();
const [config, setConfig] = useState<McpConfigFile>({ mcpServers: {} });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedOk, setSavedOk] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
const [configPath, setConfigPath] = useState("");
const load = useCallback(async () => {
setLoading(true);
setSaveError(null);
try {
const res = await fetch("/api/mcp");
const data = (await res.json()) as { config?: McpConfigFile; path?: string; error?: string };
if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`);
const next = data.config ?? { mcpServers: {} };
setConfig(next);
setConfigPath(data.path ?? "");
setSelected((current) => (current && next.mcpServers[current] ? current : Object.keys(next.mcpServers)[0] ?? null));
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const updateServer = useCallback((name: string, entry: McpServerEntry) => {
setConfig((prev) => ({ ...prev, mcpServers: { ...prev.mcpServers, [name]: entry } }));
}, []);
const renameServer = useCallback((oldName: string, newName: string) => {
const trimmed = newName.trim();
if (!trimmed || trimmed === oldName) return;
setConfig((prev) => {
if (prev.mcpServers[trimmed]) return prev; // 同名已存在
const mcpServers = { ...prev.mcpServers };
mcpServers[trimmed] = mcpServers[oldName];
delete mcpServers[oldName];
return { ...prev, mcpServers };
});
setSelected(trimmed);
}, []);
const deleteServer = useCallback((name: string) => {
setConfig((prev) => {
const mcpServers = { ...prev.mcpServers };
delete mcpServers[name];
return { ...prev, mcpServers };
});
setSelected((current) => {
if (current !== name) return current;
const remaining = Object.keys(config.mcpServers).filter((k) => k !== name);
return remaining[0] ?? null;
});
}, [config.mcpServers]);
const addServer = useCallback(() => {
let finalName = "new-server";
let n = 1;
while (config.mcpServers[finalName]) finalName = `new-server-${n++}`;
setConfig((prev) => ({ ...prev, mcpServers: { ...prev.mcpServers, [finalName]: {} } }));
setSelected(finalName);
}, [config.mcpServers]);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveError(null);
setSavedOk(false);
try {
const res = await fetch("/api/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
const d = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`);
else {
setSavedOk(true);
setTimeout(() => setSavedOk(false), 2000);
}
} catch (e) {
setSaveError(String(e));
} finally {
setSaving(false);
}
}, [config]);
const names = useMemo(() => Object.entries(config.mcpServers), [config.mcpServers]);
const selectedEntry = selected ? config.mcpServers[selected] : undefined;
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: isMobile ? "calc(100vw - 16px)" : 860,
maxWidth: "calc(100vw - 16px)",
height: isMobile ? "calc(100dvh - 16px)" : "78vh",
maxHeight: "calc(100dvh - 16px)",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 10,
display: "flex",
flexDirection: "column",
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("common.mcp")}</span>
<code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{configPath || "data/agent/mcp.json"}
</code>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>
×
</button>
</div>
{/* Body */}
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
{/* Left: server list */}
<div
style={{
width: isMobile ? "100%" : 230,
maxHeight: isMobile ? "40vh" : undefined,
borderRight: isMobile ? "none" : "1px solid var(--border)",
borderBottom: isMobile ? "1px solid var(--border)" : "none",
display: "flex",
flexDirection: "column",
flexShrink: 0,
background: "var(--bg-panel)",
}}
>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
{loading ? (
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.loading")}</div>
) : names.length === 0 ? (
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>{t("mcp.noServers")}</div>
) : (
names.map(([sName, sEntry]) => {
const isSelected = selected === sName;
return (
<div
key={sName}
onClick={() => setSelected(sName)}
style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.background = "none";
}}
>
<span
style={{
flexShrink: 0,
width: 7,
height: 7,
borderRadius: "50%",
background: sEntry.disabled ? "var(--text-dim)" : "var(--accent)",
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 12,
fontWeight: isSelected ? 600 : 400,
color: "var(--text)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{sName}
</div>
<div
style={{
fontSize: 10,
color: "var(--text-dim)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 2,
}}
title={entrySummary(sEntry)}
>
{entrySummary(sEntry) || (sEntry.disabled ? t("i18n.disabled") : "—")}
</div>
</div>
</div>
);
})
)}
</div>
{/* Add server */}
<div style={{ borderTop: "1px solid var(--border)", padding: "8px 6px" }}>
<button
type="button"
onClick={addServer}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 5,
width: "100%",
padding: "6px 0",
background: "none",
border: "1px dashed var(--border)",
borderRadius: 5,
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.color = "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--text-muted)";
}}
>
+ {t("mcp.addServer")}
</button>
</div>
</div>
{/* Right: detail */}
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
{loading ? null : selected && selectedEntry ? (
<ServerDetail
key={selected}
name={selected}
entry={selectedEntry}
onRename={(n) => renameServer(selected, n)}
onChange={(entry) => updateServer(selected, entry)}
onDelete={() => deleteServer(selected)}
/>
) : (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>
{t("mcp.selectServer")}
</div>
)}
</div>
</div>
{/* Footer */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("mcp.restartHint")}
</div>
{saveError && <span style={{ fontSize: 12, color: "#f87171", flexShrink: 0 }}>{saveError}</span>}
<button onClick={onClose} style={buttonStyle(false)}>
{t("i18n.close")}
</button>
<button
onClick={handleSave}
disabled={saving || savedOk}
style={{
position: "relative",
padding: "6px 16px",
minWidth: 92,
background: savedOk ? "#16a34a" : saving ? "var(--bg-panel)" : "var(--accent)",
border: "none",
borderRadius: 6,
color: savedOk ? "#fff" : saving ? "var(--text-muted)" : "#fff",
cursor: saving || savedOk ? "default" : "pointer",
fontSize: 13,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
>
{savedOk && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<span>{savedOk ? t("i18n.saved") : saving ? t("i18n.saving") : t("i18n.save")}</span>
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
-441
View File
@@ -1,441 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useI18n } from "@/hooks/useI18n";
interface VisionConfigFile {
backend?: "ollama" | "openai";
ollama?: { host?: string; model?: string };
openai?: { baseUrl?: string; apiKey?: string; model?: string };
}
const inputStyle: React.CSSProperties = {
padding: "6px 9px",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 5,
color: "var(--text)",
fontSize: 12,
outline: "none",
width: "100%",
boxSizing: "border-box",
};
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<label
style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}
>
{label}
</label>
{children}
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
mono,
type,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
mono?: boolean;
type?: string;
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
type={type ?? "text"}
style={{
...inputStyle,
fontFamily: mono ? "var(--font-mono)" : "inherit",
}}
/>
);
}
const saveButtonStyle = (primary: boolean): React.CSSProperties => ({
padding: "6px 14px",
borderRadius: 6,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
border: "1px solid var(--border)",
background: primary ? "var(--accent)" : "var(--bg-panel)",
color: primary ? "#fff" : "var(--text)",
});
/**
* Vision backend settings (backend picker + fields + save), without modal
* chrome. Embedded in the Models config panel and reused by the standalone
* modal below.
*/
export function VisionConfigContent() {
const { t } = useI18n();
const [config, setConfig] = useState<VisionConfigFile>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedOk, setSavedOk] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setSaveError(null);
try {
const res = await fetch("/api/vision-config");
const data = (await res.json()) as {
config?: VisionConfigFile;
error?: string;
};
if (!res.ok || data.error)
throw new Error(data.error ?? `HTTP ${res.status}`);
setConfig(data.config ?? {});
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = useCallback(async () => {
setSaving(true);
setSaveError(null);
setSavedOk(false);
try {
const res = await fetch("/api/vision-config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
const data = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || !data.success)
throw new Error(data.error ?? `HTTP ${res.status}`);
setSavedOk(true);
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}, [config]);
const backend = config.backend;
const ollama = config.ollama ?? {};
const openai = config.openai ?? {};
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
padding: "14px 18px",
flex: 1,
overflow: "auto",
}}
>
{loading ? (
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
{t("vision.loading")}
</div>
) : (
<>
{/* Backend picker */}
<div style={{ display: "flex", gap: 8 }}>
{(["ollama", "openai"] as const).map((b) => (
<button
key={b}
onClick={() => setConfig((prev) => ({ ...prev, backend: b }))}
style={{
flex: 1,
padding: "10px 12px",
borderRadius: 8,
cursor: "pointer",
textAlign: "left",
border: `1px solid ${backend === b ? "var(--accent)" : "var(--border)"}`,
background:
backend === b
? "color-mix(in srgb, var(--accent) 8%, var(--bg-panel))"
: "var(--bg-panel)",
display: "flex",
flexDirection: "column",
gap: 3,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text)",
}}
>
{b === "ollama"
? t("vision.backend.ollama")
: t("vision.backend.openai")}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-muted)",
lineHeight: 1.4,
}}
>
{b === "ollama"
? t("vision.backend.ollamaHint")
: t("vision.backend.openaiHint")}
</span>
</button>
))}
</div>
{/* No backend selected yet */}
{!backend && (
<div
style={{
fontSize: 11,
color: "var(--text-muted)",
lineHeight: 1.5,
background: "var(--bg-panel)",
border: "1px dashed var(--border)",
borderRadius: 6,
padding: "8px 10px",
}}
>
{t("vision.notConfigured")}
</div>
)}
{/* Ollama settings */}
{backend === "ollama" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Field label={t("vision.ollama.host")}>
<TextInput
value={ollama.host ?? ""}
onChange={(v) =>
setConfig((prev) => ({
...prev,
ollama: { ...prev.ollama, host: v },
}))
}
placeholder={t("vision.ollama.hostPlaceholder")}
mono
/>
</Field>
<Field label={t("vision.ollama.model")}>
<TextInput
value={ollama.model ?? ""}
onChange={(v) =>
setConfig((prev) => ({
...prev,
ollama: { ...prev.ollama, model: v },
}))
}
placeholder={t("vision.ollama.modelPlaceholder")}
mono
/>
</Field>
</div>
)}
{/* OpenAI-compatible settings */}
{backend === "openai" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Field label={t("vision.openai.baseUrl")}>
<TextInput
value={openai.baseUrl ?? ""}
onChange={(v) =>
setConfig((prev) => ({
...prev,
openai: { ...prev.openai, baseUrl: v },
}))
}
placeholder={t("vision.openai.baseUrlPlaceholder")}
mono
/>
</Field>
<Field label={t("vision.openai.apiKey")}>
<TextInput
value={openai.apiKey ?? ""}
onChange={(v) =>
setConfig((prev) => ({
...prev,
openai: { ...prev.openai, apiKey: v },
}))
}
type="password"
mono
/>
</Field>
<Field label={t("vision.openai.model")}>
<TextInput
value={openai.model ?? ""}
onChange={(v) =>
setConfig((prev) => ({
...prev,
openai: { ...prev.openai, model: v },
}))
}
placeholder={t("vision.openai.modelPlaceholder")}
mono
/>
</Field>
</div>
)}
<div
style={{
fontSize: 11,
color: "var(--text-muted)",
lineHeight: 1.5,
borderTop: "1px solid var(--border)",
paddingTop: 10,
}}
>
{t("vision.effective")}
</div>
</>
)}
{saveError && (
<div style={{ fontSize: 11, color: "var(--danger, #e5484d)" }}>
{t("vision.loadFailed")}: {saveError}
</div>
)}
{savedOk && (
<div style={{ fontSize: 11, color: "var(--success, #30a46c)" }}>
{t("vision.saved")}
</div>
)}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: "auto",
}}
>
<button
onClick={save}
disabled={saving || loading}
style={{
...saveButtonStyle(true),
opacity: saving || loading ? 0.6 : 1,
}}
>
{t("vision.save")}
</button>
</div>
</div>
);
}
/** Standalone modal wrapper (kept for backward compatibility). */
export function VisionConfig({ onClose }: { onClose: () => void }) {
const isMobile = useIsMobile();
const { t } = useI18n();
const [configPath, setConfigPath] = useState("");
useEffect(() => {
fetch("/api/vision-config")
.then((r) => r.json())
.then((d: { path?: string }) => setConfigPath(d.path ?? ""))
.catch(() => {});
}, []);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
padding: 8,
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: isMobile ? "calc(100vw - 16px)" : 560,
maxWidth: "calc(100vw - 16px)",
maxHeight: "calc(100dvh - 16px)",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 10,
display: "flex",
flexDirection: "column",
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 18px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span
style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}
>
{t("vision.title")}
</span>
<code
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{configPath || "data/agent/vision.json"}
</code>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
padding: "2px 6px",
}}
>
×
</button>
</div>
<VisionConfigContent />
</div>
</div>
);
}
-18
View File
@@ -103,21 +103,3 @@ export interface PluginsResponse {
diagnostics: PluginDiagnostic[];
projectResourcesLoaded: boolean;
}
export type ExtensionScope = "global" | "project" | "builtin";
export type ExtensionStatus = "enabled" | "disabled" | "blocked";
export interface ExtensionInfo {
name: string;
path: string;
relativePath: string;
source: string;
scope: ExtensionScope;
status: ExtensionStatus;
}
export interface ExtensionsResponse {
extensions: ExtensionInfo[];
diagnostics: PluginDiagnostic[];
projectResourcesLoaded: boolean;
}
-68
View File
@@ -8,11 +8,8 @@ export const enLocale: LocalePlugin = {
"common.ok": "OK",
"common.language": "Language",
"common.models": "Models",
"common.mcp": "MCP",
"common.skills": "Skills",
"common.plugins": "Plugins",
"common.extensions": "Extensions",
"common.vision": "Vision",
"sidebar.hide": "Hide sidebar",
"sidebar.show": "Show sidebar",
"theme.light": "Switch to light mode",
@@ -45,7 +42,6 @@ export const enLocale: LocalePlugin = {
"session.output": "Output",
"session.cacheRead": "Cache Read",
"session.cacheWrite": "Cache Write",
"session.cacheHitRate": "Cache Hit Rate (Main Session)",
"session.cost": "Cost",
"session.context": "Context",
"session.copy": "Copy {value}",
@@ -240,47 +236,6 @@ export const enLocale: LocalePlugin = {
"chat.commandCopy": "Copy the last assistant message",
"chat.compacted": "Compacted",
"chat.tokensSaved": "{saved} saved",
"mcp.addServer": "Add server",
"mcp.addEnv": "Add env",
"mcp.serverName": "Server name",
"mcp.enabled": "Enabled",
"mcp.transport": "Transport",
"mcp.command": "Command",
"mcp.args": "Arguments (one per line)",
"mcp.url": "URL",
"mcp.headers": "Headers (JSON)",
"mcp.auth": "Auth",
"mcp.bearerToken": "Bearer token",
"mcp.cwd": "Working directory",
"mcp.lifecycle": "Lifecycle",
"mcp.idleTimeout": "Idle timeout (minutes)",
"mcp.requestTimeout": "Request timeout (ms)",
"mcp.advanced": "Advanced (raw JSON)",
"mcp.applyJson": "Apply JSON",
"mcp.invalidJson": "Invalid JSON",
"mcp.noServers": "No MCP servers configured",
"mcp.selectServer": "Select a server to edit",
"mcp.restartHint": "Saved changes apply after restarting pi (or /reload).",
"vision.title": "Vision config",
"vision.loading": "Loading…",
"vision.backend.ollama": "Local Ollama",
"vision.backend.ollamaHint": "Free and private — needs a local Ollama with a vision model pulled",
"vision.backend.openai": "OpenAI-compatible API",
"vision.backend.openaiHint": "Any OpenAI-compatible vision endpoint (cloud or self-hosted)",
"vision.ollama.host": "Ollama address",
"vision.ollama.hostPlaceholder": "e.g. http://localhost:11434",
"vision.ollama.model": "Vision model",
"vision.ollama.modelPlaceholder": "e.g. qwen3-vl:8b",
"vision.openai.baseUrl": "Base URL",
"vision.openai.baseUrlPlaceholder": "e.g. https://api.openai.com/v1",
"vision.openai.apiKey": "API Key",
"vision.openai.model": "Model",
"vision.openai.modelPlaceholder": "e.g. gpt-4o-mini / glm-4.5v",
"vision.effective": "Saved config takes effect on the next image request — no restart needed. Uploaded images are auto-transcribed into text for text-only main models (e.g. DeepSeek).",
"vision.notConfigured": "No backend selected yet — image requests fail until you pick one and fill in its details.",
"vision.loadFailed": "Failed to load / save config",
"vision.save": "Save",
"vision.saved": "Saved — takes effect on the next image request",
"i18n.close": "Close",
"i18n.copy": "Copy",
"i18n.copied": "Copied",
@@ -349,16 +304,6 @@ export const enLocale: LocalePlugin = {
"i18n.mentionSelectedLines": "Mention selected lines",
"i18n.disableWrap": "Disable word wrap",
"i18n.enableWrap": "Enable word wrap",
"i18n.editFile": "Edit file",
"i18n.stopEditing": "Stop editing",
"i18n.saveFile": "Save file",
"i18n.saveShortcut": "Save (Ctrl/Cmd+S)",
"i18n.discardChanges": "Discard unsaved changes",
"i18n.saveError": "Could not save file: {error}",
"i18n.closeWithoutSaving": "You have unsaved changes. Discard them and exit editing?",
"i18n.editing": "Editing",
"i18n.unsaved": "unsaved changes",
"i18n.readOnly": "Read-only",
"i18n.htmlPreview": "HTML preview",
"i18n.previewFile": "Preview {file}",
"i18n.invalidMermaid": "Invalid Mermaid diagram",
@@ -411,19 +356,6 @@ export const enLocale: LocalePlugin = {
"i18n.configuredVersion": "configured {version}",
"i18n.extensions": "Extensions",
"i18n.prompts": "Prompts",
"extensions.app": "App",
"extensions.project": "Project",
"extensions.builtin": "Built-in",
"extensions.enabled": "enabled",
"extensions.disabled": "disabled",
"extensions.blocked": "blocked",
"extensions.total": "extensions",
"extensions.status": "Status",
"extensions.source": "Source",
"extensions.path": "Path",
"extensions.noExtensions": "No direct extensions found",
"extensions.projectResourcesBlocked": "Project extensions are blocked until this project is trusted.",
"extensions.diagnostics": "diagnostics",
"i18n.themes": "Themes",
"i18n.resourceCount": "{count} {label}",
"i18n.extensionShort": "ext",
-68
View File
@@ -8,11 +8,8 @@ export const zhCNLocale: LocalePlugin = {
"common.ok": "确定",
"common.language": "语言",
"common.models": "模型",
"common.mcp": "MCP",
"common.skills": "技能",
"common.plugins": "插件",
"common.extensions": "扩展",
"common.vision": "视觉",
"sidebar.hide": "隐藏侧边栏",
"sidebar.show": "显示侧边栏",
"theme.light": "切换到浅色模式",
@@ -45,7 +42,6 @@ export const zhCNLocale: LocalePlugin = {
"session.output": "输出",
"session.cacheRead": "缓存读取",
"session.cacheWrite": "缓存写入",
"session.cacheHitRate": "缓存命中率(主会话)",
"session.cost": "费用",
"session.context": "上下文",
"session.copy": "复制{value}",
@@ -240,47 +236,6 @@ export const zhCNLocale: LocalePlugin = {
"chat.commandCopy": "复制最后一条助手消息",
"chat.compacted": "已压缩",
"chat.tokensSaved": "节省 {saved}",
"mcp.addServer": "添加服务器",
"mcp.addEnv": "添加环境变量",
"mcp.serverName": "服务器名称",
"mcp.enabled": "启用",
"mcp.transport": "传输方式",
"mcp.command": "命令",
"mcp.args": "参数(每行一个)",
"mcp.url": "URL",
"mcp.headers": "请求头(JSON)",
"mcp.auth": "认证",
"mcp.bearerToken": "Bearer token",
"mcp.cwd": "工作目录",
"mcp.lifecycle": "生命周期",
"mcp.idleTimeout": "空闲超时(分钟)",
"mcp.requestTimeout": "请求超时(毫秒)",
"mcp.advanced": "高级(原始 JSON)",
"mcp.applyJson": "应用 JSON",
"mcp.invalidJson": "JSON 无效",
"mcp.noServers": "未配置 MCP 服务器",
"mcp.selectServer": "选择一个服务器进行编辑",
"mcp.restartHint": "保存的更改在重启 pi(或 /reload)后生效。",
"vision.title": "视觉配置",
"vision.loading": "加载中…",
"vision.backend.ollama": "本地 Ollama",
"vision.backend.ollamaHint": "免费、私密——本机 Ollama 需已拉取视觉模型",
"vision.backend.openai": "OpenAI 兼容 API",
"vision.backend.openaiHint": "任意 OpenAI 兼容的视觉端点(云端或自托管)",
"vision.ollama.host": "Ollama 地址",
"vision.ollama.hostPlaceholder": "如 http://localhost:11434",
"vision.ollama.model": "视觉模型",
"vision.ollama.modelPlaceholder": "如 qwen3-vl:8b",
"vision.openai.baseUrl": "接口地址",
"vision.openai.baseUrlPlaceholder": "如 https://api.openai.com/v1",
"vision.openai.apiKey": "API 密钥",
"vision.openai.model": "模型",
"vision.openai.modelPlaceholder": "如 gpt-4o-mini / glm-4.5v",
"vision.effective": "保存后下次识图请求立即生效,无需重启。上传图片会自动转录为文本,供纯文本主模型(如 DeepSeek)使用。",
"vision.notConfigured": "尚未选择后端——未配置时识图请求会报错,请先选择后端并填写对应参数。",
"vision.loadFailed": "配置加载/保存失败",
"vision.save": "保存",
"vision.saved": "已保存——下次识图请求生效",
"i18n.close": "关闭",
"i18n.copy": "复制",
"i18n.copied": "已复制",
@@ -349,16 +304,6 @@ export const zhCNLocale: LocalePlugin = {
"i18n.mentionSelectedLines": "提及所选行",
"i18n.disableWrap": "禁用自动换行",
"i18n.enableWrap": "启用自动换行",
"i18n.editFile": "编辑文件",
"i18n.stopEditing": "停止编辑",
"i18n.saveFile": "保存文件",
"i18n.saveShortcut": "保存(Ctrl/Cmd+S)",
"i18n.discardChanges": "放弃未保存的更改",
"i18n.saveError": "无法保存文件:{error}",
"i18n.closeWithoutSaving": "您有未保存的更改。放弃并退出编辑?",
"i18n.editing": "编辑中",
"i18n.unsaved": "未保存的更改",
"i18n.readOnly": "只读",
"i18n.htmlPreview": "HTML 预览",
"i18n.previewFile": "预览 {file}",
"i18n.invalidMermaid": "Mermaid 图表无效",
@@ -411,19 +356,6 @@ export const zhCNLocale: LocalePlugin = {
"i18n.configuredVersion": "已配置 {version}",
"i18n.extensions": "扩展",
"i18n.prompts": "提示词",
"extensions.app": "应用",
"extensions.project": "项目",
"extensions.builtin": "内置",
"extensions.enabled": "已启用",
"extensions.disabled": "已禁用",
"extensions.blocked": "已阻止",
"extensions.total": "个扩展",
"extensions.status": "状态",
"extensions.source": "来源",
"extensions.path": "路径",
"extensions.noExtensions": "未找到直接扩展",
"extensions.projectResourcesBlocked": "项目尚未受信任,项目扩展已被阻止。",
"extensions.diagnostics": "条诊断信息",
"i18n.themes": "主题",
"i18n.resourceCount": "{count}{label}",
"i18n.extensionShort": "扩展",
+39 -100
View File
@@ -1,117 +1,56 @@
export const MAX_ATTACHED_IMAGE_BYTES = 10 * 1024 * 1024;
export const MAX_ATTACHED_IMAGES = 10;
/** 上传时自动压缩:长边超过此像素的 JPEG 缩到此边长(相机照片主场景,视觉模型输入上限 ~1344px,1600 无损)。 */
export const MAX_IMAGE_EDGE_PX = 1600;
/** JPEG 重编码质量:避免伪影糊掉边缘/小字。 */
export const IMAGE_JPEG_QUALITY = 0.85;
export interface Base64ImageAttachment {
data: string;
mimeType: string;
data: string;
mimeType: string;
}
function isBase64DataChar(code: number): boolean {
return (
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a) ||
(code >= 0x30 && code <= 0x39) ||
code === 0x2b ||
code === 0x2f
);
return (code >= 0x41 && code <= 0x5a)
|| (code >= 0x61 && code <= 0x7a)
|| (code >= 0x30 && code <= 0x39)
|| code === 0x2b
|| code === 0x2f;
}
export function getBase64DecodedByteLength(data: string): number | null {
if (!data || data.length % 4 !== 0) return null;
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
const dataEnd = data.length - padding;
for (let index = 0; index < dataEnd; index += 1) {
if (!isBase64DataChar(data.charCodeAt(index))) return null;
}
for (let index = dataEnd; index < data.length; index += 1) {
if (data[index] !== "=") return null;
}
return (data.length / 4) * 3 - padding;
if (!data || data.length % 4 !== 0) return null;
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
const dataEnd = data.length - padding;
for (let index = 0; index < dataEnd; index += 1) {
if (!isBase64DataChar(data.charCodeAt(index))) return null;
}
for (let index = dataEnd; index < data.length; index += 1) {
if (data[index] !== "=") return null;
}
return (data.length / 4) * 3 - padding;
}
export function isBase64ImageWithinLimits(
value: unknown,
): value is Base64ImageAttachment {
if (!value || typeof value !== "object") return false;
const image = value as Partial<Base64ImageAttachment>;
if (
typeof image.data !== "string" ||
typeof image.mimeType !== "string" ||
!image.mimeType.startsWith("image/")
) {
return false;
}
const bytes = getBase64DecodedByteLength(image.data);
return bytes !== null && bytes <= MAX_ATTACHED_IMAGE_BYTES;
}
/**
* 上传前压缩:JPEG 和 PNG 且长边超过 MAX_IMAGE_EDGE_PX 时缩放并统一转 JPEG。
* PNG 截图(代码编辑器/UI)通常无透明通道,转 JPEG 体积大幅减小。
* WebP/GIF 原样返回(WebP 已经高效;GIF 可能是动画)。
* 重编码无收益(更小或失败)时退回原文件。
*/
export async function compressImageFile(file: File): Promise<File> {
// 仅压缩 JPEG 和 PNG(最常见的截图/照片格式)
if (
!file.type ||
!(file.type.startsWith("image/jpeg") || file.type.startsWith("image/png"))
) {
return file;
}
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
return file; // 解码失败退化为原文件
}
try {
const edge = Math.max(bitmap.width, bitmap.height);
if (edge <= MAX_IMAGE_EDGE_PX) return file; // 本就不大,避免无谓重编码
const scale = MAX_IMAGE_EDGE_PX / edge;
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(bitmap, 0, 0, width, height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", IMAGE_JPEG_QUALITY),
);
if (!blob || blob.size >= file.size) return file; // 重编码无收益则用原文件
return new File([blob], file.name.replace(/\.\w+$/, ".jpg"), {
type: "image/jpeg",
});
} finally {
bitmap.close();
}
export function isBase64ImageWithinLimits(value: unknown): value is Base64ImageAttachment {
if (!value || typeof value !== "object") return false;
const image = value as Partial<Base64ImageAttachment>;
if (typeof image.data !== "string" || typeof image.mimeType !== "string" || !image.mimeType.startsWith("image/")) {
return false;
}
const bytes = getBase64DecodedByteLength(image.data);
return bytes !== null && bytes <= MAX_ATTACHED_IMAGE_BYTES;
}
/** Return an API-safe error for prompt, steering, and follow-up image arrays. */
export function validateAgentImages(value: unknown): string | null {
if (value === undefined) return null;
if (!Array.isArray(value)) return "images must be an array";
if (value.length > MAX_ATTACHED_IMAGES) {
return `A message can include at most ${MAX_ATTACHED_IMAGES} images`;
}
for (const image of value) {
if (
!image ||
typeof image !== "object" ||
(image as { type?: unknown }).type !== "image"
) {
return "Each attachment must be an image";
}
if (!isBase64ImageWithinLimits(image)) {
return `Each image must be valid base64 image data of ${MAX_ATTACHED_IMAGE_BYTES / (1024 * 1024)}MB or smaller`;
}
}
return null;
if (value === undefined) return null;
if (!Array.isArray(value)) return "images must be an array";
if (value.length > MAX_ATTACHED_IMAGES) {
return `A message can include at most ${MAX_ATTACHED_IMAGES} images`;
}
for (const image of value) {
if (!image || typeof image !== "object" || (image as { type?: unknown }).type !== "image") {
return "Each attachment must be an image";
}
if (!isBase64ImageWithinLimits(image)) {
return `Each image must be valid base64 image data of ${MAX_ATTACHED_IMAGE_BYTES / (1024 * 1024)}MB or smaller`;
}
}
return null;
}
+2 -11
View File
@@ -2,7 +2,7 @@ import { execFile } from "child_process";
import { existsSync, mkdirSync, realpathSync } from "fs";
import { basename, dirname, join, resolve } from "path";
import { promisify } from "util";
import { allowFileRoot, normalizeSlashes } from "./allowed-roots";
import { allowFileRoot } from "./allowed-roots";
const execFileAsync = promisify(execFile);
@@ -48,15 +48,6 @@ export function invalidateProjectCache(): void {
globalThis.__piProjectCache?.clear();
}
/** Path equality that tolerates Windows quirks: git prints forward slashes
* while realpathSync uses backslashes, and drive letters may differ in case. */
function samePath(a: string, b: string): boolean {
if (process.platform === "win32") {
return normalizeSlashes(a).toLowerCase() === normalizeSlashes(b).toLowerCase();
}
return a === b;
}
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
timeout: 10_000,
@@ -108,7 +99,7 @@ export async function resolveProject(cwd: string): Promise<ProjectInfo> {
// cwd is a subdirectory of a repo keeps its own project identity —
// grouping subdirs under the repo root would change where new sessions
// are created for existing users.
const isTopLevel = samePath(toplevel, realCwd);
const isTopLevel = toplevel === realCwd;
const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
info = {
projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
+1 -6
View File
@@ -9,11 +9,6 @@ try {
piVersion = (JSON.parse(readFileSync(piPkgPath, "utf8")) as { version: string }).version;
} catch { /* package not found, use default */ }
const allowedDevOrigins = [
"192.168.*.*",
...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []),
].map((origin) => origin.trim()).filter(Boolean);
const nextConfig: NextConfig = {
devIndicators: false,
serverExternalPackages: [
@@ -23,7 +18,7 @@ const nextConfig: NextConfig = {
"@earendil-works/pi-ai",
"@earendil-works/pi-tui",
],
allowedDevOrigins,
allowedDevOrigins: ['192.168.*.*'],
async headers() {
return [
{
+1 -3
View File
@@ -3,9 +3,7 @@
"private": true,
"pi": {
"extensions": [
"./searxng-search.ts",
"./vision.ts",
"./runtime-control.ts"
"./searxng-search.ts"
]
}
}
-303
View File
@@ -1,303 +0,0 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Type } from "typebox";
const WEB_BASE_URL =
process.env.PI_AGENT_WEB_URL?.trim() || "http://127.0.0.1:30141";
const REQUEST_FILE_NAME = "restart-request.json";
const REQUEST_VERSION = 1;
const IDLE_TIMEOUT_MS = 15_000;
const POLL_INTERVAL_MS = 150;
type RestartRequest = {
version: number;
requestId: string;
sessionId: string;
cwd: string;
testInstructions: string;
state: "requested" | "ready";
createdAt: string;
readyAt?: string;
};
type AgentStateResponse = {
running?: boolean;
state?: {
isStreaming?: boolean;
isPromptRunning?: boolean;
isCompacting?: boolean;
isBashRunning?: boolean;
};
};
function projectRoot(): string {
return process.env.PI_AGENT_APP_ROOT?.trim() || process.cwd();
}
function restartRequestPath(): string {
return join(projectRoot(), "data", "agent", REQUEST_FILE_NAME);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
await mkdir(join(projectRoot(), "data", "agent"), { recursive: true });
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, JSON.stringify(value, null, 2), "utf8");
try {
await rename(temporaryPath, filePath);
} catch (error) {
await unlink(filePath).catch(() => undefined);
await rename(temporaryPath, filePath).catch(() => {
throw error;
});
}
}
async function readRestartRequest(): Promise<RestartRequest | null> {
try {
const parsed = JSON.parse(
await readFile(restartRequestPath(), "utf8"),
) as Partial<RestartRequest>;
if (
parsed.version !== REQUEST_VERSION ||
typeof parsed.requestId !== "string" ||
typeof parsed.sessionId !== "string" ||
typeof parsed.cwd !== "string" ||
typeof parsed.testInstructions !== "string" ||
(parsed.state !== "requested" && parsed.state !== "ready")
) {
return null;
}
return parsed as RestartRequest;
} catch {
return null;
}
}
async function markRestartReady(sessionId: string): Promise<void> {
const request = await readRestartRequest();
if (!request || request.sessionId !== sessionId || request.state !== "requested") {
return;
}
await writeJsonAtomically(restartRequestPath(), {
...request,
state: "ready",
readyAt: new Date().toISOString(),
});
}
function sessionIdFromContext(ctx: {
sessionManager: { getSessionId(): string };
}): string {
const sessionId = ctx.sessionManager.getSessionId();
if (!sessionId) throw new Error("The current Pi session has no session ID.");
return sessionId;
}
function requireRpcMode(ctx: { mode: string }): void {
if (ctx.mode !== "rpc") {
throw new Error(
"Runtime control is available in Pi Web only; use the local /reload command in TUI mode.",
);
}
}
async function postAgentCommand(
sessionId: string,
command: Record<string, unknown>,
): Promise<unknown> {
const response = await fetch(
`${WEB_BASE_URL}/api/agent/${encodeURIComponent(sessionId)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
},
);
if (!response.ok) {
const detail = (await response.text().catch(() => "")).slice(0, 300);
throw new Error(
`Pi Web command failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
);
}
return response.json().catch(() => undefined);
}
async function waitForAgentIdle(sessionId: string): Promise<void> {
const deadline = Date.now() + IDLE_TIMEOUT_MS;
while (Date.now() < deadline) {
const response = await fetch(
`${WEB_BASE_URL}/api/agent/${encodeURIComponent(sessionId)}`,
);
if (!response.ok) throw new Error(`Pi Web state failed with HTTP ${response.status}`);
const data = (await response.json()) as AgentStateResponse;
const state = data.state;
if (
!data.running ||
!state ||
(!state.isStreaming &&
!state.isPromptRunning &&
!state.isCompacting &&
!state.isBashRunning)
) {
return;
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error("Timed out waiting for the current Agent turn to finish.");
}
function scheduleRuntimeReload(
sessionId: string,
testInstructions: string,
): void {
void (async () => {
try {
await waitForAgentIdle(sessionId);
await postAgentCommand(sessionId, { type: "reload" });
if (testInstructions) {
await postAgentCommand(sessionId, {
type: "prompt",
message:
"[Pi 运行时已重新加载]\n\n" +
"请继续验证刚才的修改,不要重复编辑。测试要求:\n" +
testInstructions,
});
}
} catch (error) {
const cause = error instanceof Error ? error.message : String(error);
try {
await postAgentCommand(sessionId, {
type: "prompt",
message:
"[Pi 运行时重载失败]\n\n" +
`请先检查运行时状态。失败原因:${cause}`,
});
} catch {
// The Web process may be unavailable; the failure is still visible in
// the extension/runtime logs when the session can be recovered.
}
}
})();
}
export default function runtimeControlExtension(pi: ExtensionAPI): void {
pi.registerCommand("reload-runtime", {
description: "重新加载 Pi 扩展、skills、prompts 和配置",
handler: async (_args, ctx) => {
await ctx.reload();
},
});
pi.on("session_shutdown", async (_event, ctx) => {
await markRestartReady(ctx.sessionManager.getSessionId());
});
pi.registerTool({
name: "reload_runtime",
label: "Reload Pi runtime",
description:
"重新加载 Pi 的扩展、skills、prompts 和运行时配置,不重启 Web 服务。" +
"修改 resources/extensions、skills、prompts 或视觉配置后使用。" +
"可选地在重载完成后自动继续执行测试。",
promptSnippet: "Reload Pi runtime after extension or resource changes",
promptGuidelines: [
"修改 Pi 扩展、skills、prompts 或视觉配置后,优先使用 reload_runtime。",
"reload_runtime 不会重启 Web 服务;如果需要测试,可填写 testInstructions。",
"不要为了扩展修改直接执行 npm run restart 或 pi-agent-public.ps1。",
],
parameters: Type.Object({
testInstructions: Type.Optional(
Type.String({
description: "重载完成后需要继续执行的测试说明",
maxLength: 4000,
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
requireRpcMode(ctx);
const sessionId = sessionIdFromContext(ctx);
const testInstructions =
typeof params.testInstructions === "string"
? params.testInstructions.trim()
: "";
scheduleRuntimeReload(sessionId, testInstructions);
return {
content: [
{
type: "text",
text: testInstructions
? "已安排运行时重载。重载完成后会自动继续执行测试。"
: "已安排运行时重载。",
},
],
details: { mode: "reload", sessionId },
terminate: true,
};
},
});
pi.registerTool({
name: "restart_web_and_test",
label: "Restart Pi Web and test",
description:
"通过外部 supervisor 重启 Pi Web,并在服务恢复后自动恢复当前会话、继续执行测试。" +
"这是终止当前 Agent 回合的操作;只用于 Web 服务、依赖、环境变量或 Pi 核心代码必须重启的情况。",
promptSnippet: "Schedule a supervised Pi Web restart and resume testing",
promptGuidelines: [
"只有修改 Web 服务、Node 依赖、环境变量或 Pi 核心代码时才使用 restart_web_and_test。",
"restart_web_and_test 会短暂断开 Web,但会保存并恢复当前会话。",
"不要直接通过 bash 执行 npm run restart 或 pi-agent-public.ps1。",
"必须在 testInstructions 中说明 Web 恢复后需要执行的验证步骤。",
],
parameters: Type.Object({
testInstructions: Type.String({
description: "Pi Web 重启完成后,Agent 应继续执行的测试说明",
minLength: 1,
maxLength: 4000,
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
requireRpcMode(ctx);
const sessionId = sessionIdFromContext(ctx);
const testInstructions = params.testInstructions.trim();
const existing = await readRestartRequest();
if (existing && (existing.state === "requested" || existing.state === "ready")) {
throw new Error(
`A Web restart is already pending (${existing.requestId}). Wait for it to finish.`,
);
}
const request: RestartRequest = {
version: REQUEST_VERSION,
requestId: randomUUID(),
sessionId,
cwd: ctx.cwd,
testInstructions,
state: "requested",
createdAt: new Date().toISOString(),
};
await writeJsonAtomically(restartRequestPath(), request);
ctx.shutdown();
return {
content: [
{
type: "text",
text:
"已请求外部 supervisor 重启 Pi Web。当前回合将结束,服务恢复后会自动继续测试。",
},
],
details: {
mode: "restart",
requestId: request.requestId,
},
terminate: true,
};
},
});
}
+2 -7
View File
@@ -91,8 +91,7 @@ function formatResults(data: SearxngResponse, maxResults: number): string {
const formatted = results.map((result, index) => {
const lines = [`${index + 1}. ${compactText(result.title ?? "(no title)", 500)}`];
if (result.url) lines.push(` URL: ${result.url}`);
let engines = result.engines && result.engines.length > 0 ? result.engines : [];
if (engines.length === 0 && result.engine) engines = [result.engine];
const engines = result.engines?.length ? result.engines : result.engine ? [result.engine] : [];
if (engines.length > 0) lines.push(` Engines: ${engines.join(", ")}`);
if (result.publishedDate) lines.push(` Published: ${result.publishedDate}`);
if (result.content) lines.push(` ${compactText(result.content, MAX_SNIPPET_LENGTH)}`);
@@ -126,14 +125,10 @@ export default function searxngSearchExtension(pi: ExtensionAPI) {
name: "web_search",
label: "Web Search",
description:
"Search the current web through the application's configured SearXNG service. Returns result titles, URLs, engines, dates, and snippets. Use it for recent or time-sensitive information, current software documentation and versions, unfamiliar errors, explicit lookup requests, and verifiable facts you are uncertain about. By default verify anything that may have changed within the last year.",
"Search the current web through the application's configured SearXNG service. Returns result titles, URLs, engines, dates, and snippets. Use it for recent or time-sensitive information, current software documentation and versions, unfamiliar errors, explicit lookup requests, and verifiable facts you are uncertain about.",
promptSnippet: "Search the current web through the configured SearXNG service",
promptGuidelines: [
"Use web_search when the user asks to search or verify online, or when information may have changed recently.",
"Search by default for ANY question that involves software/library versions, APIs, error messages, tutorials, news, events, prices, or facts that may have changed within the last ~year — verify these with a live search rather than answering from training-data memory.",
"Search when the user cites a source, reference, claim, or asks you to check something online or if something is true/current.",
"When genuinely uncertain whether information is current or correct, search instead of guessing.",
"You MAY skip searching only when fully confident the answer cannot have changed (pure math, static language syntax, facts local to this codebase) or the user clearly wants a purely local/codebase answer.",
"Prefer focused queries. If results are weak, refine the query or select suitable engines and try again.",
"Do not search for information already available in the opened codebase, conversation, or provided files.",
"When presenting search findings, retain the result URLs so the user can inspect the sources.",
-609
View File
@@ -1,609 +0,0 @@
/**
* Vision extension: describe images for text-only main models (e.g. DeepSeek).
*
* Two interchangeable backends, selected by VISION_BACKEND. No backend is
* preconfigured — users must pick one (Web UI vision panel or env vars):
* - "ollama": local Ollama vision model via the native /api/chat endpoint.
* OLLAMA_HOST (default http://localhost:11434)
* OLLAMA_VISION_MODEL (required, e.g. qwen3-vl:8b)
* - "openai": any OpenAI-compatible vision API (cloud or self-hosted).
* VISION_OPENAI_BASE_URL e.g. https://api.openai.com/v1
* VISION_OPENAI_API_KEY
* VISION_OPENAI_MODEL e.g. gpt-4o-mini, glm-4.5v, qwen-vl-max
*
* Why not Ollama's OpenAI-compatible /v1 endpoint: it moves qwen3-family
* reasoning output into the `reasoning` field with an empty `content`, which Pi
* treats as an empty reply. The native /api/chat with `think: false` returns a
* normal textual answer, so the Ollama backend deliberately bypasses /v1.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createHash } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { Type } from "typebox";
const REQUEST_TIMEOUT_MS = 180_000;
const MAX_IMAGE_BYTES = 7 * 1024 * 1024; // Ollama's built-in per-image limit
const DEFAULT_MAX_TOKENS = 4096; // generous: thinking-based APIs spend budget on reasoning first
const DEFAULT_PROMPT = [
"请详细描述这张图片,输出要求:",
"1. 完整 OCR:按阅读顺序逐字转录所有可见文字(含 UI 标签、按钮、错误信息、代码、数字、日期),保留版式线索。",
"2. 版式与视觉结构:区域、颜色、形状;UI 截图、图表(坐标轴/数值/趋势)、表格用 markdown 重建、流程图(节点/连线/方向)。",
"3. 语义总结:2-3 句概括图片内容与关键信息。",
"4. 模糊/截断/有歧义处明确说明,不要猜测。",
"5. 多张图片时,分别描述每张,用【图片1】【图片2】…标注。",
"主模型看不到图,完全依赖你的转录,文字务必穷尽。",
].join("\n");
/** Hook transcription prompt: the automatic Web-UI-image pipeline. Kept short
* on purpose — each run is ~15s of Ollama time and the text is injected into
* the main model's context every turn, so verbosity costs both latency and
* tokens. The `vision` tool keeps the detailed DEFAULT_PROMPT above. */
const HOOK_PROMPT = [
`用中文简要描述此图(主模型完全依赖此转录):`,
`1. 可见文字逐字转录(标签/代码/数字),无则写无文字。`,
`2. 画面内容 1-2 句。`,
`控制在 50 字内。`,
].join("\n");
const visionParams = Type.Object({
image_paths: Type.Array(
Type.String({
description: "图片文件路径(绝对或相对路径),至少一个",
minLength: 1,
}),
{ minItems: 1, maxItems: 8 },
),
backend: Type.Optional(
Type.Union([Type.Literal("ollama"), Type.Literal("openai")], {
description:
"视觉后端:ollama(本地)或 openai(OpenAI 兼容 API)。默认取 VISION_BACKEND 环境变量,未配置时报错",
}),
),
model: Type.Optional(
Type.String({
description:
"视觉模型 tag/ID,默认取后端对应环境变量(OLLAMA_VISION_MODEL 或 VISION_OPENAI_MODEL)",
}),
),
prompt: Type.Optional(
Type.String({
description: `自定义识图指令,默认:${DEFAULT_PROMPT.split("\n")[0]}`,
}),
),
});
function envOr(name: string, fallback: string): string {
const value = process.env[name]?.trim();
return value && value.length > 0 ? value : fallback;
}
function mimeFromPath(filePath: string): string {
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "png":
return "image/png";
case "jpg":
case "jpeg":
return "image/jpeg";
case "webp":
return "image/webp";
case "gif":
return "image/gif";
case "bmp":
return "image/bmp";
default:
return "application/octet-stream";
}
}
interface LoadedImage {
base64: string;
mime: string;
}
async function loadImages(imagePaths: string[]): Promise<LoadedImage[]> {
const images: LoadedImage[] = [];
for (const filePath of imagePaths) {
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat) throw new Error(`vision: file not found: ${filePath}`);
if (!fileStat.isFile())
throw new Error(`vision: not a regular file: ${filePath}`);
if (fileStat.size === 0) throw new Error(`vision: empty file: ${filePath}`);
if (fileStat.size > MAX_IMAGE_BYTES) {
throw new Error(
`vision: ${filePath} is ${fileStat.size} bytes, exceeding the ${MAX_IMAGE_BYTES} byte limit. Resize or compress the image first.`,
);
}
const buffer = await readFile(filePath);
images.push({
base64: buffer.toString("base64"),
mime: mimeFromPath(filePath),
});
}
return images;
}
function requestSignal(signal: AbortSignal | undefined): AbortSignal {
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
}
async function describeWithOllama(
baseUrl: string,
model: string,
prompt: string,
images: LoadedImage[],
signal: AbortSignal | undefined,
): Promise<string> {
// Ollama defaults to a small num_ctx (4096 on this setup); multi-image
// requests blow past it. Raise explicitly — the model supports 262k.
const numCtx = parseInt(envOr("OLLAMA_NUM_CTX", "16384"), 10) || 16384;
// Keep the vision model resident in VRAM: system OLLAMA_KEEP_ALIVE is 30s on
// this machine, so every transcribe would cold-load 7.7GB otherwise. -1 =
// stay loaded until memory pressure evicts it (numeric -1, not "-1").
const keepAliveRaw = envOr("OLLAMA_VISION_KEEP_ALIVE", "-1");
const keepAlive: number | string = keepAliveRaw === "-1" ? -1 : keepAliveRaw;
const body = {
model,
messages: [
{
role: "user",
content: prompt,
images: images.map((img) => img.base64),
},
],
stream: false,
think: false,
keep_alive: keepAlive,
options: { temperature: 0, num_ctx: numCtx },
};
let response: Response;
try {
response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: requestSignal(signal),
});
} catch (error) {
const cause = error instanceof Error ? error.message : String(error);
throw new Error(
`vision: cannot reach Ollama at ${baseUrl} (${cause}). Is Ollama running?`,
);
}
if (!response.ok) {
const detail = (await response.text().catch(() => "")).slice(0, 500);
throw new Error(
`vision: Ollama returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
);
}
const data = (await response.json()) as { message?: { content?: string } };
const content = data.message?.content?.trim();
if (!content) {
throw new Error(
`vision: Ollama model ${model} returned an empty response. ` +
"Check the model tag and that it supports vision.",
);
}
return content;
}
async function describeWithOpenAI(
baseUrl: string,
apiKey: string,
model: string,
prompt: string,
images: LoadedImage[],
signal: AbortSignal | undefined,
): Promise<string> {
const body = {
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
...images.map((img) => ({
type: "image_url",
image_url: { url: `data:${img.mime};base64,${img.base64}` },
})),
],
},
],
max_tokens: DEFAULT_MAX_TOKENS,
temperature: 0,
};
let response: Response;
try {
response = await fetch(`${baseUrl.replace(/\/+$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: requestSignal(signal),
});
} catch (error) {
const cause = error instanceof Error ? error.message : String(error);
throw new Error(`vision: cannot reach ${baseUrl} (${cause}).`);
}
if (!response.ok) {
const detail = (await response.text().catch(() => "")).slice(0, 500);
throw new Error(
`vision: API returned HTTP ${response.status}${detail ? `: ${detail}` : ""}. ` +
"Check VISION_OPENAI_BASE_URL / VISION_OPENAI_API_KEY / VISION_OPENAI_MODEL.",
);
}
const data = (await response.json()) as {
choices?: Array<{
message?: {
content?: string;
reasoning_content?: string;
reasoning?: string;
};
finish_reason?: string;
}>;
};
const choice = data.choices?.[0];
const content = choice?.message?.content?.trim();
if (content) return content;
const reasoning =
choice?.message?.reasoning_content || choice?.message?.reasoning;
const finish = choice?.finish_reason ?? "unknown";
if (reasoning) {
throw new Error(
`vision: model ${model} returned only reasoning (finish=${finish}). ` +
"If it is a thinking model, pick a non-thinking vision model or raise max_tokens.",
);
}
throw new Error(
`vision: model ${model} returned an empty response (finish=${finish}).`,
);
}
interface DescribeOptions {
backend?: string;
model?: string;
prompt?: string;
signal?: AbortSignal;
}
/**
* Optional file-based configuration, edited from the Web UI vision panel
* (writes `$PI_CODING_AGENT_DIR/vision.json`). Environment variables and
* per-call parameters take precedence over this file; every read is fresh, so
* saving the panel takes effect on the next image request without a restart.
*/
interface VisionFileConfig {
backend?: "ollama" | "openai";
ollama?: { host?: string; model?: string };
openai?: { baseUrl?: string; apiKey?: string; model?: string };
}
function visionConfigPath(): string {
const agentDir = process.env.PI_CODING_AGENT_DIR?.trim();
return (
(agentDir && agentDir.length > 0
? agentDir
: join(homedir(), ".pi", "agent")) + "/vision.json"
);
}
async function loadVisionFileConfig(): Promise<VisionFileConfig> {
try {
const raw = await readFile(visionConfigPath(), "utf8");
const parsed = JSON.parse(raw) as VisionFileConfig;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
async function describeImages(
images: LoadedImage[],
options: DescribeOptions,
): Promise<string> {
const fileConfig = await loadVisionFileConfig();
const backend =
options.backend ?? envOr("VISION_BACKEND", fileConfig.backend ?? "");
if (backend !== "ollama" && backend !== "openai") {
throw new Error(
"vision: no vision backend configured. Pick one in the Web UI vision panel " +
"(lower-left → Models → Vision) or set VISION_BACKEND=ollama|openai plus " +
"the backend's address/model (see the OLLAMA_* / VISION_OPENAI_* env vars).",
);
}
const prompt = options.prompt ?? DEFAULT_PROMPT;
if (backend === "ollama") {
const baseUrl = envOr(
"OLLAMA_HOST",
fileConfig.ollama?.host ?? "http://localhost:11434",
);
const model =
options.model ??
envOr("OLLAMA_VISION_MODEL", fileConfig.ollama?.model ?? "");
if (!model) {
throw new Error(
"vision: the ollama backend needs a vision model. Set it in the Web UI " +
"vision panel or via OLLAMA_VISION_MODEL (pull one first, e.g. " +
"`ollama pull qwen3-vl:8b`).",
);
}
return describeWithOllama(baseUrl, model, prompt, images, options.signal);
}
const baseUrl = envOr(
"VISION_OPENAI_BASE_URL",
fileConfig.openai?.baseUrl ?? "",
);
const apiKey = envOr(
"VISION_OPENAI_API_KEY",
fileConfig.openai?.apiKey ?? "",
);
const model =
options.model ??
envOr("VISION_OPENAI_MODEL", fileConfig.openai?.model ?? "");
if (!baseUrl || !apiKey || !model) {
throw new Error(
"vision: the openai backend needs a base URL, API key and model. " +
"Configure them in the Web UI vision panel (lower-left) or via " +
"VISION_OPENAI_BASE_URL / VISION_OPENAI_API_KEY / VISION_OPENAI_MODEL. " +
"Example: https://api.openai.com/v1 + gpt-4o-mini.",
);
}
return describeWithOpenAI(
baseUrl,
apiKey,
model,
prompt,
images,
options.signal,
);
}
/**
* Description cache, persisted to disk so restarts don't force re-transcribing
* the whole conversation's history. Each entry is ~300B; capped at 64.
*/
const IMAGE_DESCRIPTION_CACHE = new Map<string, string>();
const CACHE_MAX_ENTRIES = 64;
let cacheLoaded = false;
function visionCachePath(): string {
const agentDir = process.env.PI_CODING_AGENT_DIR?.trim();
return (
(agentDir && agentDir.length > 0
? agentDir
: join(homedir(), ".pi", "agent")) + "/vision-cache.json"
);
}
async function loadDescriptionCache(): Promise<void> {
if (cacheLoaded) return;
cacheLoaded = true;
try {
const parsed = JSON.parse(
await readFile(visionCachePath(), "utf8"),
) as Record<string, string>;
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") IMAGE_DESCRIPTION_CACHE.set(key, value);
}
} catch {
/* no cache file yet, or corrupt — start empty */
}
}
function persistDescriptionCache(): void {
void writeFile(
visionCachePath(),
JSON.stringify(Object.fromEntries(IMAGE_DESCRIPTION_CACHE)),
"utf8",
).catch(() => {
/* disk cache is best-effort; failures must never break transcription */
});
}
function dataUrlToLoadedImage(url: string): LoadedImage | null {
const match = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(url);
if (!match) return null;
return { base64: match[2], mime: match[1] };
}
function imageCacheKey(image: LoadedImage, prompt: string): string {
// Hash image + prompt so different prompts (hook vs tool) don't share cache.
// PNG/JPG headers repeat for same dimensions, so full base64 is needed.
return createHash("md5").update(image.base64).update(prompt).digest("hex");
}
/**
* Per-image description cache. Every request rebuilds the payload from the
* session file, which keeps the original image parts, so without this cache
* the whole conversation's images would be re-transcribed every turn and
* eventually exceed Ollama's context window. Single-image granularity means
* only genuinely new images hit the vision model.
*/
async function getImageDescription(
image: LoadedImage,
prompt: string,
signal: AbortSignal | undefined,
): Promise<string> {
const key = imageCacheKey(image, prompt);
await loadDescriptionCache();
const cached = IMAGE_DESCRIPTION_CACHE.get(key);
if (cached) return cached;
const description = await describeImages([image], { prompt, signal });
IMAGE_DESCRIPTION_CACHE.set(key, description);
if (IMAGE_DESCRIPTION_CACHE.size > CACHE_MAX_ENTRIES) {
const oldest = IMAGE_DESCRIPTION_CACHE.keys().next().value;
if (oldest !== undefined) IMAGE_DESCRIPTION_CACHE.delete(oldest);
}
persistDescriptionCache();
return description;
}
function isTextOnlyModel(model: unknown): boolean {
const input = (model as { input?: string[] } | undefined)?.input;
return Array.isArray(input) && !input.includes("image");
}
export default function visionExtension(pi: ExtensionAPI) {
// Keep user-message images intact for text-only models so the hook below can
// transcribe them (pi-ai would otherwise replace them with a text placeholder
// before before_provider_request runs). The matching pi-ai patch is applied
// and replayed by scripts/configure-pi-ai-vision.mjs.
process.env.PI_VISION_PASSTHROUGH_IMAGES = "1";
pi.registerTool({
name: "vision",
label: "Vision (image description)",
description:
"用视觉模型描述本地图片并返回详细文本(完整 OCR、版式结构、语义总结)。" +
"适用于主模型不支持图片输入(如 DeepSeek)时查看截图/图表/文档/照片。" +
"后端可配置:ollama(本地)或 openai(任意 OpenAI 兼容视觉 API),初始未配置需先设置:" +
"WebUI 视觉面板(左下角→模型→视觉)或环境变量(VISION_BACKEND + 对应地址/模型/密钥)。",
promptSnippet:
"Describe local images using a vision model (Ollama or OpenAI-compatible)",
promptGuidelines: [
"Use vision when the user asks you to look at an image (screenshot, diagram, chart, document, photo) and the current model cannot receive image attachments.",
"Pass image file paths that exist on disk; the tool reads and encodes them itself.",
"One call can describe up to 8 images; prefer batching related images into a single call.",
"The returned text is the vision model's transcription — relay it faithfully, quoting OCR text verbatim.",
"If the call fails because no vision backend is configured, report the missing environment variables.",
],
parameters: visionParams,
executionMode: "sequential",
async execute(_toolCallId, params, signal) {
const prompt = params.prompt ?? DEFAULT_PROMPT;
const images = await loadImages(params.image_paths);
const content = await describeImages(images, {
backend: params.backend,
model: params.model,
prompt,
signal,
});
return {
content: [{ type: "text", text: content }],
details: {
backend: params.backend ?? envOr("VISION_BACKEND", ""),
model: params.model ?? undefined,
imageCount: images.length,
},
};
},
});
// --- Automatic image transcription for text-only main models ---
// Web UI image uploads arrive as base64 image_url parts in the user message.
// A text-only model (e.g. DeepSeek) cannot receive them — DeepSeek rejects
// the request with HTTP 400. This hook transcribes the images through the
// configured vision backend and replaces them with text before the request
// is sent, so the main model keeps working seamlessly.
pi.on("before_provider_request", async (event, ctx) => {
const payload = event.payload as
| { messages?: Array<{ role?: string; content?: unknown }> }
| undefined;
const messages = payload?.messages;
if (!Array.isArray(messages) || messages.length === 0) return;
if (!isTextOnlyModel(ctx.model)) return; // vision-capable models pass through untouched
// Phase 1: collect image parts, replace with placeholders
// Replace each image in place with its stable transcription. Do not move
// historical descriptions to a later message: DeepSeek caches exact prompt
// prefixes, so rewriting an old image message invalidates everything after it.
interface PendingImage {
img: LoadedImage;
num: number;
msgIndex: number;
partIndex: number;
}
const pendingImages: PendingImage[] = [];
let imageNumber = 0;
let dirty = false;
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
const msg = messages[msgIdx];
if (msg?.role !== "user" || !Array.isArray(msg.content)) continue;
const newContent: unknown[] = [];
for (let partIdx = 0; partIdx < msg.content.length; partIdx++) {
const part = msg.content[partIdx];
const p = part as { type?: string; image_url?: { url?: string } };
if (p?.type !== "image_url" || typeof p.image_url?.url !== "string") {
newContent.push(part);
continue;
}
const img = dataUrlToLoadedImage(p.image_url.url);
if (!img) {
newContent.push(part);
continue;
}
imageNumber += 1;
pendingImages.push({
img,
num: imageNumber,
msgIndex: msgIdx,
partIndex: partIdx,
});
newContent.push(part); // placeholder, replaced after transcription
dirty = true;
}
if (newContent !== msg.content) {
msg.content = newContent;
}
}
if (!dirty) return;
// Phase 2: transcribe all images in parallel
const results = await Promise.all(
pendingImages.map(async ({ img, num, msgIndex, partIndex }) => {
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const desc = await getImageDescription(
img,
HOOK_PROMPT,
ctx.signal,
);
return { msgIndex, partIndex, num, desc };
} catch (err) {
console.warn(
`vision: transcription attempt ${attempt} failed:`,
err instanceof Error ? err.message : String(err),
);
if (attempt === 2) {
// Deterministic fallback — variable errors would invalidate cache
return { msgIndex, partIndex, num, desc: "[图片转录失败]" };
}
}
}
}),
);
// Phase 3: replace placeholders with descriptions
for (const r of results) {
if (!r) continue;
const { msgIndex, partIndex, num, desc } = r;
const msg = messages[msgIndex];
if (msg && Array.isArray(msg.content)) {
msg.content[partIndex] = {
type: "text",
text: `\n\n【图片${num}】\n${desc}\n\n`,
};
}
}
return payload;
});
}
+1 -8
View File
@@ -7,7 +7,6 @@ const webDir = join(rootDir, "pi-web");
const requiredProfilePaths = [
join(rootDir, "config", "settings.default.json"),
join(rootDir, "config", "mcp.default.json"),
join(rootDir, "config", "mcp.macos.default.json"),
join(rootDir, "config", "models.example.json"),
join(rootDir, "config", "subagents.default.json"),
join(rootDir, "config", "subagent-tool-description.default.md"),
@@ -28,13 +27,7 @@ const packages = [
];
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`ERROR: Invalid JSON file ${path}: ${message}`);
return {};
}
return JSON.parse(readFileSync(path, "utf8"));
}
const webPackage = readJson(join(webDir, "package.json"));
+9 -19
View File
@@ -49,12 +49,6 @@ const missing = [
...requiredTools.filter((name) => !tools.has(name)).map((name) => `tool:${name}`),
...requiredCommands.filter((name) => !commands.has(name)).map((name) => `command:/${name}`),
];
if (process.env.PI_MEMORY_NO_SEARCH === "1" && tools.has("memory_search")) {
missing.push("unexpected tool:memory_search in lightweight memory mode");
}
if (process.env.PI_MEMORY_NO_SEARCH !== "1" && !tools.has("memory_search")) {
missing.push("tool:memory_search");
}
if (missing.length > 0) {
console.error(`Managed Pi profile is incomplete: ${missing.join(", ")}`);
@@ -66,19 +60,15 @@ if (!npmCliPath) {
console.error("Run the managed profile check through npm: npm run check:profile");
process.exit(1);
}
if (platform() === "darwin") {
// Browser automation is opt-in on macOS; do not install Playwright during setup.
} else {
const playwrightResult = spawnSync(
process.execPath,
[npmCliPath, "exec", "--yes", "--package=@playwright/mcp@0.0.78", "--", "playwright-mcp", "--version"],
{ cwd: rootDir, env: process.env, encoding: "utf8" },
);
if (playwrightResult.error || playwrightResult.status !== 0) {
process.stderr.write(playwrightResult.stderr ?? "");
console.error("Unable to cache @playwright/mcp@0.0.78 for the managed MCP profile.");
process.exit(playwrightResult.status ?? 1);
}
const playwrightResult = spawnSync(
process.execPath,
[npmCliPath, "exec", "--yes", "--package=@playwright/mcp@0.0.78", "--", "playwright-mcp", "--version"],
{ cwd: rootDir, env: process.env, encoding: "utf8" },
);
if (playwrightResult.error || playwrightResult.status !== 0) {
process.stderr.write(playwrightResult.stderr ?? "");
console.error("Unable to cache @playwright/mcp@0.0.78 for the managed MCP profile.");
process.exit(playwrightResult.status ?? 1);
}
if (platform() === "win32") {
-299
View File
@@ -1,299 +0,0 @@
#!/usr/bin/env node
/**
* pi-agent-integrated 缓存/会话清理脚本
*
* 背景:会话 jsonl 内嵌上传图片的 base64(单张相机照片可达 5.7MB),
* 每次 WebUI 加载会话要整体读出+序列化+前端解码渲染 → 加载变慢;
* opengrep/npm 缓存占磁盘数 GB。
*
* 用法:node scripts/cleanup-cache.mjs [--apply] [--target all|sessions|strip-images|npm|opengrep|artifacts] [--days N] [--keep N] [--purge] [--yes]
*
* --target all (默认) | sessions(移走旧会话) | strip-images(旧会话图片替换为占位,保留对话文本) |
* npm(清 cache/npm) | opengrep(清 AppData/Local/opengrep) | artifacts(清旧子代理产物)
* --days N 会话保留 N 天内的 (默认 7);配合 --target sessions/strip-images
* --keep N 额外保留最近 N 个会话文件 (默认 0)
* --apply 真正执行;缺省为 dry-run 只预览
* --purge 同时删除回收目录中 30 天前的备份(默认只清理,不删回收站)
* --yes 跳过确认(--apply 时)
*
* 安全边界:永不触碰 agent/npm(扩展安装)、.pi-lens/tools、.pi-lens/bin、memory(记忆仓库)。
* 所有删除 = 先移入回收目录 data/agent/tmp/session-trash/<时间戳>/,--purge 才清旧备份。
*/
import fs from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
const DATA = path.join(ROOT, "data");
const SESSIONS_DIR = path.join(DATA, "agent", "sessions");
const TRASH_ROOT = path.join(DATA, "agent", "tmp", "session-trash");
const NPM_CACHE = path.join(DATA, "cache", "npm");
const OPENGREP = path.join(DATA, "home", "AppData", "Local", "opengrep");
const CUTOFF_DAYS = () => Date.now() - opt.days * 86400_000;
const args = process.argv.slice(2);
const opt = {
apply: args.includes("--apply"),
purge: args.includes("--purge"),
yes: args.includes("--yes"),
days: 7,
keep: 0,
target: "all",
};
for (let i = 0; i < args.length; i++) {
if (args[i] === "--days") opt.days = parseInt(args[++i], 10);
if (args[i] === "--keep") opt.keep = parseInt(args[++i], 10);
if (args[i] === "--target") opt.target = args[++i];
}
if (isNaN(opt.days) || opt.days < 0) opt.days = 7;
const TARGETS = ["all", "sessions", "strip-images", "npm", "opengrep", "artifacts"];
if (!TARGETS.includes(opt.target)) {
console.error(`未知 --target: ${opt.target}(可选 ${TARGETS.join(" / ")})`);
process.exit(1);
}
const want = (t) => opt.target === "all" || opt.target === t;
const mode = opt.apply ? "执行" : "预览(dry-run)";
console.log(`[cleanup-cache] ${mode} | target=${opt.target} | 会话保留 ${opt.days} 天${opt.keep ? ` + 最近 ${opt.keep} 个` : ""}\n`);
let freed = 0;
const actions = [];
const note = (s) => console.log(" " + s);
const planned = (file, size, why) => {
freed += size;
actions.push({ file, why });
note(`- ${why}: ${file} (${fmtSize(size)})`);
};
function fmtSize(n) {
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(2) + " GB";
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " MB";
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(0) + " KB";
return n + " B";
}
function dirSize(p) {
if (!fs.existsSync(p)) return 0;
let total = 0;
const st = fs.statSync(p);
if (st.isFile()) return st.size;
for (const e of fs.readdirSync(p, { withFileTypes: true })) {
total += dirSize(path.join(p, e.name));
}
return total;
}
function moveToTrash(p) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = path.join(TRASH_ROOT, stamp, path.basename(p));
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(p, dest);
return dest;
}
function countImages(file) {
let n = 0;
let bytes = 0;
try {
const lines = fs.readFileSync(file, "utf8").split("\n");
for (const line of lines) {
if (!line || !line.includes('"image"')) continue;
let e;
try { e = JSON.parse(line); } catch { continue; }
if (e?.type !== "message" || !Array.isArray(e.message?.content)) continue;
for (const b of e.message.content) {
if (b?.type !== "image") continue;
const data = typeof b.data === "string" ? b.data
: b.source?.type === "base64" ? b.source.data : null;
n++;
if (data) {
const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
bytes += Math.floor((data.length * 3) / 4) - pad;
}
}
}
} catch { /* skip */ }
return { n, bytes };
}
function stripImagesInPlace(file) {
const lines = fs.readFileSync(file, "utf8").split("\n");
let stripped = 0;
let bytes = 0;
const out = [];
for (const line of lines) {
if (!line || !line.includes('"image"')) { out.push(line); continue; }
try {
const e = JSON.parse(line);
if (e?.type === "message" && Array.isArray(e.message?.content)
&& (e.message.role === "user" || e.message.role === "toolResult")) {
const content = [];
for (const b of e.message.content) {
if (b?.type === "image") {
const data = typeof b.data === "string" ? b.data
: b.source?.type === "base64" ? b.source.data : null;
const pad = data?.endsWith("==") ? 2 : data?.endsWith("=") ? 1 : 0;
bytes += data ? Math.floor((data.length * 3) / 4) - pad : 0;
stripped++;
content.push({ type: "text", text: "[图片已由 cleanup-cache 替换为占位,原始数据已移入回收目录]" });
} else content.push(b);
}
e.message.content = content;
out.push(JSON.stringify(e));
continue;
}
} catch { /* keep as-is */ }
out.push(line);
}
if (stripped > 0) {
fs.writeFileSync(file, out.join("\n"));
}
return { stripped, bytes };
}
function listSessionFiles() {
const files = [];
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name === "subagent-artifacts") continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith(".jsonl")) files.push(p);
}
};
walk(SESSIONS_DIR);
return files;
}
function sessionInfo(file) {
const st = fs.statSync(file);
const { n, bytes } = countImages(file);
return { file, size: st.size, mtime: st.mtimeMs, images: n, imgBytes: bytes };
}
// ---------- 1. sessions / strip-images ----------
if (want("sessions") || want("strip-images")) {
console.log(`[会话] 目录 ${SESSIONS_DIR}`);
const infos = listSessionFiles().map(sessionInfo).sort((a, b) => b.mtime - a.mtime);
if (infos.length === 0) { console.log(" (无会话文件)"); }
const cutoff = CUTOFF_DAYS();
const keepIdx = Math.min(opt.keep, infos.length);
const doomed = infos.filter((s, i) => s.mtime < cutoff && i >= keepIdx);
if (want("sessions")) {
for (const s of doomed) {
planned(s.file, s.size, s.images > 0 ? `旧会话(含 ${s.images} 图)` : "旧会话");
}
if (doomed.length === 0) console.log(` (最近 ${opt.days} 天内无会话需要移走)`);
}
if (want("strip-images")) {
for (const s of doomed) {
if (s.images === 0) continue;
note(`- 图片瘦身: ${path.basename(s.file)} (${s.images} 图, ~${fmtSize(s.imgBytes)} 将替换为占位)`);
freed += s.imgBytes;
}
const recent = infos.filter((s, i) => !(s.mtime < cutoff && i >= keepIdx));
for (const s of recent) {
if (s.images === 0) continue;
note(`- 图片瘦身(最近窗口, 可选): ${path.basename(s.file)} (${s.images} 图, ~${fmtSize(s.imgBytes)})`);
}
}
console.log();
}
// ---------- 2. npm cache ----------
if (want("npm")) {
const sz = dirSize(NPM_CACHE);
if (sz > 0) planned(NPM_CACHE, sz, "npm 缓存(_cacache/_npx/_logs)");
else console.log("[npm] 缓存已空");
}
// ---------- 3. opengrep ----------
if (want("opengrep")) {
const sz = dirSize(OPENGREP);
if (sz > 0) planned(OPENGREP, sz, "opengrep 语义扫描缓存");
else console.log("[opengrep] 缓存已空");
}
// ---------- 4. artifacts ----------
if (want("artifacts")) {
const dirs = [];
const findArtifactDirs = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "subagent-artifacts") dirs.push(p);
else findArtifactDirs(p);
}
}
};
findArtifactDirs(SESSIONS_DIR);
let cnt = 0;
let sz = 0;
const files = [];
for (const d of dirs) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
const st = fs.statSync(p);
if (st.mtimeMs < CUTOFF_DAYS()) { cnt++; sz += st.size; files.push(p); }
}
}
if (cnt > 0) { note(`- ${cnt} 个旧子代理产物文件 (~${fmtSize(sz)})`); for (const f of files) planned(f, fs.statSync(f).size, "旧子代理产物"); }
else console.log("[artifacts] 无旧产物");
}
// ---------- 汇总 ----------
console.log(`\n合计可释放: ${fmtSize(freed)}`);
if (!opt.apply) {
console.log("\n以上为预览。加 --apply 真正执行(删除前先移入回收目录)。");
process.exit(0);
}
if (!opt.yes) {
console.log(`\n确认执行 ${actions.length} 项清理?输入 yes 继续:`);
const readline = (await import("node:readline")).default;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ans = await new Promise((r) => rl.question("> ", r));
rl.close();
if (ans.trim().toLowerCase() !== "yes") { console.log("已取消。"); process.exit(1); }
}
for (const a of actions) {
try {
if (a.file.includes("opengrep") || a.file === NPM_CACHE) {
fs.rmSync(a.file, { recursive: true, force: true });
console.log(`已删除 ${a.file}`);
} else {
const dest = moveToTrash(a.file);
console.log(`已移入回收目录 ${dest}`);
}
} catch (err) {
console.error(`失败: ${a.file}: ${err.message}`);
}
}
if (want("strip-images")) {
const infos = listSessionFiles().map(sessionInfo).sort((a, b) => b.mtime - a.mtime);
const keepIdx = Math.min(opt.keep, infos.length);
const cutoff = CUTOFF_DAYS();
for (const s of infos) {
if (s.images === 0) continue;
const i = infos.indexOf(s);
if (!(s.mtime < cutoff && i >= keepIdx)) continue;
const backup = moveToTrash(s.file);
const { stripped, bytes } = stripImagesInPlace(s.file);
console.log(`图片瘦身完成: ${path.basename(s.file)} (${stripped} 图, ~${fmtSize(bytes)}, 原备份 ${backup})`);
}
}
if (opt.purge) {
const purged = [];
const old = Date.now() - 30 * 86400_000;
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
const st = fs.statSync(p);
if (st.mtimeMs < old) { fs.rmSync(p, { recursive: true, force: true }); purged.push(p); }
else walk(p);
}
}
};
walk(TRASH_ROOT);
console.log(purged.length ? `回收目录已清旧备份: ${purged.join(", ")}` : "回收目录无 30 天前备份");
}
console.log(`\n完成。释放 ${fmtSize(freed)}。注意:当前正在使用的会话若被移走会丢失历史——建议先退出 pi 再执行。`);
-122
View File
@@ -1,122 +0,0 @@
/**
* pi-ai vision passthrough patch.
*
* Pi-ai's `transform-messages.js` replaces image parts with a text placeholder
* ("(image omitted: model does not support images)") for text-only models BEFORE
* the before_provider_request hook runs, so the vision extension's automatic
* transcription hook can never see the image. This patch adds an opt-in switch:
* when PI_VISION_PASSTHROUGH_IMAGES=1 (set by the vision extension at load),
* user-message image parts are kept intact so the vision hook can transcribe
* them; without the env var the original placeholder behavior is preserved
* (safe for users without the vision extension).
*
* Idempotent, replayed by run.mjs and install-managed-packages.mjs, mirroring
* configure-pi-memory.mjs.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const supportedVersion = "0.83.0";
function replaceOnce(source, before, after, label) {
if (source.includes(after)) return source;
const first = source.indexOf(before);
const last = source.lastIndexOf(before);
if (first < 0 || first !== last) {
throw new Error(`Cannot apply pi-ai vision patch: ${label}`);
}
return source.slice(0, first) + after + source.slice(first + before.length);
}
export function patchPiAiSource(input) {
let source = input.replaceAll("\r\n", "\n");
source = replaceOnce(
source,
` return messages.map((msg) => {
if (msg.role === "user" && Array.isArray(msg.content)) {
return {
...msg,
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER),
};
}`,
` const keepUserImages =
typeof process !== "undefined" && process.env?.PI_VISION_PASSTHROUGH_IMAGES === "1";
return messages.map((msg) => {
if (msg.role === "user" && Array.isArray(msg.content) && !keepUserImages) {
return {
...msg,
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER),
};
}`,
"downgradeUnsupportedImages user-image passthrough switch",
);
return source;
}
export function isPiAiVisionConfigured(source) {
return source.includes("PI_VISION_PASSTHROUGH_IMAGES === \"1\"");
}
export function defaultPiAiDir() {
// Managed installs place packages under <agentDir>/npm/node_modules; pi-ai is
// also a direct dependency of pi-web, which wins in dev. Prefer pi-web's copy.
const candidates = [
resolve(process.cwd(), "pi-web", "node_modules", "@earendil-works", "pi-ai"),
resolve(process.cwd(), "node_modules", "@earendil-works", "pi-ai"),
];
for (const candidate of candidates) {
if (existsSync(join(candidate, "package.json"))) return candidate;
}
throw new Error("pi-ai package not found under pi-web/node_modules or node_modules");
}
export function configurePiAiVision({ piAiDir = defaultPiAiDir(), quiet = false } = {}) {
const packageJsonPath = join(piAiDir, "package.json");
const sourcePath = join(piAiDir, "dist", "api", "transform-messages.js");
if (!existsSync(packageJsonPath) || !existsSync(sourcePath)) {
return { status: "missing", sourcePath };
}
let packageJson;
try {
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
} catch (error) {
throw new Error(`Cannot parse ${packageJsonPath}: ${error.message}`);
}
if (packageJson.version !== supportedVersion) {
throw new Error(
`Unsupported pi-ai version ${packageJson.version ?? "unknown"}; expected ${supportedVersion}.`,
);
}
const source = readFileSync(sourcePath, "utf8");
const patched = patchPiAiSource(source);
if (patched === source) {
if (!quiet) console.log("pi-ai vision passthrough ready.");
return { status: "ready", sourcePath };
}
writeFileSync(sourcePath, patched, "utf8");
if (!quiet) console.log("Configured pi-ai vision passthrough.");
return { status: "patched", sourcePath };
}
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
if (invokedPath === fileURLToPath(import.meta.url)) {
try {
const result = configurePiAiVision();
if (result.status === "missing") {
console.error("pi-ai is not installed. Run npm run setup first.");
process.exit(1);
}
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
-191
View File
@@ -1,191 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { agentDir as defaultAgentDir } from "./profile.mjs";
const supportedVersion = "0.4.0";
function replaceOnce(source, before, after, label) {
if (source.includes(after)) return source;
const first = source.indexOf(before);
const last = source.lastIndexOf(before);
if (first < 0 || first !== last) {
throw new Error(`Cannot apply pi-memory lightweight patch: ${label}`);
}
return source.slice(0, first) + after + source.slice(first + before.length);
}
export function patchPiMemorySource(input) {
let source = input.replaceAll("\r\n", "\n");
source = replaceOnce(
source,
`\t\tqmdAvailable = await detectQmd();
\t\tif (!qmdAvailable) {
\t\t\tif (ctx.hasUI) {
\t\t\t\tctx.ui.notify(qmdInstallInstructions(), "info");
\t\t\t}
\t\t\trefreshMemorySnapshot("session_start");
\t\t\treturn;
\t\t}`,
`\t\tconst searchEnabled = process.env.PI_MEMORY_NO_SEARCH !== "1";
\t\tqmdAvailable = searchEnabled ? await detectQmd() : false;
\t\tif (!qmdAvailable) {
\t\t\tif (ctx.hasUI && searchEnabled) {
\t\t\t\tctx.ui.notify(qmdInstallInstructions(), "info");
\t\t\t}
\t\t\trefreshMemorySnapshot("session_start");
\t\t\treturn;
\t\t}`,
"session-start qmd probe",
);
source = replaceOnce(
source,
`\t\t\tsnapshotCaveat =
\t\t\t\t\`Snapshot \${snapshotReason} at \${snapshotTakenAt}. \` +
\t\t\t\t"Use memory_read / memory_search for the authoritative latest state; " +
\t\t\t\t"recent writes may also be visible in tool-call history.";`,
`\t\t\tsnapshotCaveat =
\t\t\t\t\`Snapshot \${snapshotReason} at \${snapshotTakenAt}. \` +
\t\t\t\t(process.env.PI_MEMORY_NO_SEARCH === "1"
\t\t\t\t\t? "Use memory_read for the authoritative latest state; "
\t\t\t\t\t: "Use memory_read / memory_search for the authoritative latest state; ") +
\t\t\t\t"recent writes may also be visible in tool-call history.";`,
"snapshot search guidance",
);
source = replaceOnce(
source,
`\t\t\t"- Things to fix later or keep in mind \\u2192 scratchpad tool",
\t\t\t"- Use memory_search to find past context across all memory files (keyword, semantic, or deep search).",
\t\t\t"- Use #tags (e.g. #decision, #preference) and [[links]] (e.g. [[auth-strategy]]) in memory content to improve future search recall.",
\t\t\t'- If someone says "remember this," write it immediately.',`,
`\t\t\t"- Things to fix later or keep in mind \\u2192 scratchpad tool",
\t\t\t...(process.env.PI_MEMORY_NO_SEARCH === "1"
\t\t\t\t? []
\t\t\t\t: [
\t\t\t\t\t\t"- Use memory_search to find past context across all memory files (keyword, semantic, or deep search).",
\t\t\t\t\t\t"- Use #tags (e.g. #decision, #preference) and [[links]] (e.g. [[auth-strategy]]) in memory content to improve future search recall.",
\t\t\t\t\t]),
\t\t\t'- If someone says "remember this," write it immediately.',`,
"memory prompt search guidance",
);
source = replaceOnce(
source,
`\t// --- memory_search tool ---
\tpi.registerTool({`,
`\t// --- memory_search tool (optional in lightweight mode) ---
\tif (process.env.PI_MEMORY_NO_SEARCH !== "1") {
\t\tpi.registerTool({`,
"memory_search registration start",
);
source = replaceOnce(
source,
`\t\t\t\t\tisError: true,
\t\t\t\t\tdetails: {},
\t\t\t\t};
\t\t\t}
\t\t},
\t});
\t// --- memory_status tool (doctor) ---`,
`\t\t\t\t\tisError: true,
\t\t\t\t\tdetails: {},
\t\t\t\t};
\t\t\t}
\t\t\t},
\t\t});
\t}
\t// --- memory_status tool (doctor) ---`,
"memory_search registration end",
);
source = replaceOnce(
source,
`\t\t\tconst qmdOk = qmdAvailable || (await detectQmd());`,
`\t\t\tconst searchEnabled = process.env.PI_MEMORY_NO_SEARCH !== "1";
\t\t\tconst qmdOk = searchEnabled && (qmdAvailable || (await detectQmd()));`,
"memory status qmd probe",
);
source = replaceOnce(
source,
`\t\t\t\t"## Search (qmd)",
\t\t\t\t\`- qmd available: \${mark(qmdOk)}\`,
\t\t\t];
\t\t\tif (qmdOk) {`,
`\t\t\t\t"## Search (qmd)",
\t\t\t\tsearchEnabled
\t\t\t\t\t? \`- qmd available: \${mark(qmdOk)}\`
\t\t\t\t\t: "- Disabled by PI_MEMORY_NO_SEARCH=1 (lightweight mode)",
\t\t\t];
\t\t\tif (!searchEnabled) {
\t\t\t\t// Core Markdown memory remains fully available in lightweight mode.
\t\t\t} else if (qmdOk) {`,
"memory status lightweight result",
);
source = replaceOnce(
source,
`\t\t\t\t\`- PI_MEMORY_QMD_UPDATE: \${getQmdUpdateMode()}\`,
\t\t\t\t\`- PI_MEMORY_DIR: \${process.env.PI_MEMORY_DIR ? "set" : "default"}\`,`,
`\t\t\t\t\`- PI_MEMORY_NO_SEARCH: \${process.env.PI_MEMORY_NO_SEARCH ?? "0"}\`,
\t\t\t\t\`- PI_MEMORY_QMD_UPDATE: \${getQmdUpdateMode()}\`,
\t\t\t\t\`- PI_MEMORY_DIR: \${process.env.PI_MEMORY_DIR ? "set" : "default"}\`,`,
"memory status configuration",
);
return source;
}
export function isPiMemoryLiteConfigured(source) {
return (
source.includes('qmdAvailable = searchEnabled ? await detectQmd() : false;') &&
source.includes('if (process.env.PI_MEMORY_NO_SEARCH !== "1") {\n\t\tpi.registerTool({') &&
source.includes("Disabled by PI_MEMORY_NO_SEARCH=1 (lightweight mode)")
);
}
export function configurePiMemory({ agentDir = defaultAgentDir, quiet = false } = {}) {
const packageDir = join(agentDir, "npm", "node_modules", "pi-memory");
const packageJsonPath = join(packageDir, "package.json");
const sourcePath = join(packageDir, "index.ts");
if (!existsSync(packageJsonPath) || !existsSync(sourcePath)) {
return { status: "missing", sourcePath };
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
if (packageJson.version !== supportedVersion) {
throw new Error(
`Unsupported pi-memory version ${packageJson.version ?? "unknown"}; expected ${supportedVersion}.`,
);
}
const source = readFileSync(sourcePath, "utf8");
const patched = patchPiMemorySource(source);
if (patched === source) {
if (!quiet) console.log("pi-memory lightweight integration ready.");
return { status: "ready", sourcePath };
}
writeFileSync(sourcePath, patched, "utf8");
if (!quiet) console.log("Configured pi-memory lightweight integration.");
return { status: "patched", sourcePath };
}
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
if (invokedPath === fileURLToPath(import.meta.url)) {
const result = configurePiMemory();
if (result.status === "missing") {
console.error("pi-memory is not installed. Run npm run setup first.");
process.exit(1);
}
}
-45
View File
@@ -1,45 +0,0 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
configurePiMemory,
isPiMemoryLiteConfigured,
patchPiMemorySource,
} from "./configure-pi-memory.mjs";
import { agentDir, managedEnvironment } from "./profile.mjs";
test("managed profile defaults pi-memory to lightweight mode", () => {
const defaultEnvironment = managedEnvironment({ ...process.env, PI_MEMORY_NO_SEARCH: undefined });
const searchEnvironment = managedEnvironment({ ...process.env, PI_MEMORY_NO_SEARCH: "0" });
assert.equal(defaultEnvironment.PI_MEMORY_NO_SEARCH, "1");
assert.equal(searchEnvironment.PI_MEMORY_NO_SEARCH, "0");
});
test("installed pi-memory integration is patched and idempotent", (context) => {
const sourcePath = join(agentDir, "npm", "node_modules", "pi-memory", "index.ts");
if (!existsSync(sourcePath)) {
context.skip("managed pi-memory is not installed; npm run setup installs it before CI tests");
return;
}
configurePiMemory({ quiet: true });
const source = readFileSync(sourcePath, "utf8");
assert.equal(isPiMemoryLiteConfigured(source), true);
assert.equal(patchPiMemorySource(source), source);
});
test("integration rejects an unreviewed pi-memory version", () => {
const temporaryAgentDir = mkdtempSync(join(tmpdir(), "pi-memory-version-"));
const packageDir = join(temporaryAgentDir, "npm", "node_modules", "pi-memory");
mkdirSync(packageDir, { recursive: true });
writeFileSync(join(packageDir, "package.json"), JSON.stringify({ version: "9.9.9" }), "utf8");
writeFileSync(join(packageDir, "index.ts"), "export default function () {}\n", "utf8");
assert.throws(
() => configurePiMemory({ agentDir: temporaryAgentDir, quiet: true }),
/Unsupported pi-memory version 9\.9\.9/,
);
});
-9
View File
@@ -1,8 +1,6 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { configurePiMemory } from "./configure-pi-memory.mjs";
import { configurePiAiVision } from "./configure-pi-ai-vision.mjs";
import { agentDir, managedEnvironment, rootDir } from "./profile.mjs";
const settings = JSON.parse(
@@ -48,10 +46,3 @@ for (const source of packages) {
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
configurePiMemory({ agentDir });
try {
configurePiAiVision({ quiet: false });
} catch (error) {
console.warn(`[vision] pi-ai passthrough patch skipped: ${error.message}`);
}
+1 -4
View File
@@ -44,11 +44,9 @@ const managedDirectories = [
join(resourcesDir, "themes"),
];
const mcpDefaultsFile = platform() === "darwin" ? "mcp.macos.default.json" : "mcp.default.json";
const seedFiles = [
[join(configDir, "settings.default.json"), join(agentDir, "settings.json")],
[join(configDir, mcpDefaultsFile), join(agentDir, "mcp.json")],
[join(configDir, "mcp.default.json"), join(agentDir, "mcp.json")],
[join(configDir, "models.example.json"), join(agentDir, "models.json")],
[join(configDir, "subagents.default.json"), join(agentDir, "extensions", "subagent", "config.json")],
[join(configDir, "subagent-tool-description.default.md"), join(agentDir, "subagent-tool-description.md")],
@@ -126,7 +124,6 @@ export function managedEnvironment(baseEnv = process.env) {
USERPROFILE: homeDir,
PI_MEMORY_DIR: baseEnv.PI_MEMORY_DIR?.trim() || memoryDir,
PI_MEMORY_SNAPSHOT: baseEnv.PI_MEMORY_SNAPSHOT?.trim() || "stable",
PI_MEMORY_NO_SEARCH: baseEnv.PI_MEMORY_NO_SEARCH?.trim() || "1",
PI_MEMORY_QMD_UPDATE: baseEnv.PI_MEMORY_QMD_UPDATE?.trim() || "off",
PI_RETRY_STALL_TIMEOUT_MS: baseEnv.PI_RETRY_STALL_TIMEOUT_MS?.trim() || "180000",
npm_config_cache: baseEnv.npm_config_cache?.trim() || join(dataDir, "cache", "npm"),
+4 -76
View File
@@ -1,6 +1,5 @@
import { spawn, spawnSync } from "node:child_process";
import { platform } from "node:os";
import { isLocalPortListening } from "./storage-maintenance.mjs";
import { rootDir } from "./profile.mjs";
const DEV_PORT = 30141;
@@ -50,84 +49,13 @@ exit 0
if (result.status !== 0) process.exit(result.status ?? 1);
}
function readMacListenerPids() {
const result = spawnSync(
"lsof",
["-nP", "-t", `-iTCP:${DEV_PORT}`, "-sTCP:LISTEN"],
{ cwd: rootDir, encoding: "utf8" },
);
if (result.error) throw result.error;
const output = result.stdout ?? "";
if (result.status !== 0 && !output.trim()) return [];
return [...new Set(
output
.trim()
.split(/\s+/)
.map((value) => Number.parseInt(value, 10))
.filter((pid) => Number.isInteger(pid) && pid > 0),
)];
}
function readMacProcessCommand(pid) {
const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
cwd: rootDir,
encoding: "utf8",
});
if (result.error) throw result.error;
return result.status === 0 ? (result.stdout ?? "").trim() : "";
}
function signalProcess(pid, signal) {
try {
process.kill(pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") throw error;
}
}
async function waitForPortClosed(timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (await isLocalPortListening(DEV_PORT)) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return true;
}
async function stopMacDevelopmentProcesses() {
const listenerPids = readMacListenerPids();
if (listenerPids.length === 0) return;
const projectPids = listenerPids.filter((pid) => readMacProcessCommand(pid).includes(rootDir));
const foreignPids = listenerPids.filter((pid) => !projectPids.includes(pid));
if (foreignPids.length > 0 || projectPids.length === 0) {
const pids = foreignPids.length > 0 ? foreignPids : listenerPids;
throw new Error(`Port ${DEV_PORT} is occupied by a process that does not belong to this project (PID ${pids.join(", ")}).`);
}
for (const pid of projectPids) signalProcess(pid, "SIGTERM");
if (await waitForPortClosed(3000)) return;
for (const pid of projectPids) signalProcess(pid, "SIGKILL");
if (!(await waitForPortClosed(1000))) {
throw new Error(`Port ${DEV_PORT} is still occupied by this project after termination (PID ${projectPids.join(", ")}).`);
}
}
try {
if (platform() === "win32") {
stopWindowsDevelopmentProcesses();
} else if (platform() === "darwin") {
await stopMacDevelopmentProcesses();
} else {
throw new Error("npm run restart currently supports Windows and macOS only. Stop the existing dev process and run npm run dev.");
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
if (platform() !== "win32") {
console.error("npm run restart currently supports Windows only. Stop the existing dev process and run npm run dev.");
process.exit(1);
}
stopWindowsDevelopmentProcesses();
const npmCliPath = process.env.npm_execpath;
if (!npmCliPath) {
console.error("Run this command through npm: npm run restart");
+1 -27
View File
@@ -1,13 +1,6 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
import { configurePiMemory } from "./configure-pi-memory.mjs";
import { configurePiAiVision } from "./configure-pi-ai-vision.mjs";
import { managedEnvironment, rootDir } from "./profile.mjs";
import {
isLocalPortListening,
maintainStorage,
printMaintenanceResult,
} from "./storage-maintenance.mjs";
const target = process.argv[2] ?? "dev";
const allowedTargets = new Set(["build", "dev", "dev:lan", "start", "start:lan"]);
@@ -22,28 +15,12 @@ if (!npmCliPath) {
process.exit(1);
}
const childEnvironment = managedEnvironment();
const memoryConfiguration = configurePiMemory({ quiet: true });
if (memoryConfiguration.status === "missing") {
console.warn("[memory] pi-memory is not installed; run npm run setup to enable managed memory");
}
try {
configurePiAiVision({ quiet: true });
} catch (error) {
console.warn(`[vision] pi-ai passthrough patch skipped: ${error.message}`);
}
if (await isLocalPortListening(30141)) {
console.log("[storage] skipped automatic maintenance because Pi Web is already running");
} else {
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
}
const child = spawn(
process.execPath,
[npmCliPath, "--prefix", join(rootDir, "pi-web"), "run", target],
{
cwd: rootDir,
env: childEnvironment,
env: managedEnvironment(),
stdio: "inherit",
},
);
@@ -57,9 +34,6 @@ child.on("error", (error) => {
process.exit(1);
});
child.on("exit", (code, signal) => {
if (!signal && code === 0 && target === "build") {
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
}
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 1);
});
-255
View File
@@ -1,255 +0,0 @@
import { spawnSync } from "node:child_process";
import {
existsSync,
lstatSync,
readdirSync,
rmSync,
} from "node:fs";
import { createConnection } from "node:net";
import { basename, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { dataDir as managedDataDir, rootDir as projectRootDir } from "./profile.mjs";
const MIB = 1024 * 1024;
const DAY_MS = 24 * 60 * 60 * 1000;
function numericSetting(env, name, fallback) {
const value = Number.parseFloat(env[name] ?? "");
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function enabledSetting(env, name, fallback = true) {
const value = env[name]?.trim().toLowerCase();
if (!value) return fallback;
return !["0", "false", "no", "off"].includes(value);
}
export function storagePolicy(env = process.env) {
return {
enabled: enabledSetting(env, "PI_STORAGE_AUTO_MAINTENANCE", true),
nextDevMaxBytes: numericSetting(env, "PI_STORAGE_NEXT_DEV_MAX_MB", 640) * MIB,
nextBuildCacheMaxBytes: numericSetting(env, "PI_STORAGE_NEXT_CACHE_MAX_MB", 256) * MIB,
npmCacheMaxBytes: numericSetting(env, "PI_STORAGE_NPM_CACHE_MAX_MB", 256) * MIB,
orphanGraceMs: numericSetting(env, "PI_STORAGE_ORPHAN_GRACE_DAYS", 7) * DAY_MS,
rewindGcLooseObjects: numericSetting(env, "PI_STORAGE_REWIND_GC_OBJECTS", 1000),
};
}
export function directoryStats(path) {
if (!existsSync(path)) return { bytes: 0, files: 0, newestMtimeMs: 0 };
let bytes = 0;
let files = 0;
let newestMtimeMs = 0;
const pending = [path];
while (pending.length > 0) {
const current = pending.pop();
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const child = join(current, entry.name);
let stats;
try {
stats = lstatSync(child);
} catch {
continue;
}
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
if (stats.isSymbolicLink()) continue;
if (stats.isDirectory()) pending.push(child);
else if (stats.isFile()) {
bytes += stats.size;
files += 1;
}
}
}
return { bytes, files, newestMtimeMs };
}
function collectSessionIds(path) {
const ids = new Set();
if (!existsSync(path)) return ids;
const pending = [path];
while (pending.length > 0) {
const current = pending.pop();
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const child = join(current, entry.name);
if (entry.isDirectory()) pending.push(child);
else if (entry.isFile() && entry.name.endsWith(".jsonl")) ids.add(basename(entry.name, ".jsonl"));
}
}
return ids;
}
function countLooseGitObjects(checkpointPath) {
const objectsPath = join(checkpointPath, ".git", "objects");
if (!existsSync(objectsPath)) return 0;
let count = 0;
for (const entry of readdirSync(objectsPath, { withFileTypes: true })) {
if (!entry.isDirectory() || !/^[0-9a-f]{2}$/i.test(entry.name)) continue;
count += readdirSync(join(objectsPath, entry.name), { withFileTypes: true })
.filter((child) => child.isFile()).length;
}
return count;
}
function defaultCheckpointCompactor(path) {
const result = spawnSync("git", ["-C", path, "gc", "--quiet"], {
encoding: "utf8",
windowsHide: true,
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(result.stderr.trim() || `git gc exited with ${result.status}`);
}
function removeDirectory(path, label, result) {
const before = directoryStats(path);
if (!existsSync(path)) return;
rmSync(path, { recursive: true, force: true });
result.reclaimedBytes += before.bytes;
result.actions.push({ type: "removed", label, path, bytes: before.bytes });
}
export function maintainStorage({
mode = "auto",
rootDir = projectRootDir,
dataDir = managedDataDir,
env = process.env,
nowMs = Date.now(),
compactCheckpoint = defaultCheckpointCompactor,
} = {}) {
const policy = storagePolicy(env);
const result = { mode, actions: [], warnings: [], reclaimedBytes: 0, status: {} };
if (mode === "auto" && !policy.enabled) return { ...result, disabled: true };
const cacheTargets = [
{
label: "Next.js development output",
path: join(rootDir, "pi-web", ".next", "dev"),
limit: policy.nextDevMaxBytes,
},
{
label: "Next.js build cache",
path: join(rootDir, "pi-web", ".next", "cache"),
limit: policy.nextBuildCacheMaxBytes,
},
{
label: "managed npm cache",
path: join(dataDir, "cache", "npm"),
limit: policy.npmCacheMaxBytes,
},
];
for (const target of cacheTargets) {
const stats = directoryStats(target.path);
result.status[target.label] = stats;
if (mode === "clean" || (mode === "auto" && stats.bytes > target.limit)) {
removeDirectory(target.path, target.label, result);
}
}
const sessionIds = collectSessionIds(join(dataDir, "agent", "sessions"));
const checkpointsPath = join(dataDir, "home", ".pi", "agent", "ayu", "checkpoints", "sessions");
if (existsSync(checkpointsPath)) {
for (const entry of readdirSync(checkpointsPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const checkpointPath = join(checkpointsPath, entry.name);
const stats = directoryStats(checkpointPath);
const orphan = !sessionIds.has(entry.name);
const ageMs = Math.max(0, nowMs - stats.newestMtimeMs);
if (orphan && (mode === "clean" || ageMs >= policy.orphanGraceMs)) {
removeDirectory(checkpointPath, `orphan Rewind checkpoint ${entry.name}`, result);
continue;
}
if (mode !== "status") {
const looseObjects = countLooseGitObjects(checkpointPath);
if (looseObjects >= policy.rewindGcLooseObjects) {
const before = directoryStats(checkpointPath).bytes;
try {
compactCheckpoint(checkpointPath);
const after = directoryStats(checkpointPath).bytes;
result.reclaimedBytes += Math.max(0, before - after);
result.actions.push({
type: "compacted",
label: `Rewind checkpoint ${entry.name}`,
path: checkpointPath,
bytes: Math.max(0, before - after),
});
} catch (error) {
result.warnings.push(`Unable to compact ${checkpointPath}: ${error.message}`);
}
}
}
}
}
return result;
}
export function formatBytes(bytes) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
if (bytes >= MIB) return `${(bytes / MIB).toFixed(1)} MiB`;
return `${(bytes / 1024).toFixed(1)} KiB`;
}
export function printMaintenanceResult(result) {
if (result.disabled) {
console.log("[storage] automatic maintenance is disabled");
return;
}
if (result.mode === "status") {
for (const [label, stats] of Object.entries(result.status)) {
console.log(`[storage] ${label}: ${formatBytes(stats.bytes)} (${stats.files} files)`);
}
}
for (const action of result.actions) {
const verb = action.type === "compacted" ? "compacted" : "removed";
console.log(`[storage] ${verb} ${action.label}; reclaimed ${formatBytes(action.bytes)}`);
}
for (const warning of result.warnings) console.warn(`[storage] ${warning}`);
if (result.actions.length > 0) console.log(`[storage] total reclaimed: ${formatBytes(result.reclaimedBytes)}`);
else if (result.mode !== "auto") console.log("[storage] nothing to clean");
}
export function isLocalPortListening(port, host = "127.0.0.1", timeoutMs = 250) {
return new Promise((resolvePromise) => {
const socket = createConnection({ port, host });
const finish = (listening) => {
socket.destroy();
resolvePromise(listening);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
const entryPath = process.argv[1] ? resolve(process.argv[1]) : "";
if (entryPath === resolve(fileURLToPath(import.meta.url))) {
const mode = process.argv[2] ?? "status";
if (!new Set(["status", "auto", "clean"]).has(mode)) {
console.error("Usage: node scripts/storage-maintenance.mjs [status|auto|clean]");
process.exit(2);
}
if (mode !== "status" && await isLocalPortListening(30141)) {
console.error("[storage] Pi Web is running on port 30141. Stop it first or use npm run restart.");
process.exit(1);
}
printMaintenanceResult(maintainStorage({ mode }));
}
-92
View File
@@ -1,92 +0,0 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { maintainStorage } from "./storage-maintenance.mjs";
function createFile(path, bytes = 2048) {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, Buffer.alloc(bytes));
}
function createCheckpoint(root, id, modifiedAt) {
const checkpoint = join(root, "home", ".pi", "agent", "ayu", "checkpoints", "sessions", id);
const objectDir = join(checkpoint, ".git", "objects", "ab");
mkdirSync(objectDir, { recursive: true });
createFile(join(objectDir, "object"));
createFile(join(checkpoint, "manifest.json"), 64);
for (const path of [join(objectDir, "object"), objectDir, join(checkpoint, ".git", "objects"), join(checkpoint, ".git"), join(checkpoint, "manifest.json"), checkpoint]) {
utimesSync(path, modifiedAt, modifiedAt);
}
return checkpoint;
}
test("automatic maintenance bounds caches and removes only expired orphan checkpoints", (t) => {
const root = mkdtempSync(join(tmpdir(), "pi-storage-maintenance-"));
const dataDir = join(root, "data");
const now = new Date("2026-07-31T00:00:00Z");
const old = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000);
const recent = new Date(now.getTime() - 24 * 60 * 60 * 1000);
t.after(() => rmSync(root, { recursive: true, force: true }));
const nextDev = join(root, "pi-web", ".next", "dev");
const nextCache = join(root, "pi-web", ".next", "cache");
const npmCache = join(dataDir, "cache", "npm");
createFile(join(nextDev, "large.bin"));
createFile(join(nextCache, "large.bin"));
createFile(join(npmCache, "large.bin"));
const sessions = join(dataDir, "agent", "sessions");
createFile(join(sessions, "live-session.jsonl"), 16);
const liveCheckpoint = createCheckpoint(dataDir, "live-session", old);
const expiredOrphan = createCheckpoint(dataDir, "expired-orphan", old);
const recentOrphan = createCheckpoint(dataDir, "recent-orphan", recent);
const compacted = [];
const result = maintainStorage({
mode: "auto",
rootDir: root,
dataDir,
nowMs: now.getTime(),
env: {
PI_STORAGE_NEXT_DEV_MAX_MB: "0.001",
PI_STORAGE_NEXT_CACHE_MAX_MB: "0.001",
PI_STORAGE_NPM_CACHE_MAX_MB: "0.001",
PI_STORAGE_ORPHAN_GRACE_DAYS: "7",
PI_STORAGE_REWIND_GC_OBJECTS: "1",
},
compactCheckpoint(path) {
compacted.push(path);
},
});
assert.equal(existsSync(nextDev), false);
assert.equal(existsSync(nextCache), false);
assert.equal(existsSync(npmCache), false);
assert.equal(existsSync(expiredOrphan), false);
assert.equal(existsSync(recentOrphan), true);
assert.equal(existsSync(liveCheckpoint), true);
assert.deepEqual(new Set(compacted), new Set([recentOrphan, liveCheckpoint]));
assert.ok(result.reclaimedBytes > 0);
});
test("clean mode removes every orphan while preserving live checkpoints", (t) => {
const root = mkdtempSync(join(tmpdir(), "pi-storage-clean-"));
const dataDir = join(root, "data");
t.after(() => rmSync(root, { recursive: true, force: true }));
createFile(join(dataDir, "agent", "sessions", "live-session.jsonl"), 16);
const liveCheckpoint = createCheckpoint(dataDir, "live-session", new Date());
const orphanCheckpoint = createCheckpoint(dataDir, "orphan-session", new Date());
maintainStorage({
mode: "clean",
rootDir: root,
dataDir,
env: { PI_STORAGE_REWIND_GC_OBJECTS: "999999" },
});
assert.equal(existsSync(liveCheckpoint), true);
assert.equal(existsSync(orphanCheckpoint), false);
});