mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a196c3a6b1 | ||
|
|
2908bcb6d0 | ||
|
|
dcce2468a0 | ||
|
|
7748c98af6 | ||
|
|
75b8c83ea3 | ||
|
|
bd4eb05a2a | ||
|
|
f531e8e2ef | ||
|
|
92e8184136 | ||
|
|
ac0f0cdc0c | ||
|
|
ccb038988e | ||
|
|
e79485bf4c | ||
|
|
dd47c3628d | ||
|
|
690020f823 | ||
|
|
5378d48095 | ||
|
|
a70798e874 | ||
|
|
551c59413e | ||
|
|
3b48681852 | ||
|
|
ec17e3c8f7 |
@@ -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 这类纯文本模型)看不到图片,完全依赖你的描述,完整性优先。
|
||||
+19
-1
@@ -5,6 +5,14 @@
|
||||
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=
|
||||
|
||||
@@ -13,8 +21,18 @@ 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.
|
||||
# PI_AGENT_DATA_DIR=D:\path\to\pi-agent-data
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: CI
|
||||
|
||||
on:
|
||||
"on":
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
@@ -28,3 +29,22 @@ jobs:
|
||||
- run: npm run setup
|
||||
- run: npm run typecheck
|
||||
- run: npm run test:managed
|
||||
|
||||
macos:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-15, macos-15-intel]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.19.0
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
pi/package-lock.json
|
||||
pi-web/package-lock.json
|
||||
- run: npm run setup
|
||||
- run: npm run typecheck
|
||||
- run: npm run test:managed
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
/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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Pi Agent Integrated
|
||||
|
||||
[中文](#中文) · [English](#english)
|
||||
[中文](#中文) · [English](#english) · [🌐 项目介绍页](https://pi.llm-local.cloud)
|
||||
|
||||
A Windows-first, repository-local distribution that connects the [Pi](https://github.com/earendil-works/pi) agent runtime to the [Pi Web](https://github.com/agegr/pi-web) browser UI and adds a managed plugin/tool ecosystem.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,24 +20,26 @@ Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆
|
||||
| 默认工作目录 | 当前终端目录 | 在用户主目录创建日期目录 | `data/workspaces/default/` |
|
||||
| 扩展生态 | 支持 Skill、扩展和包 | 提供管理界面 | 固定版本插件、MCP、Skill、提示词和主题形成项目闭环 |
|
||||
| 网络搜索 | 无项目专属搜索 | 无项目专属搜索 | 可选 SearXNG `web_search` 扩展 |
|
||||
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Playwright MCP,使用系统 Edge,按需启动 |
|
||||
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Windows 默认使用 Edge;macOS 默认不启用,按需配置 |
|
||||
| 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 |
|
||||
| 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 |
|
||||
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows restart |
|
||||
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart |
|
||||
|
||||
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
|
||||
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 或 macOS 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Windows 10/11(当前唯一实机验证平台)
|
||||
- Windows 10/11 或 macOS(Intel/Apple Silicon)
|
||||
- Node.js `22.19.0` 或更高版本
|
||||
- npm 与 Git
|
||||
- 首次安装时可访问 npm registry
|
||||
- Microsoft Edge(仅 Playwright 浏览器工具需要;不会额外下载 Chromium)
|
||||
- Microsoft Edge(仅 Windows 默认 Playwright 浏览器工具需要;macOS 默认不启用 Playwright,不会自动下载浏览器)
|
||||
- 至少一个由用户自行配置的模型供应商、订阅登录或兼容 API
|
||||
|
||||
### 快速开始
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
@@ -45,6 +47,15 @@ npm run setup
|
||||
npm run dev
|
||||
```
|
||||
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
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>。
|
||||
|
||||
`npm run setup` 会:
|
||||
@@ -53,10 +64,10 @@ npm run dev
|
||||
2. 安装并构建本地 Pi 源码;
|
||||
3. 安装 Pi Web 并连接本地 Pi 包;
|
||||
4. 安装、固定并加载检查默认插件;
|
||||
5. 预缓存 Playwright MCP 包并确认系统 Edge 可用;
|
||||
5. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置;
|
||||
6. 检查前后端版本和构建产物。
|
||||
|
||||
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,先设置 `$env:PI_SETUP_USE_PROXY = "1"`。
|
||||
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,Windows PowerShell 设置 `$env:PI_SETUP_USE_PROXY = "1"`,macOS/Linux 使用 `PI_SETUP_USE_PROXY=1 npm run setup`。
|
||||
|
||||
### 首次模型配置
|
||||
|
||||
@@ -73,6 +84,14 @@ npm run dev
|
||||
|
||||
需要可选服务时,复制模板:
|
||||
|
||||
macOS/Linux:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
@@ -110,22 +129,75 @@ data/workspaces/default/ 默认工作目录
|
||||
|
||||
### 内置插件与工具
|
||||
|
||||
所有版本固定在 `config/settings.default.json` 和 `config/mcp.default.json`。
|
||||
版本固定在 `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` | 真实网页导航、点击、表单、快照和截图 | 通过 MCP 按需启动,空闲 5 分钟退出;使用无痕 Headless 系统 Edge |
|
||||
| `@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 长期记忆、日志、临时工作区和恢复记录 | 模型按需使用 memory 工具;文件位于 `data/agent/memory/`,默认不启用向量索引 |
|
||||
| `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 任选),需在「视觉」标签页或环境变量中自行设置 |
|
||||
|
||||
Playwright 不下载独立 Chromium。首次 `setup` 只缓存 MCP 的 Node.js 包;浏览器执行使用系统 Edge。
|
||||
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、迁移和自定义扩展
|
||||
|
||||
@@ -151,8 +223,9 @@ npm run profile:migrate -- --from D:\old-pi\agent --skills-from D:\old-skills
|
||||
```powershell
|
||||
npm run setup # 完整安装、构建和 Profile 插件校验
|
||||
npm run profile:packages # 安装缺失或版本不匹配的受管插件
|
||||
npm run memory:configure # 重新应用受管 pi-memory 轻量集成
|
||||
npm run dev # 127.0.0.1:30141 开发服务
|
||||
npm run restart # Windows:停止本项目旧开发进程并重新启动
|
||||
npm run restart # Windows/macOS:停止本项目旧开发进程并重新启动
|
||||
npm run dev:lan # 局域网监听;仅在可信网络使用
|
||||
npm run build # 构建 Pi Web 生产产物
|
||||
npm run start # 启动已构建的本机生产服务
|
||||
@@ -176,6 +249,7 @@ npm run smoke:search -- "关键词" # 使用真实 SearXNG;需要配置
|
||||
- Rewind 检查点包含大量松散 Git 对象时自动执行压缩,但保留所有有效检查点;
|
||||
- 已找不到对应会话的孤儿 Rewind 检查点保留 7 天后自动删除;
|
||||
- 会话、记忆、凭据、模型配置、已安装插件和仍有关联的检查点不会被自动删除。
|
||||
- 手动清理:`node scripts/cleanup-cache.mjs`(默认预览,加 `--apply` 执行)——清 npm/opengrep 缓存、按天数移走过期会话、把旧会话图片替换为占位符瘦身;删除先进回收目录。
|
||||
|
||||
```powershell
|
||||
npm run storage:status # 只查看受管缓存大小
|
||||
@@ -208,25 +282,27 @@ Pi Agent Integrated combines the Pi backend and Pi Web frontend into one indepen
|
||||
| Default workspace | Current terminal directory | Creates a dated home directory | `data/workspaces/default/` |
|
||||
| Extension ecosystem | Supports skills, extensions, packages | Management UI | Pinned plugins, MCP, skills, prompts, and themes form a managed ecosystem |
|
||||
| Web search | No project-specific search | No project-specific search | Optional SearXNG `web_search` extension |
|
||||
| Browser automation | User-integrated | No default browser MCP | Lazy Playwright MCP using system Edge |
|
||||
| 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 restart |
|
||||
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows/macOS restart |
|
||||
|
||||
This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line.
|
||||
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
|
||||
|
||||
- Windows 10/11, the only currently validated platform
|
||||
- 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 Playwright browser tools; no separate Chromium is downloaded
|
||||
- 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
|
||||
Windows PowerShell or macOS Terminal:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
npm run setup
|
||||
@@ -235,9 +311,9 @@ npm run dev
|
||||
|
||||
Open <http://127.0.0.1:30141>.
|
||||
|
||||
Setup initializes the repository-local profile, builds Pi, links Pi Web to the local packages, installs and loads the pinned plugins, caches the Playwright MCP Node package without downloading a browser, verifies system Edge, and checks integration artifacts.
|
||||
Setup initializes the repository-local profile, builds Pi, links Pi Web to the local packages, installs and loads the pinned plugins, caches the Playwright MCP Node package and verifies system Edge on Windows, skips Playwright on macOS, and checks integration artifacts.
|
||||
|
||||
Child npm operations ignore `HTTP_PROXY` and `HTTPS_PROXY` by default to avoid stale proxy configuration. Set `$env:PI_SETUP_USE_PROXY = "1"` first when registry access requires the proxy.
|
||||
Child npm operations ignore `HTTP_PROXY` and `HTTPS_PROXY` by default to avoid stale proxy configuration. Set `$env:PI_SETUP_USE_PROXY = "1"` on Windows, or run `PI_SETUP_USE_PROXY=1 npm run setup` on macOS/Linux, when registry access requires the proxy.
|
||||
|
||||
### First model configuration
|
||||
|
||||
@@ -252,6 +328,14 @@ 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
|
||||
```
|
||||
@@ -289,22 +373,75 @@ data/workspaces/default/ Default workspace
|
||||
|
||||
### Included plugins and tools
|
||||
|
||||
Versions are pinned in `config/settings.default.json` and `config/mcp.default.json`.
|
||||
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 | Starts on demand, exits after five idle minutes, uses isolated headless system Edge |
|
||||
| `@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 | The model uses memory tools on demand; files live under `data/agent/memory/`; vector indexing is off by default |
|
||||
| `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 |
|
||||
|
||||
Playwright never downloads a standalone Chromium in this project. Setup caches only its Node package; browser execution uses system Edge.
|
||||
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
|
||||
|
||||
@@ -323,8 +460,9 @@ Migration copies without deleting source data and removes absolute resource path
|
||||
```powershell
|
||||
npm run setup # Full install, build, and managed-profile validation
|
||||
npm run profile:packages # Install missing or version-mismatched managed plugins
|
||||
npm run memory:configure # Reapply the managed pi-memory lightweight integration
|
||||
npm run dev # Development server on 127.0.0.1:30141
|
||||
npm run restart # Windows: stop this project's old dev process and restart
|
||||
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
|
||||
@@ -348,6 +486,7 @@ Before `dev`, `restart`, `build`, or `start` launches Pi Web, the integrated lau
|
||||
- Rewind repositories with many loose Git objects are compacted without dropping valid checkpoints;
|
||||
- orphan Rewind checkpoints whose session no longer exists are removed after a seven-day grace period;
|
||||
- sessions, memory, credentials, model configuration, installed plugins, and linked checkpoints are never automatically deleted.
|
||||
- Manual cleanup: `node scripts/cleanup-cache.mjs` (dry-run by default; add `--apply` to execute) — clears npm/opengrep caches, moves expired sessions aside by age, and can shrink old sessions by replacing images with placeholders; deletions go to a trash directory first.
|
||||
|
||||
```powershell
|
||||
npm run storage:status # Report managed cache sizes without changing data
|
||||
|
||||
+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,7 @@
|
||||
{
|
||||
"settings": {
|
||||
"hostConfigDiscovery": "off",
|
||||
"idleTimeout": 5
|
||||
},
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
@@ -12,6 +12,7 @@
|
||||
"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",
|
||||
@@ -31,7 +32,7 @@
|
||||
"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 scripts/storage-maintenance.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/searxng-extension.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs"
|
||||
"test:managed": "node --test scripts/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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+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,6 +9,11 @@ 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: [
|
||||
@@ -18,7 +23,7 @@ const nextConfig: NextConfig = {
|
||||
"@earendil-works/pi-ai",
|
||||
"@earendil-works/pi-tui",
|
||||
],
|
||||
allowedDevOrigins: ['192.168.*.*'],
|
||||
allowedDevOrigins,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"private": true,
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./searxng-search.ts"
|
||||
"./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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -91,7 +91,8 @@ function formatResults(data: SearxngResponse, maxResults: number): string {
|
||||
const formatted = results.map((result, index) => {
|
||||
const lines = [`${index + 1}. ${compactText(result.title ?? "(no title)", 500)}`];
|
||||
if (result.url) lines.push(` URL: ${result.url}`);
|
||||
const engines = result.engines?.length ? result.engines : result.engine ? [result.engine] : [];
|
||||
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)}`);
|
||||
@@ -125,10 +126,14 @@ export default function searxngSearchExtension(pi: ExtensionAPI) {
|
||||
name: "web_search",
|
||||
label: "Web Search",
|
||||
description:
|
||||
"Search the current web through the application's configured SearXNG service. Returns result titles, URLs, engines, dates, and snippets. Use it for recent or time-sensitive information, current software documentation and versions, unfamiliar errors, explicit lookup requests, and verifiable facts you are uncertain about.",
|
||||
"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.",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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"),
|
||||
@@ -27,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"));
|
||||
|
||||
@@ -49,6 +49,12 @@ 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(", ")}`);
|
||||
@@ -60,15 +66,19 @@ if (!npmCliPath) {
|
||||
console.error("Run the managed profile check through npm: npm run check:profile");
|
||||
process.exit(1);
|
||||
}
|
||||
const playwrightResult = spawnSync(
|
||||
process.execPath,
|
||||
[npmCliPath, "exec", "--yes", "--package=@playwright/mcp@0.0.78", "--", "playwright-mcp", "--version"],
|
||||
{ cwd: rootDir, env: process.env, encoding: "utf8" },
|
||||
);
|
||||
if (playwrightResult.error || playwrightResult.status !== 0) {
|
||||
process.stderr.write(playwrightResult.stderr ?? "");
|
||||
console.error("Unable to cache @playwright/mcp@0.0.78 for the managed MCP profile.");
|
||||
process.exit(playwrightResult.status ?? 1);
|
||||
if (platform() === "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") {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
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(
|
||||
@@ -46,3 +48,10 @@ for (const source of packages) {
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
configurePiMemory({ agentDir });
|
||||
try {
|
||||
configurePiAiVision({ quiet: false });
|
||||
} catch (error) {
|
||||
console.warn(`[vision] pi-ai passthrough patch skipped: ${error.message}`);
|
||||
}
|
||||
|
||||
+4
-1
@@ -44,9 +44,11 @@ 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, "mcp.default.json"), join(agentDir, "mcp.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")],
|
||||
@@ -124,6 +126,7 @@ export function managedEnvironment(baseEnv = process.env) {
|
||||
USERPROFILE: homeDir,
|
||||
PI_MEMORY_DIR: baseEnv.PI_MEMORY_DIR?.trim() || memoryDir,
|
||||
PI_MEMORY_SNAPSHOT: baseEnv.PI_MEMORY_SNAPSHOT?.trim() || "stable",
|
||||
PI_MEMORY_NO_SEARCH: baseEnv.PI_MEMORY_NO_SEARCH?.trim() || "1",
|
||||
PI_MEMORY_QMD_UPDATE: baseEnv.PI_MEMORY_QMD_UPDATE?.trim() || "off",
|
||||
PI_RETRY_STALL_TIMEOUT_MS: baseEnv.PI_RETRY_STALL_TIMEOUT_MS?.trim() || "180000",
|
||||
npm_config_cache: baseEnv.npm_config_cache?.trim() || join(dataDir, "cache", "npm"),
|
||||
|
||||
+76
-4
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -49,12 +50,83 @@ exit 0
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (platform() !== "win32") {
|
||||
console.error("npm run restart currently supports Windows only. Stop the existing dev process and run npm run dev.");
|
||||
process.exit(1);
|
||||
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),
|
||||
)];
|
||||
}
|
||||
|
||||
stopWindowsDevelopmentProcesses();
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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,
|
||||
@@ -21,6 +23,15 @@ if (!npmCliPath) {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user