mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 03:29:33 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a196c3a6b1 | ||
|
|
2908bcb6d0 | ||
|
|
dcce2468a0 | ||
|
|
7748c98af6 | ||
|
|
75b8c83ea3 | ||
|
|
bd4eb05a2a | ||
|
|
f531e8e2ef | ||
|
|
92e8184136 | ||
|
|
ac0f0cdc0c | ||
|
|
ccb038988e | ||
|
|
e79485bf4c | ||
|
|
dd47c3628d | ||
|
|
690020f823 | ||
|
|
5378d48095 | ||
|
|
a70798e874 | ||
|
|
551c59413e | ||
|
|
3b48681852 | ||
|
|
ec17e3c8f7 | ||
|
|
f3409f0074 | ||
|
|
5c9c6631f5 |
@@ -0,0 +1,22 @@
|
||||
---
|
||||
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 这类纯文本模型)看不到图片,完全依赖你的描述,完整性优先。
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copy this file to .env and fill only the services you use.
|
||||
# .env is ignored by Git and loaded automatically by the managed launcher.
|
||||
|
||||
# Optional repository-local SearXNG extension. Both values are required when enabled.
|
||||
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=
|
||||
|
||||
# Optional built-in model-provider credentials. The Web UI can also configure providers.
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
* text=auto
|
||||
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.jsonl text eol=lf
|
||||
*.md text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.png binary
|
||||
*.wasm binary
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: CI
|
||||
|
||||
"on":
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
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
|
||||
|
||||
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
|
||||
+13
-1
@@ -1,4 +1,16 @@
|
||||
/data/
|
||||
/data/*
|
||||
# Local tools are code (shareable) — supervisor / public-access scripts for
|
||||
# the managed Web service. Everything else under data/ stays untracked.
|
||||
!/data/local-tools/
|
||||
|
||||
# Local configuration and credentials. Keep the shareable template only.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.key
|
||||
|
||||
# Root-level dependency and build output, if added later.
|
||||
/node_modules/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Contributing / 贡献指南
|
||||
|
||||
Contributions are welcome. The project is Windows-first and keeps Pi, Pi Web, managed runtime data, and version-controlled resources as separate layers.
|
||||
|
||||
欢迎提交改进。本项目以 Windows 为首要平台,并明确区分 Pi 源码、Pi Web 源码、运行时数据与可版本化资源。
|
||||
|
||||
## Development setup / 开发环境
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
npm run setup
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Before submitting a change, run:
|
||||
|
||||
```powershell
|
||||
npm run check
|
||||
npm run check:profile
|
||||
npm run typecheck
|
||||
npm run test:managed
|
||||
```
|
||||
|
||||
提交前请运行以上检查。它们不会发起付费模型请求。
|
||||
|
||||
## Repository rules / 仓库约定
|
||||
|
||||
- Never commit `.env`, `data/`, credentials, sessions, memory, browser output, or local model/provider configuration.
|
||||
- 不要提交 `.env`、`data/`、凭据、会话、记忆、浏览器输出或本地模型配置。
|
||||
- Put shareable defaults under `config/` and shareable skills, extensions, prompts, or themes under `resources/`.
|
||||
- 可共享缺省配置放在 `config/`,可共享 Skill、扩展、提示词和主题放在 `resources/`。
|
||||
- Keep runtime package versions pinned in `config/settings.default.json` and update the plugin table and `THIRD_PARTY_NOTICES.md` together.
|
||||
- 更新运行时插件时必须固定版本,并同步 README 与第三方声明。
|
||||
- Preserve upstream licenses and record Pi/Pi Web baseline commits when synchronizing source.
|
||||
- 合并上游源码时保留许可证,并更新基线提交信息。
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Pi Agent Integrated contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,215 +1,504 @@
|
||||
# Pi Agent Integrated
|
||||
|
||||
[中文](#中文) · [English](#english)
|
||||
[中文](#中文) · [English](#english) · [🌐 项目介绍页](https://pi.llm-local.cloud)
|
||||
|
||||
An integrated, repository-local distribution of [Pi](https://github.com/earendil-works/pi) and [Pi Web](https://github.com/agegr/pi-web).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
Pi Agent Integrated 将 Pi 后端运行时与 Pi Web 浏览器前端整合为一个可独立克隆、配置和运行的项目。项目采用前后端分离结构,但通过根目录脚本统一安装、构建和启动。
|
||||
Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆、配置和启动的项目。它保留前后端源码边界,通过根目录命令完成依赖安装、Pi 构建、插件安装、Profile 初始化和 Web 启动。
|
||||
|
||||
运行时状态、会话、认证、Skill、扩展、提示词和主题均可保存在项目目录内,不依赖用户全局的 `~/.pi/agent` 或 `~/.agents`,也不会自动加载所打开工作区中的 `.pi` / `.agents` 资源。
|
||||
### 与两个源项目有什么不同
|
||||
|
||||
### 当前状态
|
||||
| 能力 | Pi | Pi Web | Pi Agent Integrated |
|
||||
| --- | --- | --- | --- |
|
||||
| 主要定位 | CLI/TUI Agent 运行时 | Pi 的浏览器 UI | 带 Web UI 的完整本地 Agent 应用 |
|
||||
| 安装方式 | 安装 CLI 或从源码构建 | 单独连接 Pi 包 | 根目录一次 `npm run setup` |
|
||||
| 运行数据 | 默认使用用户主目录 | 跟随 Pi Profile | 会话、认证、记忆、插件、缓存全部进入项目 `data/` |
|
||||
| 默认工作目录 | 当前终端目录 | 在用户主目录创建日期目录 | `data/workspaces/default/` |
|
||||
| 扩展生态 | 支持 Skill、扩展和包 | 提供管理界面 | 固定版本插件、MCP、Skill、提示词和主题形成项目闭环 |
|
||||
| 网络搜索 | 无项目专属搜索 | 无项目专属搜索 | 可选 SearXNG `web_search` 扩展 |
|
||||
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Windows 默认使用 Edge;macOS 默认不启用,按需配置 |
|
||||
| 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 |
|
||||
| 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 |
|
||||
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart |
|
||||
|
||||
- Pi 基线:`0.83.0`,上游提交 `bb226f9`
|
||||
- Pi Web 基线:`0.8.4`,上游提交 `c9b47e4`
|
||||
- 已在 Windows 10、Node.js 22 环境验证
|
||||
- 其他操作系统理论上可通过 Node.js 脚本运行,但尚未完成实机验证
|
||||
- 当前为命令行启动的 Web 应用,不包含桌面客户端或安装器
|
||||
|
||||
### 项目结构
|
||||
|
||||
```text
|
||||
pi/ Pi 后端、Agent 运行时及 CLI/TUI 源码
|
||||
pi-web/ Web 前端与 HTTP/SSE 服务
|
||||
config/ 可共享的默认配置
|
||||
resources/skills/ 纳入版本控制的应用 Skill
|
||||
resources/extensions/ 纳入版本控制的 Pi 扩展
|
||||
resources/prompts/ 纳入版本控制的提示词模板
|
||||
resources/themes/ 纳入版本控制的主题
|
||||
data/agent/ 会话、认证、模型配置、运行时包与工具
|
||||
data/skills-home/ Skill CLI 使用的隔离目录
|
||||
data/state/ Skill 更新锁及其他可变状态
|
||||
scripts/ 安装、启动、迁移与验收脚本
|
||||
```
|
||||
|
||||
`data/` 包含设备和用户相关的运行数据,因此不会提交到 Git。需要分享和长期维护的自定义内容应放入 `config/` 或 `resources/`。
|
||||
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 或 macOS 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 22.19.0 或更高版本
|
||||
- npm
|
||||
- Windows 10/11 或 macOS(Intel/Apple Silicon)
|
||||
- Node.js `22.19.0` 或更高版本
|
||||
- npm 与 Git
|
||||
- 首次安装时可访问 npm registry
|
||||
- Microsoft Edge(仅 Windows 默认 Playwright 浏览器工具需要;macOS 默认不启用 Playwright,不会自动下载浏览器)
|
||||
- 至少一个由用户自行配置的模型供应商、订阅登录或兼容 API
|
||||
|
||||
### 快速开始
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/luckyyzh/pi-agent-new.git
|
||||
cd pi-agent-new
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
npm run setup
|
||||
npm run dev
|
||||
```
|
||||
|
||||
浏览器打开 <http://127.0.0.1:30141>。
|
||||
macOS:
|
||||
|
||||
`npm run setup` 会安装两个子项目的依赖、构建本地 Pi 包,将 Pi Agent、AI、Coding Agent 和 TUI 包接入 Pi Web,并检查版本与构建产物。修改 Pi 源码或任一 lockfile 后应重新执行该命令。
|
||||
```bash
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
npm run setup
|
||||
npm run dev
|
||||
```
|
||||
|
||||
安装脚本默认忽略子进程中的 `HTTP_PROXY` 和 `HTTPS_PROXY`,避免失效的本地代理阻塞 npm。如果当前网络必须使用代理,请先设置 `PI_SETUP_USE_PROXY=1`。
|
||||
打开 <http://127.0.0.1:30141>。
|
||||
|
||||
### 项目内 Profile
|
||||
`npm run setup` 会:
|
||||
|
||||
只初始化或修复项目内的空白 Profile,不安装依赖:
|
||||
1. 初始化项目内 `data/` Profile;
|
||||
2. 安装并构建本地 Pi 源码;
|
||||
3. 安装 Pi Web 并连接本地 Pi 包;
|
||||
4. 安装、固定并加载检查默认插件;
|
||||
5. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置;
|
||||
6. 检查前后端版本和构建产物。
|
||||
|
||||
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,Windows PowerShell 设置 `$env:PI_SETUP_USE_PROXY = "1"`,macOS/Linux 使用 `PI_SETUP_USE_PROXY=1 npm run setup`。
|
||||
|
||||
### 首次模型配置
|
||||
|
||||
仓库不会附带作者的模型地址、API Key、OAuth Token 或默认模型。应用可以在空模型配置下启动,但发送消息前必须完成以下任一方式:
|
||||
|
||||
- 在 Web UI 左下角进入“模型”,为内置供应商登录或填写 API Key;
|
||||
- 在同一界面添加 Ollama、LM Studio、vLLM 或其他兼容 API;
|
||||
- 编辑运行时文件 `data/agent/models.json`;
|
||||
- 使用供应商支持的环境变量,例如 `ANTHROPIC_API_KEY`、`OPENAI_API_KEY` 或 `GEMINI_API_KEY`。
|
||||
|
||||
所有设备相关模型配置都位于被 Git 忽略的 `data/` 或 `.env` 中。
|
||||
|
||||
### 可选环境变量
|
||||
|
||||
需要可选服务时,复制模板:
|
||||
|
||||
macOS/Linux:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
根启动器会自动读取 `.env`,已有系统环境变量优先。常用变量如下:
|
||||
|
||||
| 变量 | 是否必需 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `SEARXNG_URL` | 使用 SearXNG 时必需 | 用户自己的 JSON 搜索代理端点 |
|
||||
| `SEARXNG_TOKEN` | 使用 SearXNG 时必需 | 作为 `X-Search-Token` 请求头发送 |
|
||||
| `CONTEXT7_API_KEY` | 可选 | 提高 Context7 文档查询限额 |
|
||||
| 模型供应商 API Key | 按供应商 | 模型认证;也可通过 Web UI 配置 |
|
||||
| `PI_AGENT_DATA_DIR` | 可选 | 将可变数据移动到其他目录 |
|
||||
|
||||
不要提交 `.env`、`data/`、真实密钥或包含密钥的截图。
|
||||
|
||||
### 项目结构和闭环边界
|
||||
|
||||
```text
|
||||
pi/ Pi 后端、Agent、CLI/TUI 源码
|
||||
pi-web/ Next.js Web 前端与 HTTP/SSE 服务
|
||||
config/ 可提交的缺省设置、MCP 和子代理策略
|
||||
resources/skills/ 应用级 Skill
|
||||
resources/extensions/ 应用级 Pi 扩展
|
||||
resources/prompts/ 提示词模板
|
||||
resources/themes/ 主题
|
||||
scripts/ 安装、启动、迁移与验证脚本
|
||||
data/agent/ 会话、认证、模型、插件、记忆与工具状态
|
||||
data/home/ Pi 子进程主目录与 Rewind 检查点
|
||||
data/cache/ 项目内 npm 缓存
|
||||
data/workspaces/default/ 默认工作目录
|
||||
```
|
||||
|
||||
`data/` 不进入 Git。应随仓库分发的内容放在 `config/` 或 `resources/`;个人会话、记忆、凭据、插件安装结果和模型配置留在 `data/`。打开其他代码目录时,受管运行时不会自动加载其中的 `.pi` / `.agents` 资源,从而避免和其他 Agent 项目耦合。
|
||||
|
||||
### 内置插件与工具
|
||||
|
||||
版本固定在 `config/` 下的平台默认配置文件中;Windows 使用 `mcp.default.json`,macOS 使用不含 Playwright 的 `mcp.macos.default.json`。
|
||||
|
||||
| 插件/工具 | 功能 | 自动行为与用法 |
|
||||
| --- | --- | --- |
|
||||
| `pi-mcp-adapter@2.15.0` | 用一个紧凑代理工具接入 MCP | 模型使用 `mcp` 搜索并调用 MCP;`/mcp` 查看状态。外部宿主配置发现默认关闭 |
|
||||
| `@playwright/mcp@0.0.78` | 真实网页导航、点击、表单、快照和截图 | Windows 通过 MCP 按需启动并使用系统 Edge;macOS 默认不安装,需手动通过 MCP 配置启用 |
|
||||
| `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-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 及其模型不随本仓库分发。
|
||||
|
||||
### Profile、迁移和自定义扩展
|
||||
|
||||
只创建缺失的项目 Profile 文件:
|
||||
|
||||
```powershell
|
||||
npm run profile:init
|
||||
```
|
||||
|
||||
现有 Pi 数据不会自动导入。需要复制当前用户的 Pi Profile 和全局 Skill 时,可显式运行:
|
||||
显式复制已有 Pi Profile 和全局 Skill:
|
||||
|
||||
```powershell
|
||||
npm run profile:migrate
|
||||
```
|
||||
|
||||
迁移只复制、不删除源数据,并默认保留目标目录中已存在的文件。其他用法:
|
||||
|
||||
```powershell
|
||||
npm run profile:migrate -- --dry-run
|
||||
npm run profile:migrate -- --force
|
||||
npm run profile:migrate -- --from D:\old-pi\agent --skills-from D:\old-skills
|
||||
```
|
||||
|
||||
迁移程序会移除指向项目外部的绝对资源路径,维持运行环境闭环。
|
||||
迁移只复制,不删除源数据,并清理指向仓库外部的绝对资源路径。后续共享扩展放入 `resources/extensions/` 并登记到其 `package.json`;共享 Skill、提示词和主题分别放入对应 `resources/` 目录。
|
||||
|
||||
### 常用命令
|
||||
|
||||
```powershell
|
||||
npm run dev # 仅本机访问的开发服务
|
||||
npm run dev:lan # 局域网可访问的开发服务
|
||||
npm run start # 启动已构建服务
|
||||
npm run start:lan # 在局域网启动已构建服务
|
||||
npm run check # 检查整合结构与构建产物
|
||||
npm run typecheck # TypeScript 类型检查
|
||||
npm run lint # 前端代码检查
|
||||
npm run test:managed # 项目内 Profile 与资源隔离测试
|
||||
npm run smoke # 运行中的服务冒烟测试
|
||||
npm run smoke:isolation # 外部 Skill/扩展隔离测试
|
||||
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 dev:lan # 局域网监听;仅在可信网络使用
|
||||
npm run build # 构建 Pi Web 生产产物
|
||||
npm run start # 启动已构建的本机生产服务
|
||||
npm run check # 验证前后端整合结构
|
||||
npm run check:profile # 安装/加载并检查受管插件
|
||||
npm run smoke:fresh-profile # 在临时目录验证全新 Profile 安装后自动清理
|
||||
npm run typecheck # Pi Web TypeScript 检查
|
||||
npm run test:managed # Profile、隔离、搜索契约等测试
|
||||
npm run smoke # 对运行中的 Web 服务做冒烟测试
|
||||
npm run smoke:isolation # 验证外部 Skill/扩展不会泄漏进来
|
||||
npm run smoke:search -- "关键词" # 使用真实 SearXNG;需要配置
|
||||
```
|
||||
|
||||
`smoke` 和 `smoke:isolation` 需要开发服务已启动。测试不会发起付费模型请求。
|
||||
除 `smoke:search` 会访问用户配置的 SearXNG 外,以上验证不会发起付费模型请求。
|
||||
|
||||
### 上游与许可证
|
||||
### 自动磁盘维护
|
||||
|
||||
本项目基于以下项目维护整合版本:
|
||||
每次 `dev`、`restart`、`build` 或 `start` 在启动 Pi Web 前都会检查项目内的可变数据。维护只在 30141 端口没有运行中的 Pi Web 时执行,并采用以下无损策略:
|
||||
|
||||
- [earendil-works/pi](https://github.com/earendil-works/pi),基线提交 `bb226f9c1f38d3c029156a690e97bbfc602336b9`
|
||||
- [agegr/pi-web](https://github.com/agegr/pi-web),基线提交 `c9b47e4543b11ce61e5c49c6bf02cea80aa975f6`
|
||||
- `.next/dev`、`.next/cache` 或项目 npm 缓存超过默认上限后才删除;下一次使用时会自动重建;
|
||||
- Rewind 检查点包含大量松散 Git 对象时自动执行压缩,但保留所有有效检查点;
|
||||
- 已找不到对应会话的孤儿 Rewind 检查点保留 7 天后自动删除;
|
||||
- 会话、记忆、凭据、模型配置、已安装插件和仍有关联的检查点不会被自动删除。
|
||||
- 手动清理:`node scripts/cleanup-cache.mjs`(默认预览,加 `--apply` 执行)——清 npm/opengrep 缓存、按天数移走过期会话、把旧会话图片替换为占位符瘦身;删除先进回收目录。
|
||||
|
||||
对应许可证保留在 `pi/LICENSE` 与 `pi-web/LICENSE`。后续更新由本仓库自行评估、合并和发布。
|
||||
```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`
|
||||
- Pi Web 基线:`0.8.4`,提交 `c9b47e4543b11ce61e5c49c6bf02cea80aa975f6`
|
||||
|
||||
本整合版不自动跟随上游。更新时应分别评估两个源码树,重新执行 `npm run setup`、类型检查和受管测试,再更新这里的基线信息。根目录整合代码采用 MIT;两个源码树和运行时插件保留各自许可证,详见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。安全边界见 [SECURITY.md](SECURITY.md),贡献约定见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
Pi Agent Integrated combines the Pi backend runtime with the Pi Web browser frontend in a single project that can be cloned, configured, and run independently. The backend and frontend remain separate source trees, while root-level scripts provide one installation, build, and launch workflow.
|
||||
Pi Agent Integrated combines the Pi backend and Pi Web frontend into one independently cloneable and runnable repository. It preserves separate source boundaries while root commands handle dependency installation, Pi builds, managed-profile creation, plugin installation, and Web startup.
|
||||
|
||||
Runtime state, sessions, authentication, skills, extensions, prompts, and themes can all live inside the repository. The managed launcher does not depend on the user's global `~/.pi/agent` or `~/.agents` directories and does not automatically load `.pi` or `.agents` resources from an opened workspace.
|
||||
### How it differs from the two upstream projects
|
||||
|
||||
### Status
|
||||
| Capability | Pi | Pi Web | Pi Agent Integrated |
|
||||
| --- | --- | --- | --- |
|
||||
| Primary role | CLI/TUI agent runtime | Browser UI for Pi | Complete local agent application with Web UI |
|
||||
| Installation | Install CLI or build source | Connect Pi packages separately | One root `npm run setup` |
|
||||
| Mutable state | User home by default | Follows the Pi profile | Sessions, auth, memory, plugins, and caches stay under repository `data/` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
- Pi baseline: `0.83.0`, upstream commit `bb226f9`
|
||||
- Pi Web baseline: `0.8.4`, upstream commit `c9b47e4`
|
||||
- Validated on Windows 10 with Node.js 22
|
||||
- The Node.js scripts are intended to be portable, but other operating systems have not been tested yet
|
||||
- This is currently a command-line-launched web application, without a desktop client or installer
|
||||
|
||||
### Repository layout
|
||||
|
||||
```text
|
||||
pi/ Pi backend, agent runtime, and CLI/TUI source
|
||||
pi-web/ Web frontend and HTTP/SSE service
|
||||
config/ Shareable default configuration
|
||||
resources/skills/ Version-controlled application skills
|
||||
resources/extensions/ Version-controlled Pi extensions
|
||||
resources/prompts/ Version-controlled prompt templates
|
||||
resources/themes/ Version-controlled themes
|
||||
data/agent/ Sessions, auth, model config, packages, and tools
|
||||
data/skills-home/ Isolated home used by the skills CLI
|
||||
data/state/ Skill update locks and other mutable state
|
||||
scripts/ Setup, launch, migration, and verification scripts
|
||||
```
|
||||
|
||||
`data/` contains machine- and user-specific runtime data and is excluded from Git. Put custom resources that should be shared and maintained under `config/` or `resources/`.
|
||||
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.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 22.19.0 or newer
|
||||
- npm
|
||||
- npm registry access during the first setup
|
||||
- Windows 10/11 or macOS (Intel/Apple Silicon)
|
||||
- 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
|
||||
- At least one user-configured model provider, subscription login, or compatible API
|
||||
|
||||
### Quick start
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/luckyyzh/pi-agent-new.git
|
||||
cd pi-agent-new
|
||||
Windows PowerShell or macOS Terminal:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
npm run setup
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:30141>.
|
||||
|
||||
`npm run setup` installs dependencies for both source trees, builds the local Pi packages, connects the Pi Agent, AI, Coding Agent, and TUI packages to Pi Web, and verifies their versions and build output. Run it again after changing Pi source or either lockfile.
|
||||
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.
|
||||
|
||||
The setup script ignores `HTTP_PROXY` and `HTTPS_PROXY` for child processes by default so that a stale local proxy cannot stall npm. Set `PI_SETUP_USE_PROXY=1` first when npm registry access requires your proxy.
|
||||
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.
|
||||
|
||||
### Repository-local profile
|
||||
### First model configuration
|
||||
|
||||
Initialize or repair an empty managed profile without installing dependencies:
|
||||
The repository contains no author model endpoint, API key, OAuth token, or default model. The UI starts with an empty model configuration, but one of the following is required before sending a message:
|
||||
|
||||
- open Models in the lower-left Web UI and authenticate or enter an API key for a built-in provider;
|
||||
- add Ollama, LM Studio, vLLM, or another compatible API in the same UI;
|
||||
- edit the runtime file `data/agent/models.json`;
|
||||
- set a supported variable such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY`.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
The managed launcher automatically reads `.env`, while existing process variables take precedence.
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `SEARXNG_URL` | When using SearXNG | The user's JSON search proxy endpoint |
|
||||
| `SEARXNG_TOKEN` | When using SearXNG | Sent as the `X-Search-Token` request header |
|
||||
| `CONTEXT7_API_KEY` | Optional | Higher Context7 documentation quota |
|
||||
| Provider API keys | Provider-specific | Model authentication; the Web UI is another option |
|
||||
| `PI_AGENT_DATA_DIR` | Optional | Relocate mutable runtime data |
|
||||
|
||||
Never commit `.env`, `data/`, real credentials, or screenshots containing credentials.
|
||||
|
||||
### Repository-local ecosystem
|
||||
|
||||
```text
|
||||
pi/ Pi backend, agent, and CLI/TUI source
|
||||
pi-web/ Next.js frontend and HTTP/SSE service
|
||||
config/ Versioned settings, MCP, and subagent policy
|
||||
resources/skills/ Application skills
|
||||
resources/extensions/ Application Pi extensions
|
||||
resources/prompts/ Prompt templates
|
||||
resources/themes/ Themes
|
||||
scripts/ Setup, launch, migration, and verification
|
||||
data/agent/ Sessions, auth, models, packages, memory, tools
|
||||
data/home/ Pi subprocess home and Rewind checkpoints
|
||||
data/cache/ Repository-local npm cache
|
||||
data/workspaces/default/ Default workspace
|
||||
```
|
||||
|
||||
`data/` is excluded from Git. Shareable defaults belong in `config/` or `resources/`; personal sessions, memory, credentials, installed packages, and model settings remain in `data/`. The managed runtime does not automatically load `.pi` or `.agents` resources from another opened 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.
|
||||
|
||||
| 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 |
|
||||
| `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-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.
|
||||
|
||||
### Profile, migration, and extension
|
||||
|
||||
```powershell
|
||||
npm run profile:init
|
||||
```
|
||||
|
||||
Existing Pi data is never imported automatically. To copy the current user's Pi profile and global skills explicitly, run:
|
||||
|
||||
```powershell
|
||||
npm run profile:migrate
|
||||
```
|
||||
|
||||
Migration copies data without deleting its source and preserves existing destination files by default. Other options are:
|
||||
|
||||
```powershell
|
||||
npm run profile:migrate -- --dry-run
|
||||
npm run profile:migrate -- --force
|
||||
npm run profile:migrate -- --from D:\old-pi\agent --skills-from D:\old-skills
|
||||
```
|
||||
|
||||
The migrator removes absolute resource paths that point outside the repository, keeping the managed environment self-contained.
|
||||
Migration copies without deleting source data and removes absolute resource paths pointing outside the repository. Put future shared extensions in `resources/extensions/` and register them in its `package.json`; use the corresponding `resources/` directories for shared skills, prompts, and themes.
|
||||
|
||||
### Commands
|
||||
|
||||
```powershell
|
||||
npm run dev # Local-only development server
|
||||
npm run dev:lan # LAN-accessible development server
|
||||
npm run start # Start a production build
|
||||
npm run start:lan # Start a production build on the LAN
|
||||
npm run check # Verify integration layout and build artifacts
|
||||
npm run typecheck # TypeScript type checking
|
||||
npm run lint # Frontend linting
|
||||
npm run test:managed # Managed-profile and resource-isolation tests
|
||||
npm run smoke # Smoke-test a running server
|
||||
npm run smoke:isolation # Verify external skills/extensions stay isolated
|
||||
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 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
|
||||
npm run check # Verify backend/frontend integration
|
||||
npm run check:profile # Install/load and verify managed plugins
|
||||
npm run smoke:fresh-profile # Verify a clean temporary profile, then remove it
|
||||
npm run typecheck # Pi Web TypeScript check
|
||||
npm run test:managed # Profile, isolation, and search-contract tests
|
||||
npm run smoke # Smoke-test a running Web service
|
||||
npm run smoke:isolation # Verify external skills/extensions remain isolated
|
||||
npm run smoke:search -- "query" # Real configured SearXNG request
|
||||
```
|
||||
|
||||
`smoke` and `smoke:isolation` require a running development server. They do not make a paid model request.
|
||||
Except for `smoke:search`, validation does not make paid model requests.
|
||||
|
||||
### Upstream projects and licenses
|
||||
### Automatic storage maintenance
|
||||
|
||||
This repository maintains an integrated distribution based on:
|
||||
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:
|
||||
|
||||
- [earendil-works/pi](https://github.com/earendil-works/pi), baseline commit `bb226f9c1f38d3c029156a690e97bbfc602336b9`
|
||||
- [agegr/pi-web](https://github.com/agegr/pi-web), baseline commit `c9b47e4543b11ce61e5c49c6bf02cea80aa975f6`
|
||||
- `.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.
|
||||
|
||||
The corresponding license files remain at `pi/LICENSE` and `pi-web/LICENSE`. Future upstream updates are evaluated, merged, and released from this repository.
|
||||
```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`
|
||||
- Pi Web baseline: `0.8.4`, commit `c9b47e4543b11ce61e5c49c6bf02cea80aa975f6`
|
||||
|
||||
This integrated distribution does not auto-follow upstream. Update each vendored source tree deliberately, rerun setup, type checking, and managed tests, then update the baseline records above. Root integration code is MIT-licensed; vendored source and runtime plugins retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), [SECURITY.md](SECURITY.md), and [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Security policy / 安全说明
|
||||
|
||||
Pi Agent Integrated is currently a local, single-user application. The default `npm run dev` and `npm run start` commands bind to `127.0.0.1`. LAN commands intentionally bind to all interfaces and should only be used on trusted networks.
|
||||
|
||||
Pi Agent Integrated 当前定位为本地单用户应用。默认启动命令仅监听 `127.0.0.1`;带 `:lan` 的命令会监听所有网卡,只应在可信局域网使用。
|
||||
|
||||
Do not expose this application directly to the public internet. Agent tools can read and modify files in their selected workspace, run commands, access configured services, and invoke MCP servers. Review provider, extension, and MCP configuration before sharing an instance.
|
||||
|
||||
不要将应用直接暴露到公网。Agent 工具能够在所选工作目录内读写文件、执行命令、访问已配置服务并调用 MCP;共享实例前应审核模型、扩展和 MCP 配置。
|
||||
|
||||
Secrets belong in `.env`, environment variables, or the ignored `data/` profile. Never place real credentials in `config/`, `resources/`, screenshots, issues, or logs intended for publication.
|
||||
|
||||
密钥应放在 `.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.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Third-party notices
|
||||
|
||||
Pi Agent Integrated contains vendored upstream source and installs optional runtime packages. The root [MIT license](LICENSE) applies to the integration-specific code and documentation; it does not replace the licenses of the components below.
|
||||
|
||||
## Vendored source
|
||||
|
||||
| Component | Version or baseline | License | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Pi | `0.83.0`, commit `bb226f9c1f38d3c029156a690e97bbfc602336b9` | MIT | <https://github.com/earendil-works/pi> |
|
||||
| Pi Web | `0.8.4`, commit `c9b47e4543b11ce61e5c49c6bf02cea80aa975f6` | MIT | <https://github.com/agegr/pi-web> |
|
||||
|
||||
Their complete license texts remain in `pi/LICENSE` and `pi-web/LICENSE`.
|
||||
|
||||
## Runtime packages enabled by the managed profile
|
||||
|
||||
These packages are fetched from npm during setup or first use and remain under the ignored `data/` runtime directory.
|
||||
|
||||
| Package | Pinned version | License | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `pi-mcp-adapter` | `2.15.0` | MIT | <https://github.com/nicobailon/pi-mcp-adapter> |
|
||||
| `pi-lens` | `3.8.73` | MIT | <https://github.com/apmantza/pi-lens> |
|
||||
| `pi-memory` | `0.4.0` | MIT | <https://github.com/jayzeng/pi-memory> |
|
||||
| `pi-subagents` | `0.37.2` | MIT | <https://github.com/nicobailon/pi-subagents> |
|
||||
| `pi-smart-fetch` | `0.3.17` | MIT | <https://github.com/Thinkscape/agent-smart-fetch> |
|
||||
| `@ayulab/pi-rewind` | `0.4.6` | GPL-3.0 | <https://github.com/ayu-exorcist/oh-my-pi> |
|
||||
| `@upstash/context7-pi` | `0.1.2` | MIT | <https://github.com/upstash/context7> |
|
||||
| `@narumitw/pi-retry` | `0.31.0` | MIT | <https://github.com/narumiruna/pi-extensions> |
|
||||
| `@playwright/mcp` | `0.0.78` | Apache-2.0 | <https://github.com/microsoft/playwright-mcp> |
|
||||
|
||||
Each package may include transitive dependencies under additional licenses. npm package metadata and installed package license files are authoritative for a particular installation.
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
# Managed profile defaults
|
||||
|
||||
`settings.default.json` and `models.example.json` seed a new local profile.
|
||||
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.
|
||||
`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.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"settings": {
|
||||
"hostConfigDiscovery": "off",
|
||||
"idleTimeout": 5
|
||||
},
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@playwright/mcp@0.0.78",
|
||||
"--headless",
|
||||
"--isolated",
|
||||
"--browser",
|
||||
"msedge",
|
||||
"--output-dir",
|
||||
"data/mcp/playwright/output"
|
||||
],
|
||||
"cwd": "${PI_AGENT_APP_ROOT}",
|
||||
"env": {
|
||||
"npm_config_cache": "${npm_config_cache}"
|
||||
},
|
||||
"lifecycle": "lazy",
|
||||
"idleTimeout": 5,
|
||||
"requestTimeoutMs": 120000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": {
|
||||
"hostConfigDiscovery": "off",
|
||||
"idleTimeout": 5
|
||||
},
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -1 +1,46 @@
|
||||
{}
|
||||
{
|
||||
"packages": [
|
||||
"npm:pi-mcp-adapter@2.15.0",
|
||||
"npm:pi-lens@3.8.73",
|
||||
"npm:pi-memory@0.4.0",
|
||||
"npm:pi-subagents@0.37.2",
|
||||
"npm:pi-smart-fetch@0.3.17",
|
||||
"npm:@ayulab/pi-rewind@0.4.6",
|
||||
"npm:@upstash/context7-pi@0.1.2",
|
||||
"npm:@narumitw/pi-retry@0.31.0"
|
||||
],
|
||||
"retry": {
|
||||
"enabled": true
|
||||
},
|
||||
"ayu": {
|
||||
"rewind": {
|
||||
"restoreOnTree": "ask"
|
||||
},
|
||||
"checkpoint": {
|
||||
"restoreOnResume": false,
|
||||
"restoreOnFork": false,
|
||||
"restoreOnClone": false,
|
||||
"exclude": [
|
||||
"data/**",
|
||||
"**/node_modules/**",
|
||||
"**/.next/**",
|
||||
"**/coverage/**"
|
||||
],
|
||||
"maxFileMB": 25
|
||||
}
|
||||
},
|
||||
"subagents": {
|
||||
"disableThinking": true,
|
||||
"agentOverrides": {
|
||||
"researcher": {
|
||||
"tools": [
|
||||
"read",
|
||||
"write",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"batch_web_fetch"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
AUTOMATIC DELEGATION POLICY:
|
||||
|
||||
- Decide automatically whether delegation materially improves the result. The user should not need to request a subagent or learn subagent commands.
|
||||
- Do not delegate simple factual questions, translations, short explanations, status checks, command lookups, obvious one-file edits, or tasks the parent can complete confidently with a few tool calls.
|
||||
- Delegate only when at least one condition applies: broad investigation across multiple modules; independent external research; substantial uncertainty that benefits from a planner or oracle; a significant implementation deserves fresh-context review; or two or more independent investigations can run concurrently.
|
||||
- Prefer one focused read-only subagent. Use at most two parallel children by default. Use more only when the user explicitly requests broad parallelism and the tasks are genuinely independent.
|
||||
- Keep the parent agent as the sole writer in a shared working directory. Use worker only for a clearly separable implementation with a concrete handoff, or use isolated worktrees when multiple writers are explicitly justified.
|
||||
- Do not run a ceremonial scout-planner-worker-reviewer pipeline for every task. Do not delegate the same question to multiple similar roles without a concrete reason.
|
||||
- Prefer foreground execution so the current request finishes coherently. Use background execution only for long, independent work or when the user explicitly asks for it.
|
||||
- Account for latency, token use, provider quotas, and context value before delegating. If the expected benefit is marginal, continue in the parent.
|
||||
- Keep orchestration unobtrusive. Return a consolidated answer and mention subagents only when their findings, disagreement, or status is useful to the user.
|
||||
|
||||
{{compactDescription}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"toolDescriptionMode": "custom",
|
||||
"artifactDir": "session",
|
||||
"fleetView": false,
|
||||
"asyncWidget": false,
|
||||
"asyncByDefault": false,
|
||||
"waitTool": {
|
||||
"enabled": true
|
||||
},
|
||||
"parallel": {
|
||||
"maxTasks": 4,
|
||||
"concurrency": 2
|
||||
},
|
||||
"globalConcurrencyLimit": 2,
|
||||
"maxSubagentDepth": 1,
|
||||
"maxSubagentSpawnsPerSession": 12,
|
||||
"turnBudget": {
|
||||
"maxTurns": 16,
|
||||
"graceTurns": 2
|
||||
},
|
||||
"toolBudget": {
|
||||
"soft": 30,
|
||||
"hard": 60
|
||||
},
|
||||
"proactiveSkillSubagents": false
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
[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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
[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
|
||||
}
|
||||
+17
-2
@@ -1,23 +1,38 @@
|
||||
{
|
||||
"name": "pi-agent-integrated",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.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"
|
||||
},
|
||||
"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",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"smoke:fresh-profile": "node scripts/smoke-fresh-profile.mjs",
|
||||
"smoke:isolation": "node scripts/smoke-isolation.mjs",
|
||||
"smoke:search": "node pi-web/scripts/searxng-smoke.mjs",
|
||||
"predev": "npm run check",
|
||||
"dev": "node scripts/run.mjs dev",
|
||||
"restart": "node scripts/restart.mjs",
|
||||
"dev:lan": "node scripts/run.mjs dev:lan",
|
||||
"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/migrate-profile.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs"
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ app/api/
|
||||
auth/logout/[provider]/route.ts POST OAuth logout
|
||||
auth/providers/route.ts GET OAuth provider list
|
||||
cwd/validate/route.ts POST validate/select a cwd
|
||||
default-cwd/route.ts POST create ~/pi-cwd-YYYYMMDD
|
||||
default-cwd/route.ts POST create managed data/workspaces/default (standalone: ~/pi-cwd-YYYYMMDD)
|
||||
files/[...path]/route.ts GET file contents for viewer
|
||||
home/route.ts GET user home directory
|
||||
models/route.ts GET { models, modelList, defaultModel }
|
||||
|
||||
@@ -2,14 +2,20 @@ import { NextResponse } from "next/server";
|
||||
import { mkdirSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
|
||||
// POST /api/default-cwd
|
||||
// Creates ~/pi-cwd-<YYYYMMDD> if it doesn't exist and returns the path.
|
||||
// Managed app: creates <dataDir>/workspaces/default.
|
||||
// Standalone pi-web: preserves the upstream ~/pi-cwd-<YYYYMMDD> behavior.
|
||||
export async function POST() {
|
||||
try {
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const dir = join(homedir(), `pi-cwd-${date}`);
|
||||
const dir = isManagedRuntime()
|
||||
? join(getManagedRuntimePaths().dataDir, "workspaces", "default")
|
||||
: join(
|
||||
homedir(),
|
||||
`pi-cwd-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
allowFileRoot(dir);
|
||||
return NextResponse.json({ cwd: dir });
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
getDocumentMime,
|
||||
getFileExt,
|
||||
getImageMime,
|
||||
isAudioPath,
|
||||
isDocumentPreviewPath,
|
||||
isImagePath,
|
||||
} from "@/lib/file-types";
|
||||
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
|
||||
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
|
||||
@@ -39,6 +42,7 @@ 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.
|
||||
@@ -122,6 +126,46 @@ 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[] }> }
|
||||
@@ -132,10 +176,57 @@ 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;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
+2464
-1375
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft
|
||||
import {
|
||||
MAX_ATTACHED_IMAGE_BYTES,
|
||||
MAX_ATTACHED_IMAGES,
|
||||
compressImageFile,
|
||||
isBase64ImageWithinLimits,
|
||||
} from "@/lib/image-attachments";
|
||||
import {
|
||||
@@ -390,20 +391,21 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
|
||||
pendingImageCountRef.current += imageFiles.length;
|
||||
try {
|
||||
const newImages = await Promise.all(
|
||||
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);
|
||||
})
|
||||
)
|
||||
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),
|
||||
};
|
||||
})
|
||||
);
|
||||
setAttachedImages((prev) => {
|
||||
const accepted = newImages.slice(0, Math.max(0, MAX_ATTACHED_IMAGES - prev.length));
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ 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;
|
||||
}
|
||||
@@ -207,6 +208,16 @@ 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 (
|
||||
@@ -783,7 +794,7 @@ function DocumentViewer({ filePath, cwd, sourceSessionId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
if (isImagePath(filePath)) {
|
||||
return <ImageViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
|
||||
}
|
||||
@@ -793,10 +804,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} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
|
||||
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} onEditorSaved={onEditorSaved} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
|
||||
}
|
||||
|
||||
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<FileData | null>(null);
|
||||
@@ -810,7 +821,13 @@ 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))
|
||||
@@ -969,6 +986,88 @@ 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;
|
||||
|
||||
@@ -1069,7 +1168,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setDisplayMode(mode)}
|
||||
onClick={() => exitEditingGuard(() => setDisplayMode(mode))}
|
||||
title={mode === "diff" ? t("i18n.compareHead") : undefined}
|
||||
aria-pressed={active}
|
||||
className="file-viewer-mode-button"
|
||||
@@ -1086,7 +1185,65 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
)}
|
||||
|
||||
<div className="file-viewer-actions">
|
||||
{effectiveDisplayMode === "source" && (
|
||||
{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 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1128,7 +1285,88 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
|
||||
{/* Content area */}
|
||||
<div ref={contentRef} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
|
||||
{effectiveDisplayMode === "diff" && hasGitDiff ? (
|
||||
{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 ? (
|
||||
<DiffView patch={gitDiff.patch!} />
|
||||
) : isHtml && effectiveDisplayMode === "preview" ? (
|
||||
<iframe
|
||||
@@ -1237,6 +1475,54 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+3491
-1783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -466,6 +466,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
|
||||
|
||||
try {
|
||||
const stateRes = await fetch(`/api/sessions/${encodeURIComponent(sid)}/state`);
|
||||
if (stateRes.status === 404) {
|
||||
if (sessionIdRef.current === sid) {
|
||||
setQueuedMessages({ steering: [], followUp: [] });
|
||||
}
|
||||
return { running: false };
|
||||
}
|
||||
if (!stateRes.ok) throw new Error(`HTTP ${stateRes.status}`);
|
||||
const agentState = await stateRes.json() as { running: boolean; state?: AgentStateResponse };
|
||||
if (sessionIdRef.current !== sid) return null;
|
||||
|
||||
@@ -103,3 +103,21 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { isAbsolute, relative, resolve, sep } from "path";
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from "path";
|
||||
import {
|
||||
SettingsManager,
|
||||
type ResourceDiagnostic,
|
||||
@@ -64,7 +64,11 @@ export function getManagedRuntimePaths(): ManagedRuntimePaths {
|
||||
|
||||
function canonicalPath(path: string): string {
|
||||
const resolved = resolve(path);
|
||||
if (!existsSync(resolved)) return resolved;
|
||||
if (!existsSync(resolved)) {
|
||||
const parent = dirname(resolved);
|
||||
if (parent === resolved) return resolved;
|
||||
return resolve(canonicalPath(parent), basename(resolved));
|
||||
}
|
||||
try {
|
||||
return realpathSync.native(resolved);
|
||||
} catch {
|
||||
|
||||
@@ -8,8 +8,11 @@ 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",
|
||||
@@ -42,6 +45,7 @@ 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}",
|
||||
@@ -236,6 +240,47 @@ 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",
|
||||
@@ -304,6 +349,16 @@ 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",
|
||||
@@ -356,6 +411,19 @@ 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",
|
||||
|
||||
@@ -8,8 +8,11 @@ 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": "切换到浅色模式",
|
||||
@@ -42,6 +45,7 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"session.output": "输出",
|
||||
"session.cacheRead": "缓存读取",
|
||||
"session.cacheWrite": "缓存写入",
|
||||
"session.cacheHitRate": "缓存命中率(主会话)",
|
||||
"session.cost": "费用",
|
||||
"session.context": "上下文",
|
||||
"session.copy": "复制{value}",
|
||||
@@ -236,6 +240,47 @@ 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": "已复制",
|
||||
@@ -304,6 +349,16 @@ 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 图表无效",
|
||||
@@ -356,6 +411,19 @@ 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": "扩展",
|
||||
|
||||
+100
-39
@@ -1,56 +1,117 @@
|
||||
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;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const extensionPath = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../resources/extensions/searxng-search.ts",
|
||||
);
|
||||
const extensionDir = dirname(extensionPath);
|
||||
|
||||
test("managed SearXNG extension loads and maps the complete search contract", async () => {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-searxng-extension-"));
|
||||
const agentDir = resolve(fixtureRoot, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalToken = process.env.SEARXNG_TOKEN;
|
||||
const originalUrl = process.env.SEARXNG_URL;
|
||||
let capturedRequest;
|
||||
|
||||
process.env.SEARXNG_TOKEN = "test-token";
|
||||
process.env.SEARXNG_URL = "https://search.example.test/search";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
capturedRequest = { input: String(input), init };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
answers: ["A current answer"],
|
||||
results: [
|
||||
{
|
||||
title: "Example result",
|
||||
url: "https://example.test/result",
|
||||
content: "Example snippet",
|
||||
engines: ["google", "github"],
|
||||
publishedDate: "2026-07-30",
|
||||
},
|
||||
],
|
||||
suggestions: ["refined query"],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: fixtureRoot,
|
||||
agentDir,
|
||||
additionalExtensionPaths: [extensionDir],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const loaded = loader.getExtensions();
|
||||
assert.deepEqual(loaded.errors, []);
|
||||
const extension = loaded.extensions.find((candidate) => resolve(candidate.path) === extensionPath);
|
||||
assert.ok(extension, "SearXNG extension should load through the managed extension directory manifest");
|
||||
|
||||
const registeredTool = extension.tools.get("web_search");
|
||||
assert.ok(registeredTool, "web_search should be registered");
|
||||
const tool = registeredTool.definition;
|
||||
|
||||
const result = await tool.execute(
|
||||
"test-call",
|
||||
{
|
||||
query: "pi coding agent",
|
||||
num_results: 5,
|
||||
engines: ["google", "github"],
|
||||
language: "en",
|
||||
page: 2,
|
||||
time_range: "week",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
assert.ok(capturedRequest, "web_search should make an HTTP request");
|
||||
const requestUrl = new URL(capturedRequest.input);
|
||||
assert.equal(requestUrl.searchParams.get("q"), "pi coding agent");
|
||||
assert.equal(requestUrl.searchParams.get("format"), "json");
|
||||
assert.equal(requestUrl.searchParams.get("engines"), "google,github");
|
||||
assert.equal(requestUrl.searchParams.get("language"), "en");
|
||||
assert.equal(requestUrl.searchParams.get("pageno"), "2");
|
||||
assert.equal(requestUrl.searchParams.get("time_range"), "week");
|
||||
assert.equal(new Headers(capturedRequest.init.headers).get("X-Search-Token"), "test-token");
|
||||
assert.match(result.content[0].text, /Example result/);
|
||||
assert.match(result.content[0].text, /https:\/\/example\.test\/result/);
|
||||
assert.equal(result.details.resultCount, 1);
|
||||
assert.equal(JSON.stringify(result).includes("test-token"), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalToken === undefined) delete process.env.SEARXNG_TOKEN;
|
||||
else process.env.SEARXNG_TOKEN = originalToken;
|
||||
if (originalUrl === undefined) delete process.env.SEARXNG_URL;
|
||||
else process.env.SEARXNG_URL = originalUrl;
|
||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+11
-2
@@ -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 } from "./allowed-roots";
|
||||
import { allowFileRoot, normalizeSlashes } from "./allowed-roots";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -48,6 +48,15 @@ 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,
|
||||
@@ -99,7 +108,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 = toplevel === realCwd;
|
||||
const isTopLevel = samePath(toplevel, realCwd);
|
||||
const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
|
||||
info = {
|
||||
projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
|
||||
|
||||
@@ -9,7 +9,13 @@ 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: [
|
||||
"undici",
|
||||
"@earendil-works/pi-coding-agent",
|
||||
@@ -17,7 +23,7 @@ const nextConfig: NextConfig = {
|
||||
"@earendil-works/pi-ai",
|
||||
"@earendil-works/pi-tui",
|
||||
],
|
||||
allowedDevOrigins: ['192.168.*.*'],
|
||||
allowedDevOrigins,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev -H 127.0.0.1 -p 30141",
|
||||
"dev:lan": "next dev -H 0.0.0.0 -p 30141",
|
||||
"restart": "node ../scripts/restart.mjs",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start -H 127.0.0.1 -p 30141",
|
||||
"start:lan": "next start -H 0.0.0.0 -p 30141",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = resolve(scriptDir, "../..");
|
||||
const extensionDir = resolve(appRoot, "resources/extensions");
|
||||
const extensionPath = resolve(appRoot, "resources/extensions/searxng-search.ts");
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-searxng-smoke-"));
|
||||
const agentDir = resolve(fixtureRoot, "agent");
|
||||
const query = process.argv.slice(2).join(" ").trim() || "Pi coding agent";
|
||||
|
||||
if (!process.env.SEARXNG_TOKEN?.trim()) {
|
||||
console.error("SEARXNG_TOKEN is not available to this process.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: appRoot,
|
||||
agentDir,
|
||||
additionalExtensionPaths: [extensionDir],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const loaded = loader.getExtensions();
|
||||
if (loaded.errors.length > 0) {
|
||||
throw new Error(`Extension load failed: ${loaded.errors.map((error) => error.error).join("; ")}`);
|
||||
}
|
||||
|
||||
const extension = loaded.extensions.find((candidate) => resolve(candidate.path) === extensionPath);
|
||||
const tool = extension?.tools.get("web_search")?.definition;
|
||||
if (!tool) throw new Error("web_search was not registered");
|
||||
|
||||
const result = await tool.execute(
|
||||
"searxng-smoke",
|
||||
{ query, num_results: 3 },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
extensionLoaded: true,
|
||||
tool: tool.name,
|
||||
query,
|
||||
resultCount: result.details?.resultCount ?? null,
|
||||
responseHasText: Boolean(result.content[0]?.text),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -9,3 +9,12 @@ This directory is the version-controlled resource layer for the integrated app.
|
||||
|
||||
The managed launcher loads these directories for every workspace. Project-local
|
||||
`.pi` and `.agents` resources from an opened workspace are not loaded.
|
||||
|
||||
## Included extensions
|
||||
|
||||
- `extensions/package.json` is the explicit Pi extension manifest. Add future
|
||||
extension entry points to its `pi.extensions` array so the managed loader can
|
||||
discover them.
|
||||
- `extensions/searxng-search.ts` registers the `web_search` tool. It requires
|
||||
`SEARXNG_TOKEN` and optionally accepts `SEARXNG_URL` in the Pi process
|
||||
environment.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@pi-agent-integrated/extensions",
|
||||
"private": true,
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./searxng-search.ts",
|
||||
"./vision.ts",
|
||||
"./runtime-control.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Repository-local SearXNG web search extension.
|
||||
*
|
||||
* Required environment:
|
||||
* SEARXNG_URL=https://search.example.com/search
|
||||
* SEARXNG_TOKEN=<X-Search-Token value>
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
const DEFAULT_NUM_RESULTS = 10;
|
||||
const MAX_NUM_RESULTS = 20;
|
||||
const MAX_SNIPPET_LENGTH = 2_500;
|
||||
const MAX_OUTPUT_LENGTH = 20_000;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
const searchParams = Type.Object({
|
||||
query: Type.String({
|
||||
description: "Specific, keyword-focused web search query",
|
||||
minLength: 1,
|
||||
maxLength: 500,
|
||||
}),
|
||||
num_results: Type.Optional(
|
||||
Type.Integer({
|
||||
description: `Maximum results to return (default ${DEFAULT_NUM_RESULTS}, maximum ${MAX_NUM_RESULTS})`,
|
||||
minimum: 1,
|
||||
maximum: MAX_NUM_RESULTS,
|
||||
}),
|
||||
),
|
||||
engines: Type.Optional(
|
||||
Type.Array(Type.String({ minLength: 1, maxLength: 50 }), {
|
||||
description: "Search engines to use, for example google, bing, duckduckgo, wikipedia, or github",
|
||||
minItems: 1,
|
||||
maxItems: 10,
|
||||
}),
|
||||
),
|
||||
language: Type.Optional(
|
||||
Type.String({
|
||||
description: "Result language, for example zh-CN or en",
|
||||
minLength: 2,
|
||||
maxLength: 20,
|
||||
}),
|
||||
),
|
||||
page: Type.Optional(
|
||||
Type.Integer({
|
||||
description: "Result page number (default 1)",
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
}),
|
||||
),
|
||||
time_range: Type.Optional(
|
||||
Type.Union(
|
||||
[Type.Literal("day"), Type.Literal("week"), Type.Literal("month"), Type.Literal("year")],
|
||||
{ description: "Optional recency filter" },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
interface SearxngResult {
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
engine?: string;
|
||||
engines?: string[];
|
||||
publishedDate?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface SearxngResponse {
|
||||
query?: string;
|
||||
results?: SearxngResult[];
|
||||
answers?: string[];
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
function compactText(value: string, maxLength: number): string {
|
||||
const compacted = value.replace(/\s+/g, " ").trim();
|
||||
return compacted.length <= maxLength ? compacted : `${compacted.slice(0, maxLength)}…`;
|
||||
}
|
||||
|
||||
function formatResults(data: SearxngResponse, maxResults: number): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (data.answers && data.answers.length > 0) {
|
||||
sections.push(`## Answers\n${data.answers.map((answer) => compactText(answer, MAX_SNIPPET_LENGTH)).join("\n")}`);
|
||||
}
|
||||
|
||||
const results = (data.results ?? []).slice(0, maxResults);
|
||||
if (results.length > 0) {
|
||||
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];
|
||||
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)}`);
|
||||
return lines.join("\n");
|
||||
});
|
||||
sections.push(`## Results (${results.length})\n${formatted.join("\n\n")}`);
|
||||
}
|
||||
|
||||
if (data.suggestions && data.suggestions.length > 0) {
|
||||
sections.push(
|
||||
`## Suggestions\n${data.suggestions.map((suggestion) => compactText(suggestion, 500)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = sections.length > 0 ? sections.join("\n\n") : "No results found.";
|
||||
return text.length <= MAX_OUTPUT_LENGTH ? text : `${text.slice(0, MAX_OUTPUT_LENGTH)}\n\n[Truncated]`;
|
||||
}
|
||||
|
||||
function configuredEndpoint(): string {
|
||||
const endpoint = process.env.SEARXNG_URL?.trim();
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
"SEARXNG_URL is not available to the Pi process. Set it in .env or the environment and restart Pi Agent Integrated.",
|
||||
);
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export default function searxngSearchExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
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.",
|
||||
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.",
|
||||
],
|
||||
parameters: searchParams,
|
||||
executionMode: "parallel",
|
||||
async execute(_toolCallId, params, signal) {
|
||||
const token = process.env.SEARXNG_TOKEN?.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"SEARXNG_TOKEN is not available to the Pi process. Set it in the environment and restart Pi Agent Integrated.",
|
||||
);
|
||||
}
|
||||
|
||||
const query = params.query.trim();
|
||||
if (!query) throw new Error("Search query must not be empty");
|
||||
|
||||
const maxResults = params.num_results ?? DEFAULT_NUM_RESULTS;
|
||||
const endpoint = configuredEndpoint();
|
||||
const url = new URL(endpoint);
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("format", "json");
|
||||
|
||||
const engines = params.engines?.map((engine) => engine.trim()).filter(Boolean);
|
||||
if (engines && engines.length > 0) url.searchParams.set("engines", engines.join(","));
|
||||
if (params.language) url.searchParams.set("language", params.language);
|
||||
if (params.page) url.searchParams.set("pageno", String(params.page));
|
||||
if (params.time_range) url.searchParams.set("time_range", params.time_range);
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
||||
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Search-Token": token,
|
||||
},
|
||||
signal: requestSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SearXNG request failed: HTTP ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
let data: SearxngResponse;
|
||||
try {
|
||||
data = JSON.parse(body) as SearxngResponse;
|
||||
} catch {
|
||||
throw new Error("SearXNG returned a non-JSON response");
|
||||
}
|
||||
|
||||
const resultCount = Math.min(data.results?.length ?? 0, maxResults);
|
||||
return {
|
||||
content: [{ type: "text", text: formatResults(data, maxResults) }],
|
||||
details: {
|
||||
query,
|
||||
endpoint,
|
||||
resultCount,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,14 @@ const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
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"),
|
||||
join(rootDir, ".env.example"),
|
||||
join(rootDir, "LICENSE"),
|
||||
join(rootDir, "THIRD_PARTY_NOTICES.md"),
|
||||
join(rootDir, "resources", "skills"),
|
||||
join(rootDir, "resources", "extensions"),
|
||||
join(rootDir, "resources", "prompts"),
|
||||
@@ -21,7 +28,13 @@ const packages = [
|
||||
];
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
const webPackage = readJson(join(webDir, "package.json"));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
agentDir,
|
||||
dataDir,
|
||||
managedEnvironment,
|
||||
resourcesDir,
|
||||
rootDir,
|
||||
} from "./profile.mjs";
|
||||
|
||||
Object.assign(process.env, managedEnvironment());
|
||||
|
||||
const codingAgentEntry = pathToFileURL(
|
||||
join(rootDir, "pi-web", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"),
|
||||
).href;
|
||||
const { DefaultResourceLoader } = await import(codingAgentEntry);
|
||||
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: join(dataDir, "workspaces", "default"),
|
||||
agentDir,
|
||||
additionalExtensionPaths: [join(resourcesDir, "extensions")],
|
||||
additionalSkillPaths: [join(resourcesDir, "skills")],
|
||||
});
|
||||
|
||||
await loader.reload();
|
||||
const loaded = loader.getExtensions();
|
||||
if (loaded.errors.length > 0) {
|
||||
for (const error of loaded.errors) console.error(`Extension load error: ${error.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tools = new Set(loaded.extensions.flatMap((extension) => [...extension.tools.keys()]));
|
||||
const commands = new Set(loaded.extensions.flatMap((extension) => [...extension.commands.keys()]));
|
||||
const requiredTools = [
|
||||
"web_search",
|
||||
"mcp",
|
||||
"lens_diagnostics",
|
||||
"subagent",
|
||||
"web_fetch",
|
||||
"memory_write",
|
||||
"resolve-library-id",
|
||||
"query-docs",
|
||||
];
|
||||
const requiredCommands = ["checkpoint", "rewind"];
|
||||
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(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const npmCliPath = process.env.npm_execpath;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (platform() === "win32") {
|
||||
const edgeCandidates = [
|
||||
join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
join(process.env.ProgramFiles ?? "C:\\Program Files", "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
];
|
||||
if (!edgeCandidates.some(existsSync)) {
|
||||
console.error("Microsoft Edge is required by config/mcp.default.json but was not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Managed Pi profile ready: ${loaded.extensions.length} extension(s), ${tools.size} tool(s), ${commands.size} command(s).`,
|
||||
);
|
||||
@@ -0,0 +1,299 @@
|
||||
#!/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 再执行。`);
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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(
|
||||
readFileSync(join(rootDir, "config", "settings.default.json"), "utf8"),
|
||||
);
|
||||
const packages = Array.isArray(settings.packages) ? settings.packages : [];
|
||||
const cliPath = join(rootDir, "pi", "packages", "coding-agent", "dist", "cli.js");
|
||||
const env = managedEnvironment();
|
||||
|
||||
function parseExactNpmSource(source) {
|
||||
if (!source.startsWith("npm:")) return undefined;
|
||||
const spec = source.slice(4);
|
||||
const separator = spec.lastIndexOf("@");
|
||||
if (separator <= 0) return undefined;
|
||||
return { name: spec.slice(0, separator), version: spec.slice(separator + 1) };
|
||||
}
|
||||
|
||||
function installedAtExpectedVersion(source) {
|
||||
const parsed = parseExactNpmSource(source);
|
||||
if (!parsed) return false;
|
||||
const packageJson = join(agentDir, "npm", "node_modules", ...parsed.name.split("/"), "package.json");
|
||||
if (!existsSync(packageJson)) return false;
|
||||
try {
|
||||
return JSON.parse(readFileSync(packageJson, "utf8")).version === parsed.version;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of packages) {
|
||||
if (typeof source !== "string" || !source.trim()) continue;
|
||||
if (installedAtExpectedVersion(source)) {
|
||||
console.log(`Managed package ready: ${source}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Installing managed package: ${source}`);
|
||||
const result = spawnSync(process.execPath, [cliPath, "install", source], {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
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}`);
|
||||
}
|
||||
+70
-2
@@ -1,25 +1,40 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { loadEnvFile } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const projectEnvPath = join(rootDir, ".env");
|
||||
if (existsSync(projectEnvPath)) loadEnvFile(projectEnvPath);
|
||||
|
||||
export const configDir = join(rootDir, "config");
|
||||
export const resourcesDir = join(rootDir, "resources");
|
||||
export const dataDir = resolve(process.env.PI_AGENT_DATA_DIR ?? join(rootDir, "data"));
|
||||
export const agentDir = join(dataDir, "agent");
|
||||
export const homeDir = join(dataDir, "home");
|
||||
export const memoryDir = join(agentDir, "memory");
|
||||
export const skillsHomeDir = join(dataDir, "skills-home");
|
||||
export const stateDir = join(dataDir, "state");
|
||||
|
||||
const managedDirectories = [
|
||||
agentDir,
|
||||
homeDir,
|
||||
join(agentDir, "sessions"),
|
||||
join(agentDir, "npm"),
|
||||
join(agentDir, "git"),
|
||||
join(agentDir, "bin"),
|
||||
join(agentDir, "extensions", "subagent"),
|
||||
memoryDir,
|
||||
join(memoryDir, "daily"),
|
||||
join(memoryDir, "recovery"),
|
||||
join(agentDir, "tools"),
|
||||
join(agentDir, "tmp"),
|
||||
join(dataDir, "logs"),
|
||||
join(dataDir, "cache"),
|
||||
join(dataDir, "cache", "npm"),
|
||||
join(dataDir, "mcp", "playwright", "output"),
|
||||
join(dataDir, "workspaces", "default"),
|
||||
join(skillsHomeDir, ".agents", "skills"),
|
||||
join(skillsHomeDir, ".pi", "agent", "skills"),
|
||||
stateDir,
|
||||
@@ -29,11 +44,54 @@ 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, "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")],
|
||||
];
|
||||
|
||||
const persistedWindowsEnvironmentKeys = ["SEARXNG_TOKEN", "SEARXNG_URL"];
|
||||
|
||||
function readPersistedWindowsEnvironment(baseEnv) {
|
||||
if (platform() !== "win32") return {};
|
||||
|
||||
const missingKeys = persistedWindowsEnvironmentKeys.filter((key) => !baseEnv[key]?.trim());
|
||||
if (missingKeys.length === 0) return {};
|
||||
|
||||
const keyLiterals = missingKeys.map((key) => `'${key.replaceAll("'", "''")}'`).join(", ");
|
||||
const script = `
|
||||
$result = @{}
|
||||
foreach ($name in @(${keyLiterals})) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name, 'User')
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name, 'Machine')
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
$result[$name] = $value
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -Compress -InputObject $result
|
||||
`;
|
||||
|
||||
const result = spawnSync(
|
||||
"powershell.exe",
|
||||
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (result.status !== 0 || !result.stdout.trim()) return {};
|
||||
|
||||
try {
|
||||
const values = JSON.parse(result.stdout);
|
||||
return values && typeof values === "object" && !Array.isArray(values) ? values : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureProfile({ quiet = false } = {}) {
|
||||
for (const path of managedDirectories) mkdirSync(path, { recursive: true });
|
||||
|
||||
@@ -49,19 +107,29 @@ export function ensureProfile({ quiet = false } = {}) {
|
||||
if (created.length > 0) console.log(`Initialized ${created.length} profile file(s).`);
|
||||
}
|
||||
|
||||
return { rootDir, configDir, resourcesDir, dataDir, agentDir, skillsHomeDir, stateDir, created };
|
||||
return { rootDir, configDir, resourcesDir, dataDir, agentDir, homeDir, skillsHomeDir, stateDir, created };
|
||||
}
|
||||
|
||||
export function managedEnvironment(baseEnv = process.env) {
|
||||
ensureProfile({ quiet: true });
|
||||
const persistedEnvironment = readPersistedWindowsEnvironment(baseEnv);
|
||||
return {
|
||||
...baseEnv,
|
||||
...persistedEnvironment,
|
||||
PI_AGENT_MANAGED_RUNTIME: "1",
|
||||
PI_AGENT_APP_ROOT: rootDir,
|
||||
PI_AGENT_DATA_DIR: dataDir,
|
||||
PI_AGENT_RESOURCES_DIR: resourcesDir,
|
||||
PI_AGENT_SKILLS_HOME: skillsHomeDir,
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
HOME: homeDir,
|
||||
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"),
|
||||
XDG_STATE_HOME: stateDir,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
|
||||
function stopWindowsDevelopmentProcesses() {
|
||||
const projectRootLiteral = rootDir.replaceAll("'", "''");
|
||||
const script = `
|
||||
$projectRoot = '${projectRootLiteral}'
|
||||
$currentProcessId = ${process.pid}
|
||||
$excludedProcessIds = [Collections.Generic.HashSet[int]]::new()
|
||||
$ancestorProcessId = $currentProcessId
|
||||
|
||||
while ($ancestorProcessId -gt 0 -and $excludedProcessIds.Add($ancestorProcessId)) {
|
||||
$ancestor = Get-CimInstance Win32_Process -Filter "ProcessId = $ancestorProcessId" -ErrorAction SilentlyContinue
|
||||
if (-not $ancestor) {
|
||||
break
|
||||
}
|
||||
$ancestorProcessId = [int]$ancestor.ParentProcessId
|
||||
}
|
||||
|
||||
$targets = Get-CimInstance Win32_Process | Where-Object {
|
||||
-not $excludedProcessIds.Contains([int]$_.ProcessId) -and
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine.IndexOf($projectRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0 -and
|
||||
$_.Name -in @('node.exe', 'cmd.exe')
|
||||
}
|
||||
|
||||
foreach ($target in $targets) {
|
||||
Stop-Process -Id $target.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
$listener = Get-NetTCPConnection -LocalPort ${DEV_PORT} -State Listen -ErrorAction SilentlyContinue
|
||||
if ($listener) {
|
||||
throw "Port ${DEV_PORT} is still occupied by PID $($listener[0].OwningProcess)"
|
||||
}
|
||||
|
||||
exit 0
|
||||
`;
|
||||
|
||||
const result = spawnSync(
|
||||
"powershell.exe",
|
||||
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
{ cwd: rootDir, stdio: "inherit" },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
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));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const npmCliPath = process.env.npm_execpath;
|
||||
if (!npmCliPath) {
|
||||
console.error("Run this command through npm: npm run restart");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Restarting Pi Agent Integrated at http://127.0.0.1:${DEV_PORT}`);
|
||||
const child = spawn(process.execPath, [npmCliPath, "run", "dev"], {
|
||||
cwd: rootDir,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => child.kill(signal));
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
+28
-2
@@ -1,9 +1,16 @@
|
||||
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(["dev", "dev:lan", "start", "start:lan"]);
|
||||
const allowedTargets = new Set(["build", "dev", "dev:lan", "start", "start:lan"]);
|
||||
if (!allowedTargets.has(target)) {
|
||||
console.error(`Unsupported Pi Web script: ${target}`);
|
||||
process.exit(2);
|
||||
@@ -15,12 +22,28 @@ 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: managedEnvironment(),
|
||||
env: childEnvironment,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
@@ -34,6 +57,9 @@ 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);
|
||||
});
|
||||
|
||||
+23
-6
@@ -3,11 +3,11 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ensureProfile } from "./profile.mjs";
|
||||
import { ensureProfile, managedEnvironment } from "./profile.mjs";
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const npmCliPath = process.env.npm_execpath;
|
||||
const setupEnv = { ...process.env };
|
||||
const setupEnv = managedEnvironment();
|
||||
|
||||
if (process.env.PI_SETUP_USE_PROXY !== "1") {
|
||||
for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]) {
|
||||
@@ -91,17 +91,34 @@ function assertSupportedNodeVersion() {
|
||||
assertSupportedNodeVersion();
|
||||
ensureProfile();
|
||||
|
||||
console.log("[1/4] Installing Pi dependencies...");
|
||||
console.log("[1/5] Installing Pi dependencies...");
|
||||
run(["ci", "--ignore-scripts"], join(rootDir, "pi"));
|
||||
|
||||
console.log("[2/4] Hydrating model data and building local Pi packages...");
|
||||
console.log("[2/5] Hydrating model data and building local Pi packages...");
|
||||
hydrateModelData();
|
||||
run(["run", "build:offline"], join(rootDir, "pi"));
|
||||
|
||||
console.log("[3/4] Installing Pi Web with local Pi package links...");
|
||||
console.log("[3/5] Installing Pi Web with local Pi package links...");
|
||||
run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "pi-web"));
|
||||
|
||||
console.log("[4/4] Verifying the integration...");
|
||||
console.log("[4/5] Installing and validating managed Pi packages...");
|
||||
const packageResult = spawnSync(process.execPath, [join(rootDir, "scripts", "install-managed-packages.mjs")], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: setupEnv,
|
||||
});
|
||||
if (packageResult.error) throw packageResult.error;
|
||||
if (packageResult.status !== 0) process.exit(packageResult.status ?? 1);
|
||||
|
||||
const profileResult = spawnSync(process.execPath, [join(rootDir, "scripts", "check-managed-profile.mjs")], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: setupEnv,
|
||||
});
|
||||
if (profileResult.error) throw profileResult.error;
|
||||
if (profileResult.status !== 0) process.exit(profileResult.status ?? 1);
|
||||
|
||||
console.log("[5/5] Verifying the integration...");
|
||||
const checkResult = spawnSync(process.execPath, [join(rootDir, "scripts", "check-integration.mjs")], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { rootDir } from "./profile.mjs";
|
||||
|
||||
const npmCliPath = process.env.npm_execpath;
|
||||
if (!npmCliPath) {
|
||||
console.error("Run this smoke test through npm: npm run smoke:fresh-profile");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fixtureDataDir = mkdtempSync(join(tmpdir(), "pi-agent-integrated-profile-"));
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
PI_AGENT_DATA_DIR: fixtureDataDir,
|
||||
};
|
||||
if (process.env.PI_SETUP_USE_PROXY !== "1") {
|
||||
for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]) {
|
||||
delete childEnv[key];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const installResult = spawnSync(
|
||||
process.execPath,
|
||||
[npmCliPath, "run", "profile:packages"],
|
||||
{ cwd: rootDir, env: childEnv, stdio: "inherit" },
|
||||
);
|
||||
if (installResult.error) throw installResult.error;
|
||||
if (installResult.status !== 0) {
|
||||
throw new Error(`Fresh profile package installation exited with ${installResult.status ?? "unknown status"}`);
|
||||
}
|
||||
|
||||
const checkResult = spawnSync(
|
||||
process.execPath,
|
||||
[npmCliPath, "run", "check:profile"],
|
||||
{ cwd: rootDir, env: childEnv, stdio: "inherit" },
|
||||
);
|
||||
if (checkResult.error) throw checkResult.error;
|
||||
if (checkResult.status !== 0) {
|
||||
throw new Error(`Fresh profile validation exited with ${checkResult.status ?? "unknown status"}`);
|
||||
}
|
||||
|
||||
const settingsPath = join(fixtureDataDir, "agent", "settings.json");
|
||||
const mcpPath = join(fixtureDataDir, "agent", "mcp.json");
|
||||
const packagePath = join(fixtureDataDir, "agent", "npm", "package.json");
|
||||
for (const path of [settingsPath, mcpPath, packagePath]) {
|
||||
if (!existsSync(path)) throw new Error(`Fresh profile is missing ${path}`);
|
||||
JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
console.log("Fresh managed profile installed and loaded successfully.");
|
||||
} finally {
|
||||
rmSync(fixtureDataDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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 }));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user