mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 11:39:36 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5f180b2a |
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: vision
|
||||
description: 视觉子代理 —— 读取并描述图片(截图/图表/文档/照片),输出完整结构化描述(OCR/版式/语义),供不支持图片输入的主模型(如 DeepSeek)推理使用。后端可配置(本地 Ollama 或 OpenAI 兼容视觉 API),初始未配置需先在 WebUI 视觉面板或环境变量中设置
|
||||
tools: vision
|
||||
subagentOnlyExtensions: ./resources/extensions/vision.ts
|
||||
thinking: false
|
||||
systemPromptMode: replace
|
||||
inheritProjectContext: false
|
||||
inheritSkills: false
|
||||
defaultProgress: true
|
||||
---
|
||||
|
||||
你是视觉子代理。主会话会把一个或多个图片文件路径交给你,你调用 `vision` 工具让视觉模型看图并返回文本描述。
|
||||
|
||||
工作规则:
|
||||
|
||||
- 对每个图片路径调用一次 `vision`;相关图片可一次传入多张。
|
||||
- 工具返回的是视觉模型的转录:忠实转达,OCR 文字逐字保留,不要改写或脑补。
|
||||
- 工具报错时(文件不存在 / 后端未配置 / 模型未拉取)如实报告,并给出明确的修复提示(如安装 Ollama 并拉取视觉模型,或检查 `VISION_OPENAI_*` 环境变量)。
|
||||
- 输出保持结构化:多图按图分组,先给结论性总结,再附关键细节;文字类图片保证转录完整。
|
||||
|
||||
主会话(通常是 DeepSeek 这类纯文本模型)看不到图片,完全依赖你的描述,完整性优先。
|
||||
+1
-19
@@ -5,14 +5,6 @@
|
||||
SEARXNG_URL=
|
||||
SEARXNG_TOKEN=
|
||||
|
||||
# Optional public URL reported by pi-agent-public.ps1 (e.g. your own tunnel domain).
|
||||
PI_AGENT_PUBLIC_URL=
|
||||
|
||||
# Optional SSH reverse-tunnel command for public access, run as a scheduled task
|
||||
# by pi-agent-public.ps1 (registered only when set). Example for an "aliyun" host
|
||||
# in ~/.ssh/config forwarding remote 8090 to local 30141:
|
||||
# PI_AGENT_TUNNEL_COMMAND=C:\WINDOWS\System32\OpenSSH\ssh.exe -n -N -T -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R 127.0.0.1:8090:127.0.0.1:30141 aliyun
|
||||
|
||||
# Optional higher Context7 quota. Context7 also works at its public unauthenticated limit.
|
||||
CONTEXT7_API_KEY=
|
||||
|
||||
@@ -21,18 +13,8 @@ ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# pi-memory runs in lightweight Markdown-only mode by default. This keeps
|
||||
# durable facts, daily logs, scratchpad, and recovery without qmd/model downloads.
|
||||
# Set both values below only after installing and configuring qmd yourself.
|
||||
# PI_MEMORY_NO_SEARCH=0
|
||||
# PI_MEMORY_QMD_UPDATE=background
|
||||
|
||||
# Optional: move mutable runtime data while retaining the same isolation model.
|
||||
# macOS/Linux: PI_AGENT_DATA_DIR=/path/to/pi-agent-data
|
||||
# Windows PowerShell: PI_AGENT_DATA_DIR=D:\path\to\pi-agent-data
|
||||
|
||||
# macOS setup does not install or enable Playwright by default. Add it manually
|
||||
# through the MCP panel if browser automation is needed.
|
||||
# PI_AGENT_DATA_DIR=D:\path\to\pi-agent-data
|
||||
|
||||
# Optional storage ceilings. Automatic maintenance runs before Pi Web starts,
|
||||
# removes only rebuildable caches that exceed these limits, compacts Rewind Git
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: CI
|
||||
|
||||
"on":
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
@@ -29,22 +28,3 @@ jobs:
|
||||
- run: npm run setup
|
||||
- run: npm run typecheck
|
||||
- run: npm run test:managed
|
||||
|
||||
macos:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-15, macos-15-intel]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.19.0
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
pi/package-lock.json
|
||||
pi-web/package-lock.json
|
||||
- run: npm run setup
|
||||
- run: npm run typecheck
|
||||
- run: npm run test:managed
|
||||
|
||||
+1
-4
@@ -1,7 +1,4 @@
|
||||
/data/*
|
||||
# Local tools are code (shareable) — supervisor / public-access scripts for
|
||||
# the managed Web service. Everything else under data/ stays untracked.
|
||||
!/data/local-tools/
|
||||
/data/
|
||||
|
||||
# Local configuration and credentials. Keep the shareable template only.
|
||||
.env
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Pi Agent Integrated
|
||||
|
||||
[中文](#中文) · [English](#english) · [🌐 项目介绍页](https://pi.llm-local.cloud)
|
||||
[中文](#中文) · [English](#english)
|
||||
|
||||
A Windows/macOS, repository-local distribution that connects the [Pi](https://github.com/earendil-works/pi) agent runtime to the [Pi Web](https://github.com/agegr/pi-web) browser UI and adds a managed plugin/tool ecosystem.
|
||||
A Windows-first, repository-local distribution that connects the [Pi](https://github.com/earendil-works/pi) agent runtime to the [Pi Web](https://github.com/agegr/pi-web) browser UI and adds a managed plugin/tool ecosystem.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆、配置和启动的项目。它保留前后端源码边界,采用前后端分离架构:独立的后端服务(`server/`,常驻 30142 端口)托管 Pi 运行时、Agent 会话与全部 API,Next.js 前端(`pi-web/`,30141 端口)只负责界面并通过代理转发 `/api` 请求——前端热重载或重启不会中断运行中的 Agent 会话。根目录命令完成依赖安装、Pi 构建、插件安装、Profile 初始化和双进程启动。
|
||||
Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆、配置和启动的项目。它保留前后端源码边界,通过根目录命令完成依赖安装、Pi 构建、插件安装、Profile 初始化和 Web 启动。
|
||||
|
||||
### 与两个源项目有什么不同
|
||||
|
||||
@@ -20,26 +20,24 @@ Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆
|
||||
| 默认工作目录 | 当前终端目录 | 在用户主目录创建日期目录 | `data/workspaces/default/` |
|
||||
| 扩展生态 | 支持 Skill、扩展和包 | 提供管理界面 | 固定版本插件、MCP、Skill、提示词和主题形成项目闭环 |
|
||||
| 网络搜索 | 无项目专属搜索 | 无项目专属搜索 | 可选 SearXNG `web_search` 扩展 |
|
||||
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Windows 默认使用 Edge;macOS 默认不启用,按需配置 |
|
||||
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Playwright MCP,使用系统 Edge,按需启动 |
|
||||
| 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 |
|
||||
| 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 |
|
||||
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart、WebUI 流式输出自动跟踪与回到底部按钮 |
|
||||
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows restart |
|
||||
|
||||
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 或 macOS 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
|
||||
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Windows 10/11 或 macOS(Intel/Apple Silicon)
|
||||
- Windows 10/11(当前唯一实机验证平台)
|
||||
- Node.js `22.19.0` 或更高版本
|
||||
- npm 与 Git
|
||||
- 首次安装时可访问 npm registry
|
||||
- Microsoft Edge(仅 Windows 默认 Playwright 浏览器工具需要;macOS 默认不启用 Playwright,不会自动下载浏览器)
|
||||
- Microsoft Edge(仅 Playwright 浏览器工具需要;不会额外下载 Chromium)
|
||||
- 至少一个由用户自行配置的模型供应商、订阅登录或兼容 API
|
||||
|
||||
### 快速开始
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
@@ -47,30 +45,18 @@ 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` 会:
|
||||
|
||||
1. 初始化项目内 `data/` Profile;
|
||||
2. 安装并构建本地 Pi 源码;
|
||||
3. 安装 Pi Web 前端依赖;
|
||||
4. 安装后端服务(`server/`)并链接本地 Pi 包;
|
||||
5. 安装、固定并加载检查默认插件;
|
||||
6. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置;
|
||||
7. 检查前后端版本和构建产物。
|
||||
3. 安装 Pi Web 并连接本地 Pi 包;
|
||||
4. 安装、固定并加载检查默认插件;
|
||||
5. 预缓存 Playwright MCP 包并确认系统 Edge 可用;
|
||||
6. 检查前后端版本和构建产物。
|
||||
|
||||
`pi-smart-fetch` 使用仓库内的修复版,不再直接加载上游 `0.3.17` 发布包。setup 会在 `resources/packages/pi-smart-fetch/` 安装运行时依赖,并自动迁移旧的 `npm:pi-smart-fetch` 配置;该修复为 ESM 构建注入 `createRequire(import.meta.url)`,避免 Node/Next.js 加载 `mime-types` 时触发 `Dynamic require of "path" is not supported`。
|
||||
|
||||
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,Windows PowerShell 设置 `$env:PI_SETUP_USE_PROXY = "1"`,macOS/Linux 使用 `PI_SETUP_USE_PROXY=1 npm run setup`。
|
||||
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,先设置 `$env:PI_SETUP_USE_PROXY = "1"`。
|
||||
|
||||
### 首次模型配置
|
||||
|
||||
@@ -83,24 +69,10 @@ npm run dev
|
||||
|
||||
所有设备相关模型配置都位于被 Git 忽略的 `data/` 或 `.env` 中。
|
||||
|
||||
#### GPT Codex 快速模式
|
||||
|
||||
选择 `openai-codex` 的 GPT-5.5 或 GPT-5.6 模型后,输入框底部会显示闪电按钮。开启后按钮显示 `Fast mode ON`,并将该模型的 `serviceTier` 设为 `priority`,请求会使用 Codex 的快速档位;关闭后恢复普通档位。该设置写入 `data/agent/models.json`,下次请求立即生效,无需重启;非 GPT Codex 模型不会显示此按钮。
|
||||
|
||||
快速档位会增加费用:GPT-5.6 通常约为 2 倍,GPT-5.5 的 priority 费率约为 2.5 倍。Web UI 会在按钮提示中明确显示 `service_tier: priority` 和费用倍率。
|
||||
|
||||
### 可选环境变量
|
||||
|
||||
需要可选服务时,复制模板:
|
||||
|
||||
macOS/Linux:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
@@ -121,13 +93,11 @@ Copy-Item .env.example .env
|
||||
|
||||
```text
|
||||
pi/ Pi 后端、Agent、CLI/TUI 源码
|
||||
server/ 独立后端服务(Hono + Node,托管 Pi 运行时与全部 /api 路由,端口 30142)
|
||||
pi-web/ Next.js 纯前端(界面渲染,/api 经 rewrites 转发到后端,端口 30141)
|
||||
pi-web/ Next.js Web 前端与 HTTP/SSE 服务
|
||||
config/ 可提交的缺省设置、MCP 和子代理策略
|
||||
resources/skills/ 应用级 Skill
|
||||
resources/extensions/ 应用级 Pi 扩展
|
||||
resources/packages/ 项目内置包(含 pi-smart-fetch 修复版)
|
||||
resources/prompts/ 提示词模板
|
||||
resources/prompts/ 提示词模板
|
||||
resources/themes/ 主题
|
||||
scripts/ 安装、启动、迁移与验证脚本
|
||||
data/agent/ 会话、认证、模型、插件、记忆与工具状态
|
||||
@@ -140,77 +110,22 @@ data/workspaces/default/ 默认工作目录
|
||||
|
||||
### 内置插件与工具
|
||||
|
||||
版本固定在 `config/` 下的平台默认配置文件中;Windows 使用 `mcp.default.json`,macOS 使用不含 Playwright 的 `mcp.macos.default.json`。
|
||||
所有版本固定在 `config/settings.default.json` 和 `config/mcp.default.json`。
|
||||
|
||||
| 插件/工具 | 功能 | 自动行为与用法 |
|
||||
| --- | --- | --- |
|
||||
| `pi-mcp-adapter@2.15.0` | 用一个紧凑代理工具接入 MCP | 模型使用 `mcp` 搜索并调用 MCP;`/mcp` 查看状态。外部宿主配置发现默认关闭 |
|
||||
| `@playwright/mcp@0.0.78` | 真实网页导航、点击、表单、快照和截图 | Windows 通过 MCP 按需启动并使用系统 Edge;macOS 默认不安装,需手动通过 MCP 配置启用 |
|
||||
| `@playwright/mcp@0.0.78` | 真实网页导航、点击、表单、快照和截图 | 通过 MCP 按需启动,空闲 5 分钟退出;使用无痕 Headless 系统 Edge |
|
||||
| `pi-lens@3.8.73` | LSP、AST、符号检索和项目诊断 | 模型按需激活代码智能工具;可用 `/lens-health`、`/lens-tools`、`/lens-map` 检查 |
|
||||
| `pi-memory@0.4.0` | Markdown 长期记忆、日志、临时工作区和恢复记录 | 默认轻量模式保留读写、状态和恢复工具,但不注册依赖 qmd 的 `memory_search`;文件位于 `data/agent/memory/` |
|
||||
| `pi-memory@0.4.0` | Markdown 长期记忆、日志、临时工作区和恢复记录 | 模型按需使用 memory 工具;文件位于 `data/agent/memory/`,默认不启用向量索引 |
|
||||
| `pi-subagents@0.37.2` | 创建研究、规划或执行子代理 | 简单任务不委派,跨模块或可并行复杂任务自动判断;也可使用 `/run`、`/parallel` |
|
||||
| `pi-smart-fetch`(项目内修复版,基于 `0.3.17`) | 抓取单个或批量 URL 内容 | 模型按需使用 `web_fetch` / `batch_web_fetch`,也提供给研究子代理;修复 Node/Next.js ESM 加载兼容性 |
|
||||
| `pi-smart-fetch@0.3.17` | 抓取单个或批量 URL 内容 | 模型按需使用 `web_fetch` / `batch_web_fetch`,也提供给研究子代理 |
|
||||
| `@ayulab/pi-rewind@0.4.6` | 每轮前后创建代码检查点 | `/rewind` 恢复代码、会话或两者;`/checkpoint` 管理存储。自动恢复文件默认关闭 |
|
||||
| `@upstash/context7-pi@0.1.2` | 查询当前库、框架、SDK 和 API 文档 | 模型先解析库 ID,再按需查询文档;无 Key 可使用公共限额 |
|
||||
| `@narumitw/pi-retry@0.31.0` | 识别瞬时供应商错误和卡住的流 | 复用 Pi 内置重试;默认 180 秒无事件视为停滞,不增加正常请求的模型调用 |
|
||||
| `resources/extensions/searxng-search.ts` | 用户自有 SearXNG 的 `web_search` | 配置 `SEARXNG_URL` 与 `SEARXNG_TOKEN` 后,模型对时效性或明确搜索请求自动调用 |
|
||||
| `resources/extensions/vision.ts` | 文本主模型(如 DeepSeek)的识图工具 `vision`(双后端) | 派 `vision` 子代理或直接让模型调用工具,返回 OCR/版式/语义文本;后端初始未配置(本地 Ollama 或 OpenAI 兼容视觉 API 任选),需在「视觉」标签页或环境变量中自行设置 |
|
||||
|
||||
Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。
|
||||
|
||||
Windows 下的 `pi-subagents` 子进程由受管启动器自动处理:启动时将本地 `server/node_modules/.bin` 加入子进程 PATH,并把后端服务使用的 `pi-coding-agent` 包链接到受管 Profile,使子代理直接解析本地 `dist/cli.js`。这不会修改系统级 PATH,每次项目启动时会自动恢复。
|
||||
|
||||
#### 视觉子代理(vision)
|
||||
|
||||
DeepSeek 等纯文本模型不能接收图片。仓库内置 `vision` 子代理(`.agents/vision.md`):它通过 `vision` 工具调用视觉模型读取图片,把完整 OCR、版式结构与语义描述返回给主模型,主模型基于文本继续推理。视觉后端可插拔(本地 Ollama 或任意 OpenAI 兼容视觉 API)——仓库**不预设默认后端**,首次使用前需自行选择并配置。
|
||||
|
||||
配置入口:WebUI 左下角「模型」面板内的「视觉」标签页(写入 `data/agent/vision.json`),保存后**下次识图请求立即生效**,无需重启;环境变量优先级高于面板配置。
|
||||
|
||||
> 注意:视觉后端初始**未预设默认值**。未配置时识图会报错并提示配置入口;两种后端二选一即可。
|
||||
|
||||
**后端一:本地 Ollama(免费私密)**
|
||||
|
||||
- 前置:本机安装 [Ollama](https://ollama.com) 并拉取一个支持视觉的模型(如 `ollama pull qwen3-vl:8b`)。
|
||||
- 环境变量:`OLLAMA_HOST`(默认 `http://localhost:11434`)、`OLLAMA_VISION_MODEL`(**必填**,指定视觉模型)、`OLLAMA_VISION_KEEP_ALIVE`(默认 `-1` 常驻显存,避免每次识图冷加载大模型;也可设 `30m` 等时长)。
|
||||
|
||||
**后端二:OpenAI 兼容视觉 API**
|
||||
|
||||
- 设置 `VISION_BACKEND=openai`,并配置 `VISION_OPENAI_BASE_URL`(如 `https://api.openai.com/v1`)、`VISION_OPENAI_API_KEY`、`VISION_OPENAI_MODEL`(如 `gpt-4o-mini`、`glm-4.5v`、`qwen-vl-max`)。
|
||||
|
||||
**自动转录(WebUI 上传即用)**
|
||||
|
||||
纯文本主模型(如 DeepSeek)无法接收图片,直接在 WebUI 上传会让请求失败(DeepSeek API 返回 HTTP 400)。`vision` 扩展注册了 `before_provider_request` 钩子:请求发出前检测到图片附件时,自动调用配置的视觉后端生成文本描述并替换进消息,主模型直接基于描述继续推理——上传即用,无需手动操作。支持图片的主模型则原样透传,不受影响。
|
||||
|
||||
描述按**单张图片**缓存并**持久化到磁盘**(`data/agent/vision-cache.json`,上限 64 条):只有新上传的图片会调用视觉模型,历史图片(含重启后)秒回缓存。自动转录使用**精简模板**(约百字摘要;`vision` 工具仍返回完整 OCR),并显式 `keep_alive: -1` 让模型常驻显存。每轮请求会把历史图片的描述文本一并注入上下文以保持主模型的记忆——上下文体积会随历史图片数增长,属已知取舍(Ollama 端已显式提升 `num_ctx`,DeepSeek 前缀缓存可摊薄费用)。
|
||||
|
||||
- WebUI 上传 JPEG 会自动压缩(长边 >1600px 时缩放至 1600px、质量 0.85):相机照片从数 MB 降到几百 KB,会话文件不膨胀、加载与转录更快;PNG/WebP/GIF 原样保留(无损/动画)。
|
||||
|
||||
- 用法:对主模型说“用 vision 子代理看 <图片路径>”即可;也可 `/run vision`(子代理用于主动深度分析多图;上传自动转录已覆盖日常识图)。
|
||||
- 主会话直用:重启 pi 后 `vision` 工具在主会话也可用,可对磁盘上的图片主动调用。
|
||||
- 单次调用可覆盖后端与模型:工具参数 `backend`、`model`。
|
||||
|
||||
为什么不让 pi 直接连接 Ollama 视觉模型:Ollama 的 OpenAI 兼容端点(`/v1`)会把 qwen3 系列模型的推理内容放进 `reasoning` 字段、`content` 留空,pi 会判定为空回复。Ollama 后端改走原生 `/api/chat` 并传 `think: false` 关闭思考,实测稳定可靠。
|
||||
|
||||
MCP 服务器可通过 Web UI 左下角的 MCP 按钮可视化配置(写入 `data/agent/mcp.json`):支持 stdio(命令 + 参数)与 HTTP(URL + 请求头 + OAuth/Bearer)两种传输、环境变量键值编辑、工作目录、生命周期与超时设置,另保留原始 JSON 编辑兜底。保存后重启 pi(或 /reload)生效。
|
||||
|
||||
#### 插件与扩展
|
||||
|
||||
Web UI 左下角的「插件」和「扩展」是两个独立面板:插件面板管理 npm/git 插件包的安装、更新和启停;扩展面板只展示直接加载的 `.ts`/`.js` 扩展文件,不展示插件包内的资源。扩展面板会按项目、内置和应用范围显示扩展状态、来源与路径;未信任项目中的 `.pi/extensions` 会标记为阻止而不会执行。共享扩展放在 `resources/extensions/`,项目扩展放在项目的 `.pi/extensions/`,全局扩展位于 Pi Profile 的 `extensions/` 目录。
|
||||
|
||||
Web UI 右上角的会话信息栏会汇总 Token 使用情况;当模型返回缓存读写数据时,还会显示按 Token 加权计算的缓存命中率:`cacheRead / (input + cacheRead + cacheWrite)`,不计输出 Token。
|
||||
|
||||
#### 记忆模式
|
||||
|
||||
默认设置 `PI_MEMORY_NO_SEARCH=1` 和 `PI_MEMORY_QMD_UPDATE=off`。这不会削弱 Markdown 记忆、每日日志、scratchpad 或恢复记录,但会跳过 qmd 探测、安装提示和 `memory_search` 工具,因此全新安装不会自动下载 qmd 的本地模型。集成补丁会在 `setup` 以及每次启动前自动检查并重放,受管插件重装后无需手工修改。
|
||||
|
||||
如果确实需要跨全部记忆文件的关键词、语义或深度搜索,请先自行安装并配置 qmd,然后在 `.env` 中设置:
|
||||
|
||||
```dotenv
|
||||
PI_MEMORY_NO_SEARCH=0
|
||||
PI_MEMORY_QMD_UPDATE=background
|
||||
```
|
||||
|
||||
随后运行 `npm run memory:configure` 或直接重启应用。qmd 及其模型不随本仓库分发。
|
||||
Playwright 不下载独立 Chromium。首次 `setup` 只缓存 MCP 的 Node.js 包;浏览器执行使用系统 Edge。
|
||||
|
||||
### Profile、迁移和自定义扩展
|
||||
|
||||
@@ -236,9 +151,8 @@ npm run profile:migrate -- --from D:\old-pi\agent --skills-from D:\old-skills
|
||||
```powershell
|
||||
npm run setup # 完整安装、构建和 Profile 插件校验
|
||||
npm run profile:packages # 安装缺失或版本不匹配的受管插件
|
||||
npm run memory:configure # 重新应用受管 pi-memory 轻量集成
|
||||
npm run dev # 127.0.0.1:30141 开发服务
|
||||
npm run restart # Windows/macOS:停止本项目旧开发进程并重新启动
|
||||
npm run restart # Windows:停止本项目旧开发进程并重新启动
|
||||
npm run dev:lan # 局域网监听;仅在可信网络使用
|
||||
npm run build # 构建 Pi Web 生产产物
|
||||
npm run start # 启动已构建的本机生产服务
|
||||
@@ -262,7 +176,6 @@ npm run smoke:search -- "关键词" # 使用真实 SearXNG;需要配置
|
||||
- Rewind 检查点包含大量松散 Git 对象时自动执行压缩,但保留所有有效检查点;
|
||||
- 已找不到对应会话的孤儿 Rewind 检查点保留 7 天后自动删除;
|
||||
- 会话、记忆、凭据、模型配置、已安装插件和仍有关联的检查点不会被自动删除。
|
||||
- 手动清理:`node scripts/cleanup-cache.mjs`(默认预览,加 `--apply` 执行)——清 npm/opengrep 缓存、按天数移走过期会话、把旧会话图片替换为占位符瘦身;删除先进回收目录。
|
||||
|
||||
```powershell
|
||||
npm run storage:status # 只查看受管缓存大小
|
||||
@@ -283,7 +196,7 @@ npm run storage:clean # 清空可重建缓存并删除全部孤儿检查点
|
||||
|
||||
## English
|
||||
|
||||
Pi Agent Integrated combines the Pi backend and Pi Web frontend into one independently cloneable and runnable repository. It preserves separate source boundaries and uses a decoupled frontend/backend architecture: a standalone backend service (`server/`, port 30142) hosts the Pi runtime, agent sessions, and every API route, while the Next.js frontend (`pi-web/`, port 30141) renders the UI and proxies `/api` requests to the backend — frontend hot reloads and restarts never interrupt running agent sessions. Root commands handle dependency installation, Pi builds, managed-profile creation, plugin installation, and dual-process startup.
|
||||
Pi Agent Integrated combines the Pi backend and Pi Web frontend into one independently cloneable and runnable repository. It preserves separate source boundaries while root commands handle dependency installation, Pi builds, managed-profile creation, plugin installation, and Web startup.
|
||||
|
||||
### How it differs from the two upstream projects
|
||||
|
||||
@@ -295,27 +208,25 @@ Pi Agent Integrated combines the Pi backend and Pi Web frontend into one indepen
|
||||
| Default workspace | Current terminal directory | Creates a dated home directory | `data/workspaces/default/` |
|
||||
| Extension ecosystem | Supports skills, extensions, packages | Management UI | Pinned plugins, MCP, skills, prompts, and themes form a managed ecosystem |
|
||||
| Web search | No project-specific search | No project-specific search | Optional SearXNG `web_search` extension |
|
||||
| Browser automation | User-integrated | No default browser MCP | Windows uses Edge by default; macOS leaves Playwright opt-in |
|
||||
| Browser automation | User-integrated | No default browser MCP | Lazy Playwright MCP using system Edge |
|
||||
| Recovery and durable context | Session tree and core state | Session UI | Rewind checkpoints, repository-local memory, automatic retry |
|
||||
| Multi-agent workflow | Extensible core | Renders tool calls | Automatic complexity policy for `pi-subagents` |
|
||||
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows/macOS restart, and auto-follows streaming output with a scroll-to-bottom button in the Web UI |
|
||||
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows restart |
|
||||
|
||||
This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows or macOS user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line.
|
||||
This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Windows 10/11 or macOS (Intel/Apple Silicon)
|
||||
- Windows 10/11, the only currently validated platform
|
||||
- Node.js `22.19.0` or newer
|
||||
- npm and Git
|
||||
- npm registry access during initial setup
|
||||
- Microsoft Edge for the Windows default Playwright browser; macOS leaves Playwright disabled by default and downloads no browser
|
||||
- Microsoft Edge for Playwright browser tools; no separate Chromium is downloaded
|
||||
- At least one user-configured model provider, subscription login, or compatible API
|
||||
|
||||
### Quick start
|
||||
|
||||
Windows PowerShell or macOS Terminal:
|
||||
|
||||
```bash
|
||||
```powershell
|
||||
git clone https://github.com/luckyyzh/pi-agent-integrated.git
|
||||
cd pi-agent-integrated
|
||||
npm run setup
|
||||
@@ -324,11 +235,9 @@ npm run dev
|
||||
|
||||
Open <http://127.0.0.1:30141>.
|
||||
|
||||
Setup initializes the repository-local profile, builds Pi, installs the Pi Web frontend dependencies, installs the backend service (`server/`) with links to the local Pi packages, installs and loads the pinned plugins, installs the repository-local repaired `pi-smart-fetch` dependencies, caches the Playwright MCP Node package and verifies system Edge on Windows, skips Playwright on macOS, and checks integration artifacts.
|
||||
Setup initializes the repository-local profile, builds Pi, links Pi Web to the local packages, installs and loads the pinned plugins, caches the Playwright MCP Node package without downloading a browser, verifies system Edge, and checks integration artifacts.
|
||||
|
||||
The repaired `pi-smart-fetch` build is based on upstream `0.3.17`; it adds `createRequire(import.meta.url)` for the bundled CommonJS dependencies so Node/Next.js does not fail with `Dynamic require of "path" is not supported`. Existing managed profiles are migrated from `npm:pi-smart-fetch` to `resources/packages/pi-smart-fetch` automatically.
|
||||
|
||||
Child npm operations ignore `HTTP_PROXY` and `HTTPS_PROXY` by default to avoid stale proxy configuration. Set `$env:PI_SETUP_USE_PROXY = "1"` on Windows, or run `PI_SETUP_USE_PROXY=1 npm run setup` on macOS/Linux, when registry access requires the proxy.
|
||||
Child npm operations ignore `HTTP_PROXY` and `HTTPS_PROXY` by default to avoid stale proxy configuration. Set `$env:PI_SETUP_USE_PROXY = "1"` first when registry access requires the proxy.
|
||||
|
||||
### First model configuration
|
||||
|
||||
@@ -341,22 +250,8 @@ The repository contains no author model endpoint, API key, OAuth token, or defau
|
||||
|
||||
Device-specific model configuration remains in ignored `data/` or `.env` files.
|
||||
|
||||
#### GPT Codex fast mode
|
||||
|
||||
When an `openai-codex` GPT-5.5 or GPT-5.6 model is selected, the lightning button appears beside the model selector. Enabling it shows `Fast mode ON` and sets that model's `serviceTier` to `priority`, using Codex's faster service tier; disabling it restores the default tier. The setting is written to `data/agent/models.json` and takes effect on the next request without a restart. Non-Codex models do not show the button.
|
||||
|
||||
Fast mode costs more: GPT-5.6 is typically about 2x, while GPT-5.5 priority pricing is about 2.5x. The Web UI tooltip displays `service_tier: priority` and the applicable credit multiplier.
|
||||
|
||||
### Optional environment configuration
|
||||
|
||||
macOS/Linux:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
@@ -377,13 +272,11 @@ Never commit `.env`, `data/`, real credentials, or screenshots containing creden
|
||||
|
||||
```text
|
||||
pi/ Pi backend, agent, and CLI/TUI source
|
||||
server/ Standalone backend service (Hono + Node; hosts the Pi runtime and all /api routes; port 30142)
|
||||
pi-web/ Next.js frontend only (UI rendering; /api rewritten to the backend; port 30141)
|
||||
pi-web/ Next.js frontend and HTTP/SSE service
|
||||
config/ Versioned settings, MCP, and subagent policy
|
||||
resources/skills/ Application skills
|
||||
resources/extensions/ Application Pi extensions
|
||||
resources/packages/ Repository-local packages (including repaired pi-smart-fetch)
|
||||
resources/prompts/ Prompt templates
|
||||
resources/prompts/ Prompt templates
|
||||
resources/themes/ Themes
|
||||
scripts/ Setup, launch, migration, and verification
|
||||
data/agent/ Sessions, auth, models, packages, memory, tools
|
||||
@@ -396,77 +289,22 @@ data/workspaces/default/ Default workspace
|
||||
|
||||
### Included plugins and tools
|
||||
|
||||
Versions are pinned in the platform defaults under `config/`: Windows uses `mcp.default.json`, while macOS uses `mcp.macos.default.json` without Playwright.
|
||||
Versions are pinned in `config/settings.default.json` and `config/mcp.default.json`.
|
||||
|
||||
| Plugin/tool | Function | Automatic behavior and usage |
|
||||
| --- | --- | --- |
|
||||
| `pi-mcp-adapter@2.15.0` | Compact MCP proxy | The model searches and invokes MCP through `mcp`; `/mcp` shows status. Host-config discovery is off |
|
||||
| `@playwright/mcp@0.0.78` | Real navigation, clicks, forms, snapshots, screenshots | Windows starts it on demand with isolated headless system Edge; macOS does not install it by default and requires manual MCP configuration |
|
||||
| `@playwright/mcp@0.0.78` | Real navigation, clicks, forms, snapshots, screenshots | Starts on demand, exits after five idle minutes, uses isolated headless system Edge |
|
||||
| `pi-lens@3.8.73` | LSP, AST, symbols, project diagnostics | The model activates code intelligence on demand; inspect with `/lens-health`, `/lens-tools`, `/lens-map` |
|
||||
| `pi-memory@0.4.0` | Markdown durable facts, logs, scratchpad, recovery | Lightweight mode retains read/write, status, and recovery tools but does not register qmd-dependent `memory_search`; files live under `data/agent/memory/` |
|
||||
| `pi-memory@0.4.0` | Markdown durable facts, logs, scratchpad, recovery | The model uses memory tools on demand; files live under `data/agent/memory/`; vector indexing is off by default |
|
||||
| `pi-subagents@0.37.2` | Research, planning, execution subagents | Simple tasks stay local; complex or parallel work may delegate automatically; `/run` and `/parallel` remain available |
|
||||
| `pi-smart-fetch` (project-local repair, based on `0.3.17`) | Single and batched URL retrieval | Provides `web_fetch` and `batch_web_fetch` to the main and research agents; fixes Node/Next.js ESM loading compatibility |
|
||||
| `pi-smart-fetch@0.3.17` | Single and batched URL retrieval | Provides `web_fetch` and `batch_web_fetch` to the main and research agents |
|
||||
| `@ayulab/pi-rewind@0.4.6` | Per-turn code checkpoints | `/rewind` restores code, conversation, or both; `/checkpoint` manages storage. Automatic file restore is off |
|
||||
| `@upstash/context7-pi@0.1.2` | Current library, framework, SDK, API docs | Resolves a library ID and queries docs when needed; public quota works without a key |
|
||||
| `@narumitw/pi-retry@0.31.0` | Transient provider and stalled-stream classification | Uses Pi's built-in retry path; 180 seconds without events is a stall; no extra normal model calls |
|
||||
| `resources/extensions/searxng-search.ts` | `web_search` against a user-owned SearXNG proxy | After `SEARXNG_URL` and `SEARXNG_TOKEN` are set, the model calls it for current or explicit search requests |
|
||||
| `resources/extensions/vision.ts` | `vision` — image description for text-only models (e.g. DeepSeek), dual backend | Ask the `vision` subagent or call the tool directly; returns OCR/layout/semantics as text; no backend is preconfigured (local Ollama or any OpenAI-compatible vision API) — set one in the Vision tab or via env vars |
|
||||
|
||||
On Windows, Playwright never downloads a standalone Chromium: setup caches only its Node package and browser execution uses system Edge. On macOS, setup does not install or enable Playwright; add it manually through the MCP panel if browser automation is needed.
|
||||
|
||||
On Windows, the managed launcher prepares `pi-subagents` child processes automatically: it prepends the local `server/node_modules/.bin` directory to the child PATH and links the backend's `pi-coding-agent` package into the managed profile, allowing subagents to resolve the local `dist/cli.js` directly. This does not modify the system-wide PATH and is recreated on each project launch.
|
||||
|
||||
#### Vision subagent
|
||||
|
||||
Text-only models such as DeepSeek cannot receive image attachments. The repository ships a `vision` subagent (`.agents/vision.md`) that calls a vision model through the `vision` tool and returns a full OCR, layout, and semantic description the main model can reason over. The vision backend is pluggable (local Ollama or any OpenAI-compatible vision API) — the repository does **not** ship a default backend; pick and configure one before first use.
|
||||
|
||||
Configuration: the “Vision” tab inside the “Models” panel in the lower-left Web UI (writes `data/agent/vision.json`). Saved config takes effect on the **next image request** — no restart needed; environment variables take precedence over the panel.
|
||||
|
||||
> Note: no backend is preconfigured by default. Image requests fail with a configuration hint until you pick one — choose either backend below.
|
||||
|
||||
**Backend 1: local Ollama (free and private)**
|
||||
|
||||
- Prerequisite: install [Ollama](https://ollama.com) and pull a vision-capable model (e.g. `ollama pull qwen3-vl:8b`).
|
||||
- Env: `OLLAMA_HOST` (default `http://localhost:11434`), `OLLAMA_VISION_MODEL` (**required** — the vision model), `OLLAMA_VISION_KEEP_ALIVE` (default `-1` — keep the model resident in VRAM to avoid cold-loading it on every transcription; can be set to e.g. `30m`).
|
||||
|
||||
**Backend 2: OpenAI-compatible vision API**
|
||||
|
||||
- Set `VISION_BACKEND=openai` and configure `VISION_OPENAI_BASE_URL` (e.g. `https://api.openai.com/v1`), `VISION_OPENAI_API_KEY`, `VISION_OPENAI_MODEL` (e.g. `gpt-4o-mini`, `glm-4.5v`, `qwen-vl-max`).
|
||||
|
||||
**Automatic transcription (upload-and-go)**
|
||||
|
||||
A text-only main model such as DeepSeek cannot receive images — uploading one in the Web UI fails the request (DeepSeek API returns HTTP 400). The `vision` extension registers a `before_provider_request` hook: when it detects image attachments, it transcribes them through the configured vision backend and replaces them with text before the request is sent, so the main model keeps reasoning seamlessly. Vision-capable main models pass through untouched.
|
||||
|
||||
Descriptions are cached **per image** and **persisted to disk** (`data/agent/vision-cache.json`, capped at 64 entries): only genuinely new uploads call the vision model, while previously seen images — including after a restart — resolve from cache instantly. The automatic transcription pipeline uses a **concise prompt** (~100-character summary; the `vision` tool still returns full OCR) and sends `keep_alive: -1` so the model stays resident in VRAM. Every request also re-injects the accumulated image descriptions so the main model keeps its memory of them — a known trade-off where context grows with the number of images (the Ollama backend raises `num_ctx` explicitly, and DeepSeek prefix caching keeps the cost modest).
|
||||
|
||||
- Web UI uploads auto-compress JPEGs (downscaled to 1600px long edge at quality 0.85 when larger): camera photos drop from several MB to a few hundred KB, so session files stop growing and load/transcribe faster; PNG/WebP/GIF pass through untouched (lossless/animated).
|
||||
|
||||
- Usage: ask the main model to “use the vision subagent to look at <path>”, or run `/run vision` (the subagent is for proactive deep analysis of many images; everyday image reading is covered by automatic transcription).
|
||||
- Main-session use: after restarting pi, the `vision` tool is also available in the main session for images on disk.
|
||||
- Per-call overrides: tool parameters `backend` and `model`.
|
||||
|
||||
Why not point pi directly at an Ollama vision model: Ollama's OpenAI-compatible `/v1` endpoint moves qwen3-family reasoning into the `reasoning` field with an empty `content`, which pi treats as an empty reply. The Ollama backend uses the native `/api/chat` with `think: false` instead, which works reliably.
|
||||
|
||||
MCP servers can be configured visually from the MCP button in the lower-left Web UI (writes `data/agent/mcp.json`): stdio (command + args) or HTTP (URL + headers + OAuth/Bearer) transport, environment-variable row editing, working directory, lifecycle and timeout options, plus raw JSON editing as a fallback. Changes take effect after restarting pi (or /reload).
|
||||
|
||||
#### Plugins and extensions
|
||||
|
||||
The lower-left Web UI has separate “Plugins” and “Extensions” panels. The Plugins panel manages npm/git plugin packages, including install, update, enable, and disable actions. The Extensions panel only lists directly loaded `.ts`/`.js` extension files and never lists resources supplied by plugin packages. It groups extensions by project, built-in, and app scope and shows their status, source, and path; extensions in an untrusted project `.pi/extensions` directory are shown as blocked and are not executed. Shared extensions belong in `resources/extensions/`, project extensions in `.pi/extensions/`, and global extensions in the Pi Profile `extensions/` directory.
|
||||
|
||||
The Web UI session-info panel in the upper-right summarizes Token usage. When a model reports cache read/write data, it also shows the token-weighted cache hit rate: `cacheRead / (input + cacheRead + cacheWrite)`, excluding output Tokens.
|
||||
|
||||
#### Memory modes
|
||||
|
||||
The managed launcher defaults to `PI_MEMORY_NO_SEARCH=1` and `PI_MEMORY_QMD_UPDATE=off`. Markdown memory, daily logs, scratchpad, and recovery stay available, while qmd detection, its installation notice, and the `memory_search` tool are skipped. A deterministic integration patch is checked during setup and before every launch, so reinstalling managed packages needs no manual repair.
|
||||
|
||||
To opt into keyword, semantic, or deep search across every memory file, install and configure qmd separately, then add the following to `.env`:
|
||||
|
||||
```dotenv
|
||||
PI_MEMORY_NO_SEARCH=0
|
||||
PI_MEMORY_QMD_UPDATE=background
|
||||
```
|
||||
|
||||
Run `npm run memory:configure` or restart the application afterward. qmd and its local models are not bundled with this repository.
|
||||
Playwright never downloads a standalone Chromium in this project. Setup caches only its Node package; browser execution uses system Edge.
|
||||
|
||||
### Profile, migration, and extension
|
||||
|
||||
@@ -485,9 +323,8 @@ Migration copies without deleting source data and removes absolute resource path
|
||||
```powershell
|
||||
npm run setup # Full install, build, and managed-profile validation
|
||||
npm run profile:packages # Install missing or version-mismatched managed plugins
|
||||
npm run memory:configure # Reapply the managed pi-memory lightweight integration
|
||||
npm run dev # Development server on 127.0.0.1:30141
|
||||
npm run restart # Windows/macOS: stop this project's old dev process and restart
|
||||
npm run restart # Windows: stop this project's old dev process and restart
|
||||
npm run dev:lan # Listen on the LAN; trusted networks only
|
||||
npm run build # Build the Pi Web production output
|
||||
npm run start # Start an existing local production build
|
||||
@@ -511,7 +348,6 @@ 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
|
||||
|
||||
+2
-4
@@ -1,7 +1,5 @@
|
||||
# Managed profile defaults
|
||||
|
||||
`settings.default.json` and `models.example.json` seed a new local profile.
|
||||
`mcp.default.json` is the Windows MCP profile; `mcp.macos.default.json` leaves
|
||||
Playwright opt-in on macOS. The launcher copies the platform-appropriate files
|
||||
into `data/agent/` only when the destination is missing, so upgrades never
|
||||
overwrite a user's settings, model configuration, or custom MCP configuration.
|
||||
The launcher copies them into `data/agent/` only when the destination file is
|
||||
missing, so upgrades never overwrite a user's settings or model configuration.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"settings": {
|
||||
"hostConfigDiscovery": "off",
|
||||
"idleTimeout": 5
|
||||
},
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
"npm:pi-mcp-adapter@2.15.0",
|
||||
"npm:pi-lens@3.8.73",
|
||||
"npm:pi-memory@0.4.0",
|
||||
"../../resources/packages/pi-smart-fetch",
|
||||
"npm:pi-subagents@0.37.2",
|
||||
"npm:pi-smart-fetch@0.3.17",
|
||||
"npm:@ayulab/pi-rewind@0.4.6",
|
||||
"npm:@upstash/context7-pi@0.1.2",
|
||||
"npm:@narumitw/pi-retry@0.31.0"
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('status', 'start', 'restart', 'stop')]
|
||||
[string]$Action = 'status',
|
||||
[switch]$NoTunnel
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$webTaskName = 'PiAgentIntegratedWeb'
|
||||
$tunnelTaskName = 'PiAgentIntegratedTunnel'
|
||||
$localUrl = 'http://127.0.0.1:30141/'
|
||||
# local-tools 位于 data/ 下,上两级才是项目根
|
||||
$projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
# 加载项目 .env(独立运行的脚本不会自动加载,需手动解析)
|
||||
$envFile = Join-Path $projectRoot '.env'
|
||||
if (Test-Path $envFile) {
|
||||
Get-Content $envFile -Encoding UTF8 | ForEach-Object {
|
||||
if ($_ -match '^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$') {
|
||||
$envName = $matches[1]
|
||||
$envValue = $matches[2].Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($envValue) -and -not [Environment]::GetEnvironmentVariable($envName, 'Process')) {
|
||||
Set-Item -Path "Env:$envName" -Value $envValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 隧道命令从环境变量读取(.env 中设置 PI_AGENT_TUNNEL_COMMAND),例如:
|
||||
# C:\WINDOWS\System32\OpenSSH\ssh.exe -n -N -T -o BatchMode=yes -R 127.0.0.1:8090:127.0.0.1:30141 aliyun
|
||||
$tunnelCommand = $env:PI_AGENT_TUNNEL_COMMAND
|
||||
# 公网地址从环境变量读取(.env 中设置 PI_AGENT_PUBLIC_URL),不在代码里写死个人域名
|
||||
$publicUrl = $env:PI_AGENT_PUBLIC_URL
|
||||
if ([string]::IsNullOrWhiteSpace($publicUrl)) {
|
||||
$publicUrl = '(未配置 PI_AGENT_PUBLIC_URL)'
|
||||
}
|
||||
|
||||
function Ensure-Tasks {
|
||||
# Web supervisor 任务:必需,总是用最新定义覆盖注册(-Force 不中断运行中的实例)
|
||||
Write-Host " 注册/更新计划任务 $webTaskName ..."
|
||||
$supervisorPath = Join-Path $PSScriptRoot 'pi-agent-web-supervisor.ps1'
|
||||
# 单引号拼接避免转义歧义(`\" 不是 PowerShell 转义引号)
|
||||
$argument = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $supervisorPath + '"'
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argument -WorkingDirectory $projectRoot
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||
# 显式允许电池运行(默认 DisallowStartIfOnBatteries=True 会导致笔记本不插电时任务 Queued 不启动)
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
|
||||
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||
-DisallowStartIfOnBatteries:$false -StopIfGoingOnBatteries:$false
|
||||
# -Force 总是用最新定义覆盖(含设置修正),不中断已运行实例
|
||||
Register-ScheduledTask -TaskName $webTaskName -Action $action -Trigger $trigger `
|
||||
-Settings $settings -Description 'Pi Agent Integrated Web supervisor' -Force | Out-Null
|
||||
|
||||
# Tunnel 任务:可选,仅在配置了 PI_AGENT_TUNNEL_COMMAND 时注册;-NoTunnel 明确跳过
|
||||
if ($NoTunnel) {
|
||||
Write-Host " - 已跳过隧道任务(-NoTunnel)"
|
||||
return
|
||||
}
|
||||
if (Get-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue) { return }
|
||||
if ([string]::IsNullOrWhiteSpace($tunnelCommand)) {
|
||||
Write-Host " - 未配置 PI_AGENT_TUNNEL_COMMAND,跳过隧道任务"
|
||||
return
|
||||
}
|
||||
Write-Host " 注册计划任务 $tunnelTaskName ..."
|
||||
$parts = $tunnelCommand.Trim() -split '\s+', 2
|
||||
$action = New-ScheduledTaskAction -Execute $parts[0] -Argument $parts[1]
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
|
||||
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||
-DisallowStartIfOnBatteries:$false -StopIfGoingOnBatteries:$false
|
||||
Register-ScheduledTask -TaskName $tunnelTaskName -Action $action -Trigger $trigger `
|
||||
-Settings $settings -Description 'Pi Agent Integrated public tunnel' -Force | Out-Null
|
||||
}
|
||||
|
||||
function Test-IsAdmin {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-PublicAccessStatus {
|
||||
$webTask = Get-ScheduledTask -TaskName $webTaskName -ErrorAction SilentlyContinue
|
||||
$tunnelTask = Get-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue
|
||||
$listener = Get-NetTCPConnection -State Listen -LocalPort 30141 -ErrorAction SilentlyContinue
|
||||
|
||||
$httpStatus = 'unavailable'
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $localUrl -Method Head -TimeoutSec 10
|
||||
$httpStatus = [string][int]$response.StatusCode
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Response) {
|
||||
$httpStatus = [string][int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
WebTask = if ($webTask) { [string]$webTask.State } else { 'missing' }
|
||||
TunnelTask = if ($tunnelTask) { [string]$tunnelTask.State } else { 'missing' }
|
||||
Port30141 = if ($listener) { 'listening' } else { 'closed' }
|
||||
LocalHttp = $httpStatus
|
||||
PublicUrl = $publicUrl
|
||||
} | Format-List
|
||||
}
|
||||
|
||||
switch ($Action) {
|
||||
'status' {
|
||||
Get-PublicAccessStatus
|
||||
}
|
||||
'start' {
|
||||
# 注册计划任务需要管理员权限;非管理员时自动提权重启
|
||||
if (-not (Test-IsAdmin)) {
|
||||
$argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", 'start')
|
||||
if ($NoTunnel) { $argList += '-NoTunnel' }
|
||||
Write-Host "注册计划任务需要管理员权限,正在提权重启(请确认 UAC 提示)..."
|
||||
Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $argList -WorkingDirectory $projectRoot
|
||||
exit
|
||||
}
|
||||
Ensure-Tasks
|
||||
if (-not $NoTunnel) { Start-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue }
|
||||
Start-ScheduledTask -TaskName $webTaskName
|
||||
Start-Sleep -Seconds 3
|
||||
Get-PublicAccessStatus
|
||||
}
|
||||
'restart' {
|
||||
Stop-ScheduledTask -TaskName $webTaskName -ErrorAction SilentlyContinue
|
||||
Stop-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
if (-not $NoTunnel) { Start-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue }
|
||||
Start-ScheduledTask -TaskName $webTaskName
|
||||
Start-Sleep -Seconds 3
|
||||
Get-PublicAccessStatus
|
||||
}
|
||||
'stop' {
|
||||
Stop-ScheduledTask -TaskName $webTaskName -ErrorAction SilentlyContinue
|
||||
Stop-ScheduledTask -TaskName $tunnelTaskName -ErrorAction SilentlyContinue
|
||||
Get-PublicAccessStatus
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$logsDirectory = Join-Path $projectRoot 'data\logs'
|
||||
$standardOutputLog = Join-Path $logsDirectory 'pi-web-service.out.log'
|
||||
$standardErrorLog = Join-Path $logsDirectory 'pi-web-service.err.log'
|
||||
$supervisorLog = Join-Path $logsDirectory 'pi-web-supervisor.log'
|
||||
$restartRequestPath = Join-Path $projectRoot 'data\agent\restart-request.json'
|
||||
$webUrl = 'http://127.0.0.1:30141/'
|
||||
$restartRequestVersion = 1
|
||||
$maxResumeAttempts = 3
|
||||
|
||||
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" -Encoding UTF8
|
||||
|
||||
function Write-SupervisorLog {
|
||||
param([string]$Message)
|
||||
|
||||
Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message" -Encoding UTF8
|
||||
}
|
||||
|
||||
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 Archive-RestartRequest {
|
||||
param([object]$Request)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $restartRequestPath -PathType Leaf)) {
|
||||
return
|
||||
}
|
||||
|
||||
$requestId = (Get-RequestField $Request 'requestId') -replace '[^A-Za-z0-9-]', '_'
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$archivePath = Join-Path $logsDirectory "failed-restart-request-$timestamp-$requestId.json"
|
||||
Move-Item -LiteralPath $restartRequestPath -Destination $archivePath -Force
|
||||
Write-SupervisorLog "archived failed restart request at $archivePath"
|
||||
}
|
||||
|
||||
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
|
||||
$resumeAttemptCount = 0
|
||||
|
||||
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
|
||||
$resumeAttemptCount = 0
|
||||
}
|
||||
catch {
|
||||
$resumeAttemptCount += 1
|
||||
Write-SupervisorLog "Agent resume failed: $($_.Exception.Message)"
|
||||
if ($resumeAttemptCount -ge $maxResumeAttempts) {
|
||||
Write-SupervisorLog "Agent resume abandoned after $resumeAttemptCount attempts"
|
||||
Archive-RestartRequest $pendingRequest
|
||||
$pendingRequest = $null
|
||||
$resumeAttemptCount = 0
|
||||
}
|
||||
else {
|
||||
$nextResumeAttempt = (Get-Date).AddSeconds(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $pendingRequest) {
|
||||
$candidate = Get-RestartRequest
|
||||
if ($candidate) {
|
||||
$pendingRequest = $candidate
|
||||
$resumeAttemptCount = 0
|
||||
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
|
||||
}
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pi-agent-integrated",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Integrated local development launcher for Pi and Pi Web",
|
||||
"license": "MIT",
|
||||
@@ -12,14 +12,13 @@
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"profile:init": "node scripts/init-profile.mjs",
|
||||
"profile:packages": "node scripts/install-managed-packages.mjs",
|
||||
"memory:configure": "node scripts/configure-pi-memory.mjs",
|
||||
"profile:migrate": "node scripts/migrate-profile.mjs",
|
||||
"check": "node scripts/check-integration.mjs",
|
||||
"check:profile": "node scripts/check-managed-profile.mjs",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"smoke:fresh-profile": "node scripts/smoke-fresh-profile.mjs",
|
||||
"smoke:isolation": "node scripts/smoke-isolation.mjs",
|
||||
"smoke:search": "node server/test/searxng-smoke.mjs",
|
||||
"smoke:search": "node pi-web/scripts/searxng-smoke.mjs",
|
||||
"predev": "npm run check",
|
||||
"dev": "node scripts/run.mjs dev",
|
||||
"restart": "node scripts/restart.mjs",
|
||||
@@ -30,9 +29,9 @@
|
||||
"storage:status": "node scripts/storage-maintenance.mjs status",
|
||||
"storage:maintain": "node scripts/storage-maintenance.mjs auto",
|
||||
"storage:clean": "node scripts/storage-maintenance.mjs clean",
|
||||
"typecheck": "npm --prefix server run typecheck && node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json",
|
||||
"typecheck": "node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json",
|
||||
"lint": "npm --prefix pi-web run lint",
|
||||
"test:managed": "node --test scripts/configure-pi-memory.test.mjs scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs server/test/searxng-extension.test.mjs server/src/lib/app-runtime.test.mjs server/src/lib/bash-output.test.mjs server/src/lib/bounded-form-data.test.mjs server/src/lib/custom-ui-terminal.test.mjs server/src/lib/directory-browser.test.mjs server/src/lib/file-access.test.mjs server/src/lib/file-dirent.test.mjs server/src/lib/file-upload.test.mjs server/src/lib/git-changes.test.mjs server/src/lib/models-cache.test.mjs server/src/lib/project-trust.test.mjs server/src/lib/request-security.test.mjs server/src/lib/rpc-manager.test.mjs server/src/lib/session-file-references.test.mjs server/src/lib/session-path.test.mjs server/src/lib/session-reader.test.mjs server/src/lib/session-title.test.mjs server/src/lib/skill-lock.test.mjs server/src/lib/skill-updates.test.mjs"
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
||||
+55
-34
@@ -15,51 +15,72 @@ Lint: `npm run lint`
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser Next.js frontend Pi Agent Server
|
||||
│ │ │
|
||||
├─ GET/POST /api/* ────▶│ rewrite to :30142 ─────▶│ Hono routes
|
||||
├─ SSE connect ────────▶│ transparent proxy ─────▶│ AgentSession events
|
||||
│◀── data: {...} ───────│◀────────────────────────│
|
||||
Browser Next.js Server AgentSession (in-process)
|
||||
│ │ │
|
||||
├─ GET /api/sessions ────▶ reads ~/.pi/agent/sessions/ │
|
||||
├─ GET /api/sessions/[id] reads .jsonl file directly │
|
||||
├─ GET /api/agent/running/events ───▶ running id SSE │
|
||||
│ │ │
|
||||
├─ send message ─────────▶ POST /api/agent/[id] │
|
||||
│ │ startRpcSession() ─────────▶│ createAgentSession()
|
||||
│ │ session.send(cmd) ─────────▶│ session.prompt()
|
||||
│ │ │
|
||||
├─ SSE connect ──────────▶ GET /api/agent/[id]/events │
|
||||
│ │ session.onEvent() ◀─────────│ session.subscribe()
|
||||
│◀── data: {...} ─────────│ │
|
||||
```
|
||||
|
||||
`pi-web/` owns only React/Next.js UI code. All filesystem access, authentication,
|
||||
configuration, Pi packages, API routes, SSE streams, and AgentSession state live
|
||||
in `../server/`. The frontend proxies `/api/*` through `next.config.ts`; restarting
|
||||
or hot-reloading Next.js must not interrupt an active Agent session.
|
||||
|
||||
**Session browsing** (read-only): the backend reads `.jsonl` files through SDK
|
||||
`SessionManager` helpers and `server/src/lib/session-reader.ts`.
|
||||
**Sending a message**: `startRpcSession()` in `server/src/lib/rpc-manager.ts`
|
||||
creates and retains the AgentSession in the standalone backend process.
|
||||
**Session browsing** (read-only): reads `.jsonl` files through SDK `SessionManager` helpers and `lib/session-reader.ts` — no AgentSession created.
|
||||
**Sending a message**: `startRpcSession()` in `lib/rpc-manager.ts` creates an AgentSession in-process.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
../server/src/
|
||||
index.ts Hono server, health endpoint, route registration
|
||||
security.ts host/origin gate for every /api request
|
||||
routes/agent.ts Agent creation, commands, state, and SSE
|
||||
routes/auth.ts OAuth/device-code and API-key routes
|
||||
routes/config.ts extensions, MCP, vision, and plugin configuration
|
||||
routes/files.ts guarded file reading, preview, download, and upload
|
||||
routes/misc.ts cwd, runtime, file index, worktrees, and project trust
|
||||
routes/models.ts Git state, models, discovery, catalog, and tests
|
||||
routes/sessions.ts session listing, context, naming, export, and deletion
|
||||
routes/skills.ts skill listing, search, install, and update
|
||||
lib/rpc-manager.ts AgentSessionWrapper registry and startRpcSession()
|
||||
lib/session-reader.ts SessionManager wrappers and session path cache
|
||||
app/api/
|
||||
sessions/route.ts GET list all sessions
|
||||
sessions/[id]/route.ts GET/PATCH/DELETE session
|
||||
sessions/[id]/context/route.ts GET ?leafId= — context for a specific leaf
|
||||
sessions/[id]/export/route.ts GET exported HTML for a session
|
||||
agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? }
|
||||
agent/[id]/route.ts GET state | POST any command
|
||||
agent/[id]/events/route.ts GET SSE stream
|
||||
agent/running/events/route.ts GET SSE stream of currently-running session ids
|
||||
auth/all-providers/route.ts GET API-key provider list
|
||||
auth/api-key/[provider]/route.ts GET/POST/DELETE provider API key status/storage
|
||||
auth/login/[provider]/route.ts GET OAuth/device-code SSE | POST manual code
|
||||
auth/logout/[provider]/route.ts POST OAuth logout
|
||||
auth/providers/route.ts GET OAuth provider list
|
||||
cwd/validate/route.ts POST validate/select a cwd
|
||||
default-cwd/route.ts POST create managed data/workspaces/default (standalone: ~/pi-cwd-YYYYMMDD)
|
||||
files/[...path]/route.ts GET file contents for viewer
|
||||
home/route.ts GET user home directory
|
||||
models/route.ts GET { models, modelList, defaultModel }
|
||||
models-config/route.ts GET/PUT — read/write ~/.pi/agent/models.json
|
||||
models-config/catalog/route.ts GET models.dev pricing presets
|
||||
models-config/discover/route.ts POST fetch a configured provider's upstream model list
|
||||
models-config/test/route.ts POST test a configured model/provider
|
||||
plugins/route.ts GET/POST package plugin management
|
||||
skills/route.ts GET/PATCH loaded skills and disable-model-invocation
|
||||
skills/install/route.ts POST install skills through npx skills add
|
||||
skills/search/route.ts GET/POST skills.sh search
|
||||
worktrees/route.ts GET/POST/DELETE git worktrees
|
||||
|
||||
lib/
|
||||
agent-client.ts typed fetch helper for /api/agent commands
|
||||
draft-store.ts local draft persistence helpers
|
||||
file-paths.ts client-side path encoding helpers
|
||||
file-access.ts allowed file roots for /api/files and worktrees
|
||||
file-paths.ts client/server path encoding helpers
|
||||
markdown.ts shared markdown helpers
|
||||
pi-types.ts structural copies of API/session types; no Pi dependency
|
||||
npx.ts npx runner used by skill install
|
||||
pi-types.ts local structural types for pi SDK objects
|
||||
rpc-manager.ts AgentSessionWrapper + registry + startRpcSession
|
||||
session-reader.ts SessionManager wrappers + path cache + buildSessionContext adapter
|
||||
tool-presets.ts PRESET_NONE/DEFAULT/FULL + getPresetFromTools()
|
||||
types.ts shared TypeScript types
|
||||
normalize.ts normalizeToolCalls() — field name mismatch between file format and our types
|
||||
worktree.ts project/worktree resolution and git worktree operations
|
||||
|
||||
components/
|
||||
AppShell.tsx layout + URL state + tab management
|
||||
@@ -90,9 +111,9 @@ hooks/
|
||||
|
||||
## Key Design Decisions & Traps
|
||||
|
||||
### AgentSession lifecycle (`server/src/lib/rpc-manager.ts`)
|
||||
### AgentSession lifecycle (`lib/rpc-manager.ts`)
|
||||
- One `AgentSessionWrapper` per session id, keyed in `globalThis.__piSessions`
|
||||
- The standalone backend process survives Next.js hot reloads and restarts
|
||||
- `globalThis` survives Next.js hot-reload; plain module-level Map does not
|
||||
- Idle timeout: 10 minutes. Concurrent `startRpcSession()` calls share a single start Promise (`globalThis.__piStartLocks`)
|
||||
|
||||
### Fork must destroy the wrapper immediately
|
||||
@@ -123,12 +144,12 @@ On `ChatWindow` mount, `GET /api/agent/[id]` is called. If `state.isStreaming ==
|
||||
Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `auto_compaction_start` / `auto_compaction_end`. `handleAgentEvent` accepts both sets to keep `isCompacting` in sync. Manual compact is a blocking POST — the button stays disabled until the response returns.
|
||||
|
||||
### Running state SSE + reconciliation
|
||||
- The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `server/src/lib/rpc-manager.ts`, so running badges update without polling.
|
||||
- The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `lib/rpc-manager.ts`, so running badges update without polling.
|
||||
- `useAgentSession` still treats per-session SSE as primary for chat events, but while a run is active it periodically calls `GET /api/agent/[id]` and also reconciles on `visibilitychange`/`online`. This fixes missed `agent_end` events from background tabs or half-open connections.
|
||||
- Prompt runs use a monotonic run id; late SSE or slow reconciliation responses from an old run must be ignored so they cannot resurrect stale streaming bubbles.
|
||||
|
||||
### Worktrees and project grouping
|
||||
- `server/src/lib/worktree.ts` resolves linked worktree top-levels back to the main repo `projectRoot`; `listAllSessions()` attaches that to each `SessionInfo` so all worktrees for one repo are grouped together in the sidebar.
|
||||
- `lib/worktree.ts` resolves linked worktree top-levels back to the main repo `projectRoot`; `listAllSessions()` attaches that to each `SessionInfo` so all worktrees for one repo are grouped together in the sidebar.
|
||||
- Worktree operations are served by `/api/worktrees` and guarded by the same allowed-root rules as `/api/files`.
|
||||
- New worktrees are created under `<repoRoot>-worktrees/<sanitized-branch>`. Existing branches are reused; otherwise `git worktree add -b` creates the branch.
|
||||
- Removing a dirty worktree returns `409` with `{ dirty: true }` so the UI can ask before retrying with `force`.
|
||||
@@ -148,7 +169,7 @@ Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `au
|
||||
- `ModelsConfig` combines models from `~/.pi/agent/models.json` with provider auth status from pi's `AuthStorage`/`ModelRegistry`.
|
||||
- OAuth/device-code/manual-code flows are streamed by `GET /api/auth/login/[provider]`; manual code responses POST back with a short-lived token stored in `globalThis.__piLoginCallbacks`.
|
||||
- API-key routes store and remove keys through `AuthStorage`. Status endpoints must never return the raw key.
|
||||
- The model test endpoint is registered in `server/src/routes/models.ts` as `/api/models-config/test`; `/api/models/test` is not a real route.
|
||||
- The model test route is `app/api/models-config/test/route.ts`; `app/api/models/test/` is not a real route.
|
||||
|
||||
### Completion sound
|
||||
- `hooks/useAudio.ts` stores the toggle in `localStorage` as `pi-sound-enabled` and reuses one `AudioContext`.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
MAX_INLINE_BASH_OUTPUT_BYTES,
|
||||
openRegularFileNoFollow,
|
||||
readUtf8FileWithinLimit,
|
||||
resolveBashOutputPath,
|
||||
} from "@/lib/bash-output";
|
||||
import { isBashOutputPathReferencedBySession } from "@/lib/session-file-references";
|
||||
|
||||
// GET /api/agent/[id]/bash-output?path=<absPath>
|
||||
// Reads a bash output temp file referenced by this session. Inline display is
|
||||
// size-limited; download responses stream the file without buffering it.
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
let path: string | null = null;
|
||||
let download = false;
|
||||
try {
|
||||
const url = new URL(_req.url);
|
||||
path = url.searchParams.get("path");
|
||||
download = url.searchParams.get("download") === "1";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: "path required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolved = resolveBashOutputPath(path, tmpdir());
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: "invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!await isBashOutputPathReferencedBySession(resolved, id)) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (download) {
|
||||
const { handle } = await openRegularFileNoFollow(resolved);
|
||||
const stream = Readable.toWeb(handle.createReadStream()) as ReadableStream<Uint8Array>;
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "attachment; filename=\"bash-output.log\"",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await readUtf8FileWithinLimit(resolved);
|
||||
if (result.tooLarge) {
|
||||
return NextResponse.json({
|
||||
error: `Full output is too large to display (limit ${MAX_INLINE_BASH_OUTPUT_BYTES} bytes)`,
|
||||
data: { size: result.size, maxBytes: MAX_INLINE_BASH_OUTPUT_BYTES },
|
||||
}, { status: 413 });
|
||||
}
|
||||
return NextResponse.json({ success: true, data: { output: result.content } });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "full output unavailable" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/agent/[id]/events - SSE stream of agent events
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
// Fast path: already-running session
|
||||
let session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
try {
|
||||
({ session } = await startRpcSession(id, filePath, cwd));
|
||||
} catch (error) {
|
||||
return new Response(`Failed to start agent: ${error}`, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encode = (data: unknown) => {
|
||||
const text = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
};
|
||||
|
||||
// Send initial connected event
|
||||
encode({ type: "connected", sessionId: id });
|
||||
|
||||
const unsubscribe = session.onEvent((event) => {
|
||||
encode(event);
|
||||
});
|
||||
|
||||
// Heartbeat every 30s to prevent server/proxy timeout (Next.js default ~120-150s)
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(":\n\n"));
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Cleanup when client disconnects
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
controller.close();
|
||||
};
|
||||
|
||||
// Detect client disconnect via abort signal
|
||||
req.signal?.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
import { startRpcSession, getRpcSession } from "@/lib/rpc-manager";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// POST /api/agent/[id] - Send a command to an existing session
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { type: string; [key: string]: unknown };
|
||||
|
||||
// Fast path: already-running session
|
||||
const existing = getRpcSession(id);
|
||||
if (existing?.isAlive()) {
|
||||
const result = await existing.send(body);
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
}
|
||||
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
|
||||
const { session } = await startRpcSession(id, filePath, cwd);
|
||||
const result = await session.send(body);
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/agent/[id] - Get current agent state
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
return NextResponse.json({ running: false });
|
||||
}
|
||||
|
||||
const state = await session.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
import { invalidateSessionListCache } from "@/lib/session-reader";
|
||||
import { startRpcSession } from "@/lib/rpc-manager";
|
||||
// POST /api/agent/new body: { cwd: string; type: string; message?: string; ... }
|
||||
// Spawns a brand-new pi session. Most calls immediately send the first command;
|
||||
// type:"ensure_session" only creates the runtime so clients can query commands.
|
||||
// Returns { sessionId, data } where sessionId is pi's real session id.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; [key: string]: unknown };
|
||||
const { cwd, ...command } = body;
|
||||
|
||||
if (!cwd || typeof cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!existsSync(cwd)) {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use a one-time key so startRpcSession's lock doesn't conflict with real session ids
|
||||
const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: string; [key: string]: unknown };
|
||||
|
||||
// Must be unique per request: startRpcSession coalesces concurrent callers
|
||||
// that share a key onto one session. Date.now() (ms resolution) collides for
|
||||
// requests in the same millisecond, merging two new sessions into one.
|
||||
const tempKey = `__new__${randomUUID()}`;
|
||||
const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, toolNames);
|
||||
|
||||
// Keep the files-route allowed-roots cache (see app/api/files/[...path]/route.ts)
|
||||
// in sync so the new cwd is immediately readable via /api/files. Without this,
|
||||
// a file request under a brand-new cwd would 403 for up to the cache TTL.
|
||||
allowFileRoot(cwd);
|
||||
invalidateSessionListCache();
|
||||
|
||||
// Apply pre-selected model before sending the prompt
|
||||
if (provider && modelId) {
|
||||
await session.send({ type: "set_model", provider, modelId });
|
||||
}
|
||||
|
||||
// Apply pre-selected thinking level before sending the prompt
|
||||
if (thinkingLevel) {
|
||||
await session.send({ type: "set_thinking_level", level: thinkingLevel });
|
||||
}
|
||||
|
||||
if (promptCommand.type === "ensure_session") {
|
||||
return NextResponse.json({ success: true, sessionId: realSessionId, data: null });
|
||||
}
|
||||
|
||||
const result = await session.send(promptCommand);
|
||||
|
||||
return NextResponse.json({ success: true, sessionId: realSessionId, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getRunningRpcSessionIds, subscribeRunningSessions } from "@/lib/rpc-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/agent/running/events - SSE stream of the set of currently-running
|
||||
// session ids. Pushes an update whenever any session starts or stops working,
|
||||
// so the sidebar never has to poll.
|
||||
export async function GET(req: Request) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encode = (data: unknown) => {
|
||||
const text = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
};
|
||||
|
||||
// Subscribe BEFORE taking the initial snapshot so no state change can slip
|
||||
// through the gap between snapshot and subscription.
|
||||
const unsubscribe = subscribeRunningSessions((ids) => {
|
||||
try {
|
||||
encode({ type: "running", runningSessionIds: ids });
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
});
|
||||
|
||||
// Initial snapshot so the client renders the correct state immediately.
|
||||
// (A duplicate frame here is harmless: the client just sets the same set.)
|
||||
encode({ type: "running", runningSessionIds: getRunningRpcSessionIds() });
|
||||
|
||||
// Heartbeat to keep the connection alive through proxies/timeouts.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(":\n\n"));
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
};
|
||||
|
||||
req.signal?.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Providers that use OAuth — handled separately via /api/auth/providers
|
||||
const OAUTH_PROVIDER_IDS = new Set(["anthropic", "github-copilot", "openai-codex"]);
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const all = modelRuntime.getModels();
|
||||
|
||||
// Deduplicate by provider, skip OAuth-only providers and custom providers (source=models_json_key)
|
||||
const seen = new Set<string>();
|
||||
const result: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
configured: boolean;
|
||||
source?: string;
|
||||
modelCount: number;
|
||||
}[] = [];
|
||||
|
||||
for (const provider of modelRuntime.getProviders()) {
|
||||
if (seen.has(provider.id)) continue;
|
||||
seen.add(provider.id);
|
||||
if (OAUTH_PROVIDER_IDS.has(provider.id) || !provider.auth.apiKey?.login) continue;
|
||||
const status = modelRuntime.getProviderAuthStatus(provider.id);
|
||||
// Skip providers whose key comes from models.json (those are custom providers)
|
||||
if (status.source === "models_json_key") continue;
|
||||
const modelCount = all.filter((model) => model.provider === provider.id).length;
|
||||
result.push({
|
||||
id: provider.id,
|
||||
displayName: provider.name,
|
||||
configured: status.configured,
|
||||
source: status.source,
|
||||
modelCount,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { NextResponse } from "next/server";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ provider: string }> };
|
||||
|
||||
// GET /api/auth/api-key/[provider] — returns auth status (never returns the actual key)
|
||||
export async function GET(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const status = modelRuntime.getProviderAuthStatus(provider);
|
||||
const displayName = modelRuntime.getProvider(provider)?.name ?? provider;
|
||||
const models = modelRuntime.getModels(provider).length;
|
||||
return NextResponse.json({ provider, displayName, configured: status.configured, source: status.source, models });
|
||||
}
|
||||
|
||||
// POST /api/auth/api-key/[provider] body: { apiKey: string }
|
||||
export async function POST(req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const { apiKey } = await req.json() as { apiKey?: string };
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
return NextResponse.json({ error: "apiKey is required" }, { status: 400 });
|
||||
}
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
let keySubmitted = false;
|
||||
await modelRuntime.login(provider, "api_key", {
|
||||
notify: () => {},
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
const keyOption = prompt.options.find((option) => option.id === "api-key" || option.id === "bearer-token");
|
||||
if (keyOption) return keyOption.id;
|
||||
throw new Error(`${provider} requires interactive authentication setup`);
|
||||
}
|
||||
if (!keySubmitted && prompt.type === "secret") {
|
||||
keySubmitted = true;
|
||||
return apiKey.trim();
|
||||
}
|
||||
throw new Error(`${provider} requires additional authentication settings`);
|
||||
},
|
||||
});
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/auth/api-key/[provider] — removes stored API key
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// In-memory registry: loginToken -> resolve/reject for the manualCodeInput promise
|
||||
declare global {
|
||||
var __piLoginCallbacks: Map<string, { resolve: (v: string) => void; reject: (e: Error) => void }> | undefined;
|
||||
}
|
||||
|
||||
function getCallbackRegistry() {
|
||||
if (!globalThis.__piLoginCallbacks) globalThis.__piLoginCallbacks = new Map();
|
||||
return globalThis.__piLoginCallbacks;
|
||||
}
|
||||
|
||||
// POST /api/auth/login/[provider] — frontend sends redirect URL or auth code
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const { token, code } = (await req.json()) as { token?: string; code?: string };
|
||||
|
||||
if (!token || !code) {
|
||||
return Response.json({ error: "token and code required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const callbacks = registry.get(token);
|
||||
if (!callbacks) {
|
||||
return Response.json({ error: "No pending login for token" }, { status: 404 });
|
||||
}
|
||||
// Verify token belongs to this provider (token format: "<provider>-<ts>-<random>")
|
||||
if (!token.startsWith(`${provider}-`)) {
|
||||
return Response.json({ error: "Token does not match provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
callbacks.resolve(code);
|
||||
registry.delete(token);
|
||||
return Response.json({ ok: true, provider });
|
||||
}
|
||||
|
||||
// GET /api/auth/login/[provider] — SSE stream for OAuth flow
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const send = (controller: ReadableStreamDefaultController, data: unknown) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
// AbortController propagates client disconnect into ModelRuntime.login().
|
||||
const abort = new AbortController();
|
||||
req.signal.addEventListener("abort", () => abort.abort());
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
send(controller, { type: "error", message: `Unknown provider: ${provider}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const activeTokens = new Set<string>();
|
||||
let pendingManualRequest: { token: string; promise: Promise<string> } | undefined;
|
||||
|
||||
const createClientInputRequest = () => {
|
||||
const token = `${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeTokens.add(token);
|
||||
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
registry.set(token, {
|
||||
resolve: (value) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { token, promise };
|
||||
};
|
||||
|
||||
const getManualInputRequest = () => {
|
||||
if (!pendingManualRequest) {
|
||||
pendingManualRequest = createClientInputRequest();
|
||||
pendingManualRequest.promise
|
||||
.finally(() => {
|
||||
pendingManualRequest = undefined;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return pendingManualRequest;
|
||||
};
|
||||
|
||||
// Cleanup: remove pending token and abort any waiting promise
|
||||
const cleanup = () => {
|
||||
for (const token of activeTokens) {
|
||||
registry.get(token)?.reject(new Error("Login cancelled"));
|
||||
registry.delete(token);
|
||||
}
|
||||
activeTokens.clear();
|
||||
};
|
||||
|
||||
// Also cancel on client disconnect
|
||||
abort.signal.addEventListener("abort", cleanup);
|
||||
|
||||
try {
|
||||
await modelRuntime.login(provider, "oauth", {
|
||||
prompt: async (prompt: AuthPrompt) => {
|
||||
const request = prompt.type === "manual_code"
|
||||
? getManualInputRequest()
|
||||
: createClientInputRequest();
|
||||
if (prompt.type === "select") {
|
||||
send(controller, {
|
||||
type: "select_request",
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
token: request.token,
|
||||
});
|
||||
} else {
|
||||
send(controller, {
|
||||
type: "prompt_request",
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
}
|
||||
return request.promise;
|
||||
},
|
||||
notify: (event: AuthEvent) => {
|
||||
if (event.type === "auth_url") {
|
||||
const request = getManualInputRequest();
|
||||
send(controller, {
|
||||
type: "auth",
|
||||
url: event.url,
|
||||
instructions: event.instructions ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
} else if (event.type === "device_code") {
|
||||
send(controller, {
|
||||
type: "device_code",
|
||||
userCode: event.userCode,
|
||||
verificationUri: event.verificationUri,
|
||||
intervalSeconds: event.intervalSeconds ?? null,
|
||||
expiresInSeconds: event.expiresInSeconds ?? null,
|
||||
});
|
||||
} else {
|
||||
send(controller, { type: "progress", message: event.message });
|
||||
}
|
||||
},
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
invalidateModelsCache();
|
||||
send(controller, { type: "success" });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg !== "Login cancelled") {
|
||||
send(controller, { type: "error", message: msg });
|
||||
} else {
|
||||
send(controller, { type: "cancelled" });
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
return Response.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
|
||||
}
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const credentials = await modelRuntime.listCredentials();
|
||||
const loggedInProviders = new Set(
|
||||
credentials.filter((credential) => credential.type === "oauth").map((credential) => credential.providerId),
|
||||
);
|
||||
const providers = modelRuntime.getProviders().filter((provider) => provider.auth.oauth);
|
||||
|
||||
const EXCLUDED = new Set(["anthropic"]);
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
"openai-codex": "ChatGPT Plus/Pro",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
};
|
||||
|
||||
const result = await Promise.all(
|
||||
providers
|
||||
.filter((p) => !EXCLUDED.has(p.id))
|
||||
.map(async (p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
name: DISPLAY_NAMES[p.id] ?? p.name,
|
||||
usesCallbackServer: false,
|
||||
loggedIn: loggedInProviders.has(p.id),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { stat } from "fs/promises";
|
||||
import {
|
||||
getBrowseStartDirectory,
|
||||
getParentDirectory,
|
||||
listDirectories,
|
||||
resolveDirectory,
|
||||
} from "@/lib/directory-browser";
|
||||
|
||||
// GET /api/cwd/browse?path=...:列出文件系统中的可读子目录。
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const requested = request.nextUrl.searchParams.get("path")?.trim();
|
||||
const candidate = getBrowseStartDirectory(requested);
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await resolveDirectory(candidate);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory does not exist" }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
const directoryStat = await stat(resolved);
|
||||
if (!directoryStat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Path is not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
const directories = await listDirectories(resolved);
|
||||
|
||||
return NextResponse.json({
|
||||
path: resolved,
|
||||
parentPath: getParentDirectory(resolved),
|
||||
directories,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { statSync, type Stats } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { isAbsolute, resolve } from "path";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
if (cwd === "~") return homedir();
|
||||
if (cwd.startsWith("~/")) return resolve(homedir(), cwd.slice(2));
|
||||
return isAbsolute(cwd) ? cwd : resolve(cwd);
|
||||
}
|
||||
|
||||
// POST /api/cwd/validate body: { cwd: string }
|
||||
// Validates a candidate workspace before the UI selects it.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: unknown };
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd.trim() : "";
|
||||
|
||||
if (!cwd) {
|
||||
return NextResponse.json({ error: "Path is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedCwd = normalizeCwd(cwd);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = statSync(normalizedCwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: `Path is not a directory: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
allowFileRoot(normalizedCwd);
|
||||
return NextResponse.json({ success: true, cwd: normalizedCwd });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdirSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
|
||||
// POST /api/default-cwd
|
||||
// Managed app: creates <dataDir>/workspaces/default.
|
||||
// Standalone pi-web: preserves the upstream ~/pi-cwd-<YYYYMMDD> behavior.
|
||||
export async function POST() {
|
||||
try {
|
||||
const dir = isManagedRuntime()
|
||||
? join(getManagedRuntimePaths().dataDir, "workspaces", "default")
|
||||
: join(
|
||||
homedir(),
|
||||
`pi-cwd-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
allowFileRoot(dir);
|
||||
return NextResponse.json({ cwd: dir });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
} from "@/lib/file-access";
|
||||
import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "@/lib/file-fuzzy";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Same skip lists as /api/files — only used for the non-git readdir fallback.
|
||||
// Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior).
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
/** Cap on the plain (no-query) response used as the client-side index */
|
||||
const MAX_FILES = 5000;
|
||||
/** Hard caps on the full in-memory listing that ?q= searches against */
|
||||
const GIT_HARD_CAP = 200_000;
|
||||
const WALK_HARD_CAP = 50_000;
|
||||
const MAX_WALK_DEPTH = 8;
|
||||
const MAX_QUERY_LENGTH = 500;
|
||||
const CACHE_TTL_MS = 10_000;
|
||||
const CACHE_MAX_ENTRIES = 20;
|
||||
|
||||
interface FileListing {
|
||||
/** Full listing up to the hard cap (not the client cap) */
|
||||
files: string[];
|
||||
/** True when even the hard cap was exceeded */
|
||||
hardTruncated: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
listing: FileListing;
|
||||
/** Derived lazily on the first ?q= search against this listing */
|
||||
entries?: FileIndexEntry[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// Per-cwd cache on globalThis so it survives Next.js hot-reload; the @ menu
|
||||
// re-requests on every open and searches on every keystroke, so listings must
|
||||
// not be recomputed within a short window.
|
||||
declare global {
|
||||
var __piFileIndexCache: Map<string, CacheEntry> | undefined;
|
||||
}
|
||||
|
||||
function getIndexCache(): Map<string, CacheEntry> {
|
||||
if (!globalThis.__piFileIndexCache) globalThis.__piFileIndexCache = new Map();
|
||||
return globalThis.__piFileIndexCache;
|
||||
}
|
||||
|
||||
async function listWithGit(cwd: string): Promise<FileListing | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
{ timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } },
|
||||
);
|
||||
const all = stdout.split("\0").filter(Boolean);
|
||||
if (all.length > GIT_HARD_CAP) {
|
||||
return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true };
|
||||
}
|
||||
return { files: all, hardTruncated: false };
|
||||
} catch {
|
||||
// Not a git repo (or git unavailable) — caller falls back to readdir walk.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listWithWalk(cwd: string): FileListing {
|
||||
const files: string[] = [];
|
||||
// BFS so shallow files win when the cap truncates the listing.
|
||||
const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }];
|
||||
while (queue.length > 0) {
|
||||
const { abs, rel, depth } = queue.shift()!;
|
||||
let dirents: fs.Dirent[];
|
||||
try {
|
||||
dirents = fs.readdirSync(abs, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of dirents) {
|
||||
if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue;
|
||||
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (d.isDirectory()) {
|
||||
if (depth + 1 <= MAX_WALK_DEPTH) {
|
||||
queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 });
|
||||
}
|
||||
} else if (d.isFile()) {
|
||||
if (files.length >= WALK_HARD_CAP) {
|
||||
return { files, hardTruncated: true };
|
||||
}
|
||||
files.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, hardTruncated: false };
|
||||
}
|
||||
|
||||
// GET /api/file-index?cwd=/abs/path[&q=query]
|
||||
// Without q: { files: string[] (relative to cwd, capped at MAX_FILES),
|
||||
// truncated: boolean } — the client-side index for local filtering.
|
||||
// With q: { matches: { path, isDir }[] } — ranked against the FULL listing so
|
||||
// repos larger than MAX_FILES still find deep files (cap applied after
|
||||
// matching, like the TUI passing the query to fd).
|
||||
// Guarded by the same allow-list as /api/files.
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const cwd = req.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
const query = req.nextUrl.searchParams.get("q")?.slice(0, MAX_QUERY_LENGTH) ?? "";
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const cache = getIndexCache();
|
||||
const now = Date.now();
|
||||
let cached = cache.get(cwd);
|
||||
if (!cached || cached.expiresAt <= now) {
|
||||
const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd);
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.expiresAt <= now) cache.delete(key);
|
||||
}
|
||||
if (cache.size >= CACHE_MAX_ENTRIES) cache.clear();
|
||||
cached = { listing, expiresAt: now + CACHE_TTL_MS };
|
||||
cache.set(cwd, cached);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
cached.entries ??= buildEntriesFromFiles(cached.listing.files);
|
||||
return NextResponse.json({ matches: filterFileEntries(cached.entries, query) });
|
||||
}
|
||||
|
||||
const { files, hardTruncated } = cached.listing;
|
||||
return NextResponse.json({
|
||||
files: files.slice(0, MAX_FILES),
|
||||
truncated: hardTruncated || files.length > MAX_FILES,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
normalizeSlashes,
|
||||
} from "@/lib/file-access";
|
||||
import {
|
||||
DOCX_PREVIEW_MAX_BYTES,
|
||||
IMAGE_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
documentPreviewKind,
|
||||
getAudioMime,
|
||||
getDocumentMime,
|
||||
getFileExt,
|
||||
getImageMime,
|
||||
} from "@/lib/file-types";
|
||||
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
|
||||
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
|
||||
import { isApiRequestAllowed } from "@/lib/request-security";
|
||||
import {
|
||||
inspectUploadTargets,
|
||||
parseUploadConflictStrategy,
|
||||
validateUploadFileNames,
|
||||
} from "@/lib/file-upload";
|
||||
import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
|
||||
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store", ".git",
|
||||
]);
|
||||
|
||||
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_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.
|
||||
const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
||||
|
||||
const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
|
||||
go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
||||
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
|
||||
html: "html", htm: "html", css: "css", scss: "css", less: "css",
|
||||
json: "json", jsonl: "json", yaml: "yaml", yml: "yaml",
|
||||
toml: "toml", xml: "xml", md: "markdown", mdx: "markdown",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
||||
sql: "sql", graphql: "graphql", gql: "graphql",
|
||||
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
|
||||
env: "bash", gitignore: "bash", txt: "text",
|
||||
pdf: "pdf", docx: "word",
|
||||
};
|
||||
|
||||
function getLanguage(filePath: string): string {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
// Special full-name matches
|
||||
if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile";
|
||||
if (base === ".env" || base.startsWith(".env.")) return "bash";
|
||||
if (base === "makefile" || base === "gnumakefile") return "makefile";
|
||||
const ext = base.split(".").pop() ?? "";
|
||||
return EXT_TO_LANGUAGE[ext] ?? "text";
|
||||
}
|
||||
|
||||
function filePathFromSegments(segments: string[]): string {
|
||||
const joined = segments.join("/");
|
||||
const slashJoined = normalizeSlashes(joined);
|
||||
if (isWindowsAbsolutePath(slashJoined)) return slashJoined;
|
||||
return "/" + joined.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function parseFileRequestType(value: string): FileRequestType | null {
|
||||
return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null;
|
||||
}
|
||||
|
||||
async function getUploadDirectory(segments: string[]): Promise<
|
||||
{ directory: string } | { response: NextResponse }
|
||||
> {
|
||||
const directory = filePathFromSegments(segments);
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(directory, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(directory);
|
||||
} catch {
|
||||
return { response: NextResponse.json({ error: "Upload directory not found" }, { status: 404 }) };
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { response: NextResponse.json({ error: "Upload target is not a directory" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
// A browsable directory can be a symlink. Resolve both sides before writes
|
||||
// so a symlink inside an allowed root cannot redirect uploads outside it.
|
||||
const realDirectory = fs.realpathSync(directory);
|
||||
const realRoots = new Set<string>();
|
||||
for (const root of allowedRoots) {
|
||||
try {
|
||||
realRoots.add(fs.realpathSync(root));
|
||||
} catch {
|
||||
// Ignore stale session roots that no longer exist.
|
||||
}
|
||||
}
|
||||
if (!isFilePathAllowed(realDirectory, realRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
return { directory: realDirectory };
|
||||
}
|
||||
|
||||
function parseUploadFileNames(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
if (!isApiRequestAllowed(request)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
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;
|
||||
const fileNames = parseUploadFileNames(body?.fileNames);
|
||||
if (!fileNames) {
|
||||
return NextResponse.json({ error: "fileNames must be an array of strings" }, { status: 400 });
|
||||
}
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(inspectUploadTargets(directory, fileNames));
|
||||
}
|
||||
|
||||
if (type !== "upload") {
|
||||
return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const strategy = parseUploadConflictStrategy(request.nextUrl.searchParams.get("conflict"));
|
||||
if (!strategy) {
|
||||
return NextResponse.json({ error: "Invalid conflict strategy" }, { status: 400 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await parseFormDataWithinLimit(request, MAX_UPLOAD_REQUEST_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string");
|
||||
if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) {
|
||||
return NextResponse.json({ error: "Each upload must be 25MB or smaller" }, { status: 413 });
|
||||
}
|
||||
if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
const fileNames = files.map((file) => file.name);
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
|
||||
const inspection = inspectUploadTargets(directory, fileNames);
|
||||
if (strategy === "error" && inspection.conflicts.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: "One or more files already exist",
|
||||
conflicts: inspection.conflicts,
|
||||
nonReplaceable: inspection.nonReplaceable,
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
const conflictSet = new Set(inspection.conflicts);
|
||||
const nonReplaceableSet = new Set(inspection.nonReplaceable);
|
||||
const uploaded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const destination = path.join(directory, file.name);
|
||||
if (conflictSet.has(file.name) && strategy === "skip") {
|
||||
skipped.push(file.name);
|
||||
continue;
|
||||
}
|
||||
if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) {
|
||||
errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = Buffer.from(await file.arrayBuffer());
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (conflictSet.has(file.name)) {
|
||||
try {
|
||||
fs.unlinkSync(destination);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(destination, bytes, { flag: "wx" });
|
||||
uploaded.push(file.name);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ uploaded, skipped, errors },
|
||||
{ status: errors.length > 0 ? 207 : 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
|
||||
const fileStream = fs.createReadStream(filePath, range);
|
||||
let closed = false;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
fileStream.on("data", (chunk: Buffer) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
} catch {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
fileStream.once("end", () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// The browser may cancel media probes before the file stream ends.
|
||||
}
|
||||
});
|
||||
fileStream.once("error", (error) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.error(error);
|
||||
} catch {
|
||||
// The response was already abandoned by the client.
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(filePath: string, asDownload = false): string {
|
||||
const disposition = asDownload ? "attachment" : "inline";
|
||||
const fileName = path.basename(filePath);
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response {
|
||||
const headers = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-cache",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": getContentDisposition(filePath, asDownload),
|
||||
};
|
||||
|
||||
if (!rangeHeader) {
|
||||
return new Response(createFileBodyStream(filePath), {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(stat.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
||||
if (!match) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let start = match[1] ? Number(match[1]) : 0;
|
||||
let end = match[2] ? Number(match[2]) : stat.size - 1;
|
||||
if (!match[1] && match[2]) {
|
||||
const suffixLength = Number(match[2]);
|
||||
start = Math.max(stat.size - suffixLength, 0);
|
||||
end = stat.size - 1;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
end = Math.min(end, stat.size - 1);
|
||||
const chunkSize = end - start + 1;
|
||||
return new Response(createFileBodyStream(filePath, { start, end }), {
|
||||
status: 206,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
html, body { margin: 0; min-height: 100%; background: #eef1f5; color: #171717; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 28px; }
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
max-width: 840px;
|
||||
min-height: calc(100vh - 56px);
|
||||
margin: 0 auto;
|
||||
padding: 56px 64px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
.file-title {
|
||||
margin: 0 0 28px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #6b7280;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.1em 0 0.45em; color: #111827; }
|
||||
p { margin: 0.65em 0; line-height: 1.7; }
|
||||
table { border-collapse: collapse; max-width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 6px 9px; vertical-align: top; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
a { color: #2563eb; }
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 0; background: #fff; }
|
||||
main { min-height: 100vh; padding: 28px 22px; box-shadow: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="file-title">${escapeHtml(fileName)}</div>
|
||||
${bodyHtml}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const filePath = filePathFromSegments(segments);
|
||||
const rawType = request.nextUrl.searchParams.get("type") ?? "list";
|
||||
const type = parseFileRequestType(rawType);
|
||||
if (!type) {
|
||||
return NextResponse.json({ error: "Invalid file request type" }, { status: 400 });
|
||||
}
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId");
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
|
||||
const allowedBySessionReference =
|
||||
!allowedByRoot &&
|
||||
type !== "list" &&
|
||||
await isFilePathReferencedBySession(filePath, sessionId);
|
||||
if (!allowedByRoot && !allowedBySessionReference) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (type === "read") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
if (imageMime) {
|
||||
if (stat.size > IMAGE_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 });
|
||||
}
|
||||
return streamFile(filePath, stat, imageMime, request.headers.get("range"));
|
||||
}
|
||||
const audioMime = getAudioMime(filePath);
|
||||
if (audioMime) {
|
||||
return streamFile(filePath, stat, audioMime, request.headers.get("range"));
|
||||
}
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
if (documentMime) {
|
||||
return streamFile(filePath, stat, documentMime, request.headers.get("range"));
|
||||
}
|
||||
if (stat.size > TEXT_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 });
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const language = getLanguage(filePath);
|
||||
return NextResponse.json({ content, language, size: stat.size });
|
||||
}
|
||||
|
||||
if (type === "download") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream";
|
||||
return streamFile(filePath, stat, mime, request.headers.get("range"), true);
|
||||
}
|
||||
|
||||
if (type === "meta") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
const audioMime = getAudioMime(filePath);
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
return NextResponse.json({
|
||||
size: stat.size,
|
||||
language: getLanguage(filePath),
|
||||
mime: imageMime || audioMime || documentMime || "text/plain",
|
||||
previewKind: documentPreviewKind(filePath),
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "preview") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
if (getFileExt(filePath) !== "docx") {
|
||||
return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 });
|
||||
}
|
||||
if (stat.size > DOCX_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const mammoth = await import("mammoth");
|
||||
const result = await mammoth.convertToHtml(
|
||||
{ path: filePath },
|
||||
{
|
||||
externalFileAccess: false,
|
||||
convertImage: mammoth.images.dataUri,
|
||||
}
|
||||
);
|
||||
const html = wrapDocxPreviewHtml(result.value, path.basename(filePath));
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "watch") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
let watcher: fs.FSWatcher | null = null;
|
||||
let lastMtimeMs = stat.mtimeMs;
|
||||
let lastSize = stat.size;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (eventName: string, data: Record<string, unknown>) => {
|
||||
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(payload));
|
||||
} catch {
|
||||
// client disconnected
|
||||
}
|
||||
};
|
||||
// Send initial ping so client knows connection is live
|
||||
send("connected", { filePath });
|
||||
try {
|
||||
watcher = fs.watch(filePath, () => {
|
||||
try {
|
||||
const s = fs.statSync(filePath);
|
||||
// Some platforms emit watch events for file reads/attribute
|
||||
// access. Ignore those or the client's refresh read loops.
|
||||
if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return;
|
||||
lastMtimeMs = s.mtimeMs;
|
||||
lastSize = s.size;
|
||||
send("change", { mtime: s.mtime.toISOString(), size: s.size });
|
||||
} catch {
|
||||
send("change", { mtime: new Date().toISOString(), size: 0 });
|
||||
}
|
||||
});
|
||||
watcher.on("error", () => {
|
||||
try { controller.close(); } catch { /* ignore */ }
|
||||
});
|
||||
} catch {
|
||||
send("error", { message: "Failed to watch file" });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
try { watcher?.close(); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// type === "list"
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Avoid per-entry stat calls for normal files and directories. Symlinks and
|
||||
// filesystems without directory type information use the stat fallback.
|
||||
const dirents = fs.readdirSync(filePath, { withFileTypes: true });
|
||||
const entries = dirents
|
||||
.filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s)))
|
||||
.flatMap((d) => {
|
||||
const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name));
|
||||
return isDir === null
|
||||
? []
|
||||
: [{ name: d.name, isDir, size: 0, modified: "" }];
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Dirs first, then files, both alphabetically
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entries, path: filePath });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
|
||||
import { getGitFileDiff } from "@/lib/git-changes";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
const filePath = request.nextUrl.searchParams.get("path")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
if (!filePath || (!filePath.startsWith("/") && !isWindowsAbsolutePath(filePath))) {
|
||||
return NextResponse.json({ error: "path must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots) || !isFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
// The cwd must resolve inside an allowed root. The file itself may no
|
||||
// longer exist when Git reports it as deleted; getGitFileDiff verifies
|
||||
// that the requested path belongs to this repository and its status.
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await getGitFileDiff(cwd, filePath));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from "fs";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
|
||||
import { getGitStatus } from "@/lib/git-changes";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await getGitStatus(cwd));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { homedir } from "os";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ home: homedir() });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
flattenModelsDevCatalog,
|
||||
recommendModelCatalogPreset,
|
||||
searchModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
} from "@/lib/model-catalog";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json";
|
||||
const CATALOG_TTL_MS = 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface CatalogCache {
|
||||
entries: ModelCatalogEntry[];
|
||||
expiresAt: number;
|
||||
inFlight?: Promise<ModelCatalogEntry[]>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piModelsDevCatalogCache: CatalogCache | undefined;
|
||||
}
|
||||
|
||||
function getCache(): CatalogCache {
|
||||
return globalThis.__piModelsDevCatalogCache ??= { entries: [], expiresAt: 0 };
|
||||
}
|
||||
|
||||
async function fetchCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const response = await fetch(MODELS_DEV_URL, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`);
|
||||
const entries = flattenModelsDevCatalog(await response.json());
|
||||
if (entries.length === 0) throw new Error("models.dev returned an empty catalog");
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const cache = getCache();
|
||||
if (cache.entries.length > 0 && cache.expiresAt > Date.now()) return cache.entries;
|
||||
if (!cache.inFlight) {
|
||||
cache.inFlight = fetchCatalog().then((entries) => {
|
||||
cache.entries = entries;
|
||||
cache.expiresAt = Date.now() + CATALOG_TTL_MS;
|
||||
return entries;
|
||||
}).finally(() => {
|
||||
cache.inFlight = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await cache.inFlight;
|
||||
} catch (error) {
|
||||
if (cache.entries.length > 0) return cache.entries;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const query = (searchParams.get("q") ?? "").slice(0, 120);
|
||||
const provider = (searchParams.get("provider") ?? "").slice(0, 120);
|
||||
const baseUrl = (searchParams.get("baseUrl") ?? "").slice(0, 500);
|
||||
const parsedLimit = Number.parseInt(searchParams.get("limit") ?? "50", 10);
|
||||
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
|
||||
|
||||
try {
|
||||
const entries = await loadCatalog();
|
||||
const models = searchModelCatalog(entries, query, provider, limit);
|
||||
const recommendation = recommendModelCatalogPreset(entries, query, provider, baseUrl);
|
||||
return NextResponse.json({ models, recommendation, source: MODELS_DEV_URL });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveModelDiscoveryAuth } from "@/lib/model-discovery-auth";
|
||||
import { buildModelsListUrl, parseDiscoveredModels } from "@/lib/model-discovery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasHeader(headers: Headers, name: string): boolean {
|
||||
return headers.has(name);
|
||||
}
|
||||
|
||||
function buildHeaders(api: string, apiKey: string | undefined, configured: Record<string, string>): Headers {
|
||||
const headers = new Headers(configured);
|
||||
if (!hasHeader(headers, "accept")) headers.set("Accept", "application/json");
|
||||
if (!apiKey) return headers;
|
||||
|
||||
if (api === "anthropic-messages") {
|
||||
if (!hasHeader(headers, "x-api-key")) headers.set("x-api-key", apiKey);
|
||||
if (!hasHeader(headers, "anthropic-version")) headers.set("anthropic-version", "2023-06-01");
|
||||
} else if (api === "google-generative-ai") {
|
||||
if (!hasHeader(headers, "x-goog-api-key")) headers.set("x-goog-api-key", apiKey);
|
||||
} else if (!hasHeader(headers, "authorization")) {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ error: "provider is required" }, { status: 400 });
|
||||
|
||||
const baseUrl = typeof body.provider.baseUrl === "string" ? body.provider.baseUrl.trim() : "";
|
||||
if (!baseUrl) return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
|
||||
const api = typeof body.provider.api === "string" && body.provider.api
|
||||
? body.provider.api
|
||||
: "openai-completions";
|
||||
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = buildModelsListUrl(baseUrl, api);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Base URL is invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await resolveModelDiscoveryAuth(providerName, body.provider);
|
||||
if (typeof body.provider.apiKey === "string" && body.provider.apiKey.trim() && !auth.apiKey) {
|
||||
return NextResponse.json({ error: `No API key found for "${providerName}"` }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
cache: "no-store",
|
||||
headers: buildHeaders(api, auth.apiKey, auth.headers),
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({
|
||||
error: responseText.slice(0, 500) || `Upstream returned HTTP ${response.status}`,
|
||||
status: response.status,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(responseText);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Upstream model list was not valid JSON" }, { status: 502 });
|
||||
}
|
||||
const models = parseDiscoveredModels(payload);
|
||||
if (models.length === 0) {
|
||||
return NextResponse.json({ error: "No models found in the upstream response" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ models, endpoint: endpoint.toString() });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = error instanceof DOMException && error.name === "TimeoutError" ? 504 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getModelsPath(): string {
|
||||
return join(getAgentDir(), "models.json");
|
||||
}
|
||||
|
||||
function readModelsJson(): Record<string, unknown> {
|
||||
const path = getModelsPath();
|
||||
if (!existsSync(path)) return { providers: {} };
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { providers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeModelsJson(data: Record<string, unknown>): void {
|
||||
const path = getModelsPath();
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(readModelsJson());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
writeModelsJson(body);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { completeSimple, type AssistantMessage } from "@earendil-works/pi-ai/compat";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getAssistantText(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Content-Type must be application/json" },
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown; model?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ ok: false, error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ ok: false, error: "provider is required" }, { status: 400 });
|
||||
if (!isRecord(body.model)) return NextResponse.json({ ok: false, error: "model is required" }, { status: 400 });
|
||||
|
||||
const modelId = typeof body.model.id === "string" ? body.model.id.trim() : "";
|
||||
if (!modelId) return NextResponse.json({ ok: false, error: "Model ID is required" }, { status: 400 });
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-test-"));
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
writeFileSync(modelsPath, JSON.stringify({
|
||||
providers: {
|
||||
[providerName]: {
|
||||
...body.provider,
|
||||
models: [{ ...body.model, id: modelId }],
|
||||
},
|
||||
},
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const modelRuntime = await ModelRuntime.create({ modelsPath });
|
||||
const loadError = modelRuntime.getError();
|
||||
if (loadError) return NextResponse.json({ ok: false, error: loadError });
|
||||
|
||||
const model = modelRuntime.getModel(providerName, modelId);
|
||||
if (!model) return NextResponse.json({ ok: false, error: `Model not found: ${providerName}/${modelId}` });
|
||||
|
||||
const resolved = await modelRuntime.getAuth(model);
|
||||
if (!resolved?.auth.apiKey) {
|
||||
return NextResponse.json({ ok: false, error: `No API key found for "${providerName}"` });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||
let status: number | undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const message = await completeSimple(model, {
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: "Reply with OK only.",
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}, {
|
||||
apiKey: resolved.auth.apiKey,
|
||||
headers: resolved.auth.headers,
|
||||
maxTokens: 16,
|
||||
timeoutMs: TEST_TIMEOUT_MS,
|
||||
maxRetries: 0,
|
||||
cacheRetention: "none",
|
||||
signal: controller.signal,
|
||||
onResponse: (response) => { status = response.status; },
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: message.errorMessage ?? (controller.signal.aborted ? "Test timed out" : "Model returned an error"),
|
||||
latencyMs,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
latencyMs,
|
||||
status,
|
||||
responseText: getAssistantText(message).slice(0, 300),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ ok: false, error: errorMessage(error) }, { status: 500 });
|
||||
} finally {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { stat } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
import { loadModelsWithCache, withModelRuntimeError, type ModelsData } from "@/lib/models-cache";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { projectTrustReloadOptions } from "@/lib/project-trust";
|
||||
import { createAppSettingsManager, getAppResourceLoaderOptions } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function compareModelEntries(
|
||||
a: { id: string; name: string; provider: string },
|
||||
b: { id: string; name: string; provider: string }
|
||||
): number {
|
||||
return modelNameCollator.compare(a.name || a.id, b.name || b.id)
|
||||
|| modelNameCollator.compare(a.provider, b.provider)
|
||||
|| modelNameCollator.compare(a.id, b.id);
|
||||
}
|
||||
|
||||
const THINKING_SUFFIXES = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
|
||||
function stripThinkingSuffix(modelRef: string): string {
|
||||
const trimmed = modelRef.trim();
|
||||
const colonIndex = trimmed.lastIndexOf(":");
|
||||
if (colonIndex === -1) return trimmed;
|
||||
const suffix = trimmed.substring(colonIndex + 1);
|
||||
return THINKING_SUFFIXES.has(suffix) ? trimmed.substring(0, colonIndex) : trimmed;
|
||||
}
|
||||
|
||||
function filterByExactEnabledModels<T extends { id: string; provider: string }>(
|
||||
available: readonly T[],
|
||||
enabledModels: string[] | undefined,
|
||||
): readonly T[] {
|
||||
if (!enabledModels || enabledModels.length === 0) return available;
|
||||
|
||||
const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean));
|
||||
const visible = available.filter((m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id));
|
||||
return visible.length > 0 ? visible : available;
|
||||
}
|
||||
|
||||
async function loadModels(cwd: string): Promise<ModelsData> {
|
||||
const nameMap = new Map<string, string>();
|
||||
let modelList: { id: string; name: string; provider: string }[] = [];
|
||||
let defaultModel: { provider: string; modelId: string } | null = null;
|
||||
const thinkingLevels: Record<string, string[]> = {};
|
||||
const thinkingLevelMaps: Record<string, Record<string, string | null>> = {};
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
// Gate untrusted project extensions: enumerating models still imports and
|
||||
// runs a repository's .pi/extensions factories, so honor project trust here
|
||||
// too (see lib/project-trust.ts, #236).
|
||||
const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir);
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: createAppSettingsManager(cwd, agentDir),
|
||||
resourceLoaderOptions: getAppResourceLoaderOptions(),
|
||||
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}),
|
||||
});
|
||||
const available = await services.modelRuntime.getAvailable();
|
||||
const modelError = services.modelRuntime.getError();
|
||||
const settings: SettingsManager = services.settingsManager;
|
||||
const enabledModels = settings.getEnabledModels();
|
||||
const visible = filterByExactEnabledModels(available, enabledModels);
|
||||
modelList = visible.map((m: { id: string; name: string; provider: string }) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: m.provider,
|
||||
})).sort(compareModelEntries);
|
||||
for (const m of visible) {
|
||||
const key = `${m.provider}:${m.id}`;
|
||||
nameMap.set(key, m.name);
|
||||
thinkingLevels[key] = getSupportedThinkingLevels(m);
|
||||
if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap;
|
||||
}
|
||||
|
||||
const provider = settings.getDefaultProvider();
|
||||
const modelId = settings.getDefaultModel();
|
||||
if (provider && modelId && visible.some((m) => m.provider === provider && m.id === modelId)) {
|
||||
defaultModel = { provider, modelId };
|
||||
}
|
||||
|
||||
return withModelRuntimeError(
|
||||
{ models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, thinkingLevelMaps },
|
||||
modelError,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_MODELS: ModelsData = {
|
||||
models: {},
|
||||
modelList: [],
|
||||
defaultModel: null,
|
||||
thinkingLevels: {},
|
||||
thinkingLevelMaps: {},
|
||||
};
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const requestedCwd = new URL(req.url).searchParams.get("cwd") || process.cwd();
|
||||
const cwd = resolve(requestedCwd);
|
||||
|
||||
let cwdStat;
|
||||
try {
|
||||
cwdStat = await stat(cwd);
|
||||
} catch {
|
||||
return Response.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
if (!cwdStat.isDirectory()) {
|
||||
return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd)));
|
||||
} catch {
|
||||
return Response.json(EMPTY_MODELS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "path";
|
||||
import {
|
||||
DefaultPackageManager,
|
||||
getAgentDir,
|
||||
SettingsManager,
|
||||
type PackageSource,
|
||||
type ResolvedPaths,
|
||||
type ResolvedResource,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
import { getProjectTrustStatus } from "@/lib/project-trust";
|
||||
import { createAppSettingsManager, isManagedPath, isManagedRuntime } from "@/lib/app-runtime";
|
||||
import type {
|
||||
PluginDiagnostic,
|
||||
PluginPackageInfo,
|
||||
PluginResourceCounts,
|
||||
PluginResourceInfo,
|
||||
PluginResourceKind,
|
||||
PluginScope,
|
||||
PluginsResponse,
|
||||
} from "@/lib/api-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PluginAction = "install" | "remove" | "update" | "disable" | "enable";
|
||||
|
||||
function emptyCounts(): PluginResourceCounts {
|
||||
return { extensions: 0, skills: 0, prompts: 0, themes: 0 };
|
||||
}
|
||||
|
||||
function toPluginScope(scope: string): PluginScope {
|
||||
return scope === "project" ? "project" : "global";
|
||||
}
|
||||
|
||||
function keyFor(source: string, scope: PluginScope): string {
|
||||
return `${scope}\0${source}`;
|
||||
}
|
||||
|
||||
function getPackageSource(entry: PackageSource): string {
|
||||
return typeof entry === "string" ? entry : entry.source;
|
||||
}
|
||||
|
||||
function isDisabledPackage(entry: PackageSource): boolean {
|
||||
if (typeof entry === "string") return false;
|
||||
return (
|
||||
Array.isArray(entry.extensions) && entry.extensions.length === 0 &&
|
||||
Array.isArray(entry.skills) && entry.skills.length === 0 &&
|
||||
Array.isArray(entry.prompts) && entry.prompts.length === 0 &&
|
||||
Array.isArray(entry.themes) && entry.themes.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
function getDisabledPackages(settingsManager: SettingsManager): Map<string, boolean> {
|
||||
const disabled = new Map<string, boolean>();
|
||||
for (const entry of settingsManager.getGlobalSettings().packages ?? []) {
|
||||
disabled.set(keyFor(getPackageSource(entry), "global"), isDisabledPackage(entry));
|
||||
}
|
||||
for (const entry of settingsManager.getProjectSettings().packages ?? []) {
|
||||
disabled.set(keyFor(getPackageSource(entry), "project"), isDisabledPackage(entry));
|
||||
}
|
||||
return disabled;
|
||||
}
|
||||
|
||||
function setPackageDisabled(
|
||||
settingsManager: SettingsManager,
|
||||
source: string,
|
||||
scope: PluginScope,
|
||||
disabled: boolean,
|
||||
): boolean {
|
||||
const current = scope === "project"
|
||||
? settingsManager.getProjectSettings().packages ?? []
|
||||
: settingsManager.getGlobalSettings().packages ?? [];
|
||||
let changed = false;
|
||||
const next = current.map((entry): PackageSource => {
|
||||
if (getPackageSource(entry) !== source) return entry;
|
||||
changed = true;
|
||||
if (disabled) {
|
||||
return {
|
||||
...(typeof entry === "string" ? { source: entry } : entry),
|
||||
extensions: [],
|
||||
skills: [],
|
||||
prompts: [],
|
||||
themes: [],
|
||||
};
|
||||
}
|
||||
return getPackageSource(entry);
|
||||
});
|
||||
if (!changed) return false;
|
||||
if (scope === "project") settingsManager.setProjectPackages(next);
|
||||
else settingsManager.setPackages(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
function addCount(counts: PluginResourceCounts, kind: keyof PluginResourceCounts): void {
|
||||
counts[kind] += 1;
|
||||
}
|
||||
|
||||
function getResourceName(path: string, kind: PluginResourceKind): string {
|
||||
const file = basename(path);
|
||||
const ext = extname(file);
|
||||
if (kind === "skill" && file.toLowerCase() === "skill.md") return basename(dirname(path));
|
||||
if ((kind === "extension" || kind === "theme" || kind === "prompt") && ext) {
|
||||
if (kind === "extension" && /^index\.(ts|js)$/.test(file)) return basename(dirname(path));
|
||||
return file.slice(0, -ext.length);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function getRelativePath(resource: ResolvedResource): string {
|
||||
const baseDir = resource.metadata.baseDir;
|
||||
if (!baseDir) return resource.path;
|
||||
const rel = relative(baseDir, resource.path);
|
||||
return rel && !rel.startsWith("..") ? rel : resource.path;
|
||||
}
|
||||
|
||||
function getConfiguredVersion(source: string): string | undefined {
|
||||
const npmSpec = source.startsWith("npm:") ? source.slice(4) : undefined;
|
||||
if (npmSpec) {
|
||||
const lastAt = npmSpec.lastIndexOf("@");
|
||||
const packageNameEnd = npmSpec.startsWith("@") ? npmSpec.indexOf("/", 1) : 0;
|
||||
if (lastAt > packageNameEnd) return npmSpec.slice(lastAt + 1) || undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (source.startsWith("git:") || /^[a-z]+:\/\//.test(source)) {
|
||||
const lastAt = source.lastIndexOf("@");
|
||||
const lastSlash = source.lastIndexOf("/");
|
||||
const lastColon = source.lastIndexOf(":");
|
||||
if (lastAt > Math.max(lastSlash, lastColon)) return source.slice(lastAt + 1) || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPackageMetadata(installedPath?: string): { packageName?: string; version?: string } {
|
||||
if (!installedPath) return {};
|
||||
try {
|
||||
const stats = statSync(installedPath);
|
||||
const packageJsonPath = stats.isDirectory()
|
||||
? join(installedPath, "package.json")
|
||||
: join(dirname(installedPath), "package.json");
|
||||
if (!existsSync(packageJsonPath)) return {};
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: unknown;
|
||||
version?: unknown;
|
||||
};
|
||||
return {
|
||||
packageName: typeof parsed.name === "string" ? parsed.name : undefined,
|
||||
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function collectResource(
|
||||
resource: ResolvedResource,
|
||||
kind: keyof PluginResourceCounts,
|
||||
countsByPackage: Map<string, PluginResourceCounts>,
|
||||
resourcesByPackage: Map<string, PluginResourceInfo[]>,
|
||||
totals: PluginResourceCounts,
|
||||
): void {
|
||||
if (!isManagedPath(resource.path)) return;
|
||||
if (!resource.enabled || resource.metadata.origin !== "package") return;
|
||||
const source = resource.metadata.source;
|
||||
const scope = toPluginScope(resource.metadata.scope);
|
||||
const key = keyFor(source, scope);
|
||||
const counts = countsByPackage.get(key) ?? emptyCounts();
|
||||
addCount(counts, kind);
|
||||
addCount(totals, kind);
|
||||
countsByPackage.set(key, counts);
|
||||
const resources = resourcesByPackage.get(key) ?? [];
|
||||
const resourceKind = kind === "extensions"
|
||||
? "extension"
|
||||
: kind === "skills"
|
||||
? "skill"
|
||||
: kind === "prompts"
|
||||
? "prompt"
|
||||
: "theme";
|
||||
resources.push({
|
||||
kind: resourceKind,
|
||||
name: getResourceName(resource.path, resourceKind),
|
||||
path: resource.path,
|
||||
relativePath: getRelativePath(resource),
|
||||
});
|
||||
resourcesByPackage.set(key, resources);
|
||||
}
|
||||
|
||||
function collectResources(paths: ResolvedPaths): {
|
||||
countsByPackage: Map<string, PluginResourceCounts>;
|
||||
resourcesByPackage: Map<string, PluginResourceInfo[]>;
|
||||
totals: PluginResourceCounts;
|
||||
} {
|
||||
const countsByPackage = new Map<string, PluginResourceCounts>();
|
||||
const resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
||||
const totals = emptyCounts();
|
||||
for (const resource of paths.extensions) collectResource(resource, "extensions", countsByPackage, resourcesByPackage, totals);
|
||||
for (const resource of paths.skills) collectResource(resource, "skills", countsByPackage, resourcesByPackage, totals);
|
||||
for (const resource of paths.prompts) collectResource(resource, "prompts", countsByPackage, resourcesByPackage, totals);
|
||||
for (const resource of paths.themes) collectResource(resource, "themes", countsByPackage, resourcesByPackage, totals);
|
||||
return { countsByPackage, resourcesByPackage, totals };
|
||||
}
|
||||
|
||||
async function readPlugins(cwd: string): Promise<PluginsResponse> {
|
||||
const agentDir = getAgentDir();
|
||||
const projectTrust = getProjectTrustStatus(cwd, agentDir);
|
||||
const settingsManager = createAppSettingsManager(cwd, agentDir, projectTrust.trusted);
|
||||
const packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
|
||||
const diagnostics: PluginDiagnostic[] = [];
|
||||
let countsByPackage = new Map<string, PluginResourceCounts>();
|
||||
let resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
||||
let totals = emptyCounts();
|
||||
const disabledByPackage = getDisabledPackages(settingsManager);
|
||||
|
||||
try {
|
||||
const resolved = await packageManager.resolve(async (source) => {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
source,
|
||||
message: "Package is configured but not installed yet.",
|
||||
});
|
||||
return "skip";
|
||||
});
|
||||
({ countsByPackage, resourcesByPackage, totals } = collectResources(resolved));
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
const packages = packageManager.listConfiguredPackages().map((pkg) => {
|
||||
const scope = toPluginScope(pkg.scope);
|
||||
const key = keyFor(pkg.source, scope);
|
||||
const disabled = disabledByPackage.get(key) ?? false;
|
||||
const counts = countsByPackage.get(key) ?? emptyCounts();
|
||||
const resources = resourcesByPackage.get(key) ?? [];
|
||||
const resourceCount = counts.extensions + counts.skills + counts.prompts + counts.themes;
|
||||
const packageMetadata = readPackageMetadata(pkg.installedPath);
|
||||
if (!pkg.installedPath) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
source: pkg.source,
|
||||
message: "Configured package path was not found.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
source: pkg.source,
|
||||
scope,
|
||||
filtered: pkg.filtered,
|
||||
disabled,
|
||||
installedPath: pkg.installedPath,
|
||||
packageName: packageMetadata.packageName,
|
||||
version: packageMetadata.version,
|
||||
configuredVersion: getConfiguredVersion(pkg.source),
|
||||
counts,
|
||||
resources,
|
||||
status: disabled ? "disabled" : resourceCount > 0 ? "loaded" : pkg.installedPath ? "installed" : "missing",
|
||||
} satisfies PluginPackageInfo;
|
||||
});
|
||||
|
||||
return {
|
||||
packages,
|
||||
totals,
|
||||
diagnostics,
|
||||
projectResourcesLoaded: projectTrust.trusted,
|
||||
};
|
||||
}
|
||||
|
||||
function readScope(scope: unknown): PluginScope {
|
||||
if (isManagedRuntime()) return "global";
|
||||
return scope === "project" ? "project" : "global";
|
||||
}
|
||||
|
||||
function isRemotePackageSource(source: string): boolean {
|
||||
return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const cwd = searchParams.get("cwd");
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(await readPlugins(cwd));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/plugins body: { action, source?, scope?, cwd }
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
action?: PluginAction;
|
||||
source?: string;
|
||||
scope?: PluginScope;
|
||||
cwd?: string;
|
||||
};
|
||||
if (!body.cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
if (!body.action) return NextResponse.json({ error: "action required" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const projectTrust = getProjectTrustStatus(body.cwd, agentDir);
|
||||
const settingsManager = createAppSettingsManager(body.cwd, agentDir, projectTrust.trusted);
|
||||
const scope = readScope(body.scope);
|
||||
if (scope === "project" && !projectTrust.trusted) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project resources must be trusted before modifying project plugins" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const packageManager = new DefaultPackageManager({
|
||||
cwd: body.cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
const source = body.source?.trim();
|
||||
const local = scope === "project";
|
||||
|
||||
if (
|
||||
source &&
|
||||
isManagedRuntime() &&
|
||||
!isRemotePackageSource(source) &&
|
||||
!isManagedPath(resolve(body.cwd, source))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Local plugins must be stored inside the integrated application directory" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (body.action === "install") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
await packageManager.installAndPersist(source, { local });
|
||||
} else if (body.action === "remove") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
await packageManager.removeAndPersist(source, { local });
|
||||
} else if (body.action === "update") {
|
||||
await packageManager.update(source);
|
||||
} else if (body.action === "disable") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
setPackageDisabled(settingsManager, source, scope, true);
|
||||
await settingsManager.flush();
|
||||
} else if (body.action === "enable") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
setPackageDisabled(settingsManager, source, scope, false);
|
||||
await settingsManager.flush();
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unsupported action: ${body.action}` }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await readPlugins(body.cwd));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { stat } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
import { getProjectTrustStatus, trustProject } from "@/lib/project-trust";
|
||||
import { destroyRpcSessionsForCwd, hasBusyRpcSessionForCwd } from "@/lib/rpc-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function validateCwd(value: unknown): Promise<
|
||||
{ cwd: string } | { response: NextResponse }
|
||||
> {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return { response: NextResponse.json({ error: "cwd required" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
const cwd = resolve(value);
|
||||
try {
|
||||
if (!(await stat(cwd)).isDirectory()) {
|
||||
return { response: NextResponse.json({ error: "cwd must be a directory" }, { status: 400 }) };
|
||||
}
|
||||
} catch {
|
||||
return { response: NextResponse.json({ error: "Directory does not exist" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const result = await validateCwd(new URL(req.url).searchParams.get("cwd"));
|
||||
if ("response" in result) return result.response;
|
||||
return NextResponse.json(getProjectTrustStatus(result.cwd, getAgentDir()));
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: unknown };
|
||||
const result = await validateCwd(body.cwd);
|
||||
if ("response" in result) return result.response;
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const current = getProjectTrustStatus(result.cwd, agentDir);
|
||||
if (!current.requiresTrust) {
|
||||
return NextResponse.json({ error: "This project has no resources that require trust" }, { status: 409 });
|
||||
}
|
||||
if (hasBusyRpcSessionForCwd(result.cwd)) {
|
||||
return NextResponse.json({ error: "Wait for the active session to finish before trusting this project" }, { status: 409 });
|
||||
}
|
||||
|
||||
const status = trustProject(result.cwd, agentDir);
|
||||
invalidateModelsCache();
|
||||
destroyRpcSessionsForCwd(result.cwd);
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
getManagedRuntimePaths,
|
||||
isManagedRuntime,
|
||||
} from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
if (!isManagedRuntime()) {
|
||||
return Response.json({ managed: false, agentDir: getAgentDir() });
|
||||
}
|
||||
|
||||
const paths = getManagedRuntimePaths();
|
||||
return Response.json({
|
||||
managed: true,
|
||||
appRoot: paths.appRoot,
|
||||
dataDir: paths.dataDir,
|
||||
agentDir: paths.agentDir,
|
||||
resourcesDir: paths.resourcesDir,
|
||||
skillRoots: paths.managedSkillRoots,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
import { generateSessionTitle } from "@/lib/session-title";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
const existing = getRpcSession(id);
|
||||
const { session } = existing?.isAlive()
|
||||
? { session: existing }
|
||||
: await startRpcSession(id, filePath, cwd);
|
||||
|
||||
// globalThis keeps wrappers alive across dev hot reloads; older instances
|
||||
// may predate waitUntilReady(), but those have already completed startup.
|
||||
await session.waitUntilReady?.();
|
||||
const result = await generateSessionTitle(session.inner as unknown as AgentSession);
|
||||
|
||||
if (!session.isAlive()) {
|
||||
return NextResponse.json(
|
||||
{ error: "The session was closed while its title was being generated. Please try again." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
session.inner.setSessionName(result.title);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ title: result.title, usage: result.usage ?? null });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import { resolveSessionPath, buildSessionContext } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const url = new URL(req.url);
|
||||
const leafId = url.searchParams.get("leafId") ?? undefined;
|
||||
const deferThinking = url.searchParams.has("deferThinking");
|
||||
const deferToolResultImages = url.searchParams.has("deferMedia");
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const context = buildSessionContext(sm.getEntries() as never, leafId, {
|
||||
deferThinking,
|
||||
deferToolResultImages,
|
||||
});
|
||||
|
||||
return NextResponse.json({ context });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionEntries, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string; entryId: string }> },
|
||||
) {
|
||||
const { id, entryId } = await params;
|
||||
const blockIndexParam = new URL(req.url).searchParams.get("blockIndex");
|
||||
const blockIndex = blockIndexParam === null ? Number.NaN : Number(blockIndexParam);
|
||||
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) {
|
||||
return NextResponse.json({ error: "Valid blockIndex is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
|
||||
// SessionManager-backed parsing preserves the SDK's malformed-line tolerance.
|
||||
const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId);
|
||||
if (!entry || entry.type !== "message" || entry.message.role !== "assistant") {
|
||||
return NextResponse.json({ error: "Assistant message not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const block = entry.message.content[blockIndex];
|
||||
if (!block || block.type !== "thinking") {
|
||||
return NextResponse.json({ error: "Thinking block not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ thinking: block.thinking });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { execFile } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { promisify } from "util";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PiCodingAgentModule = {
|
||||
getPackageDir: () => string;
|
||||
};
|
||||
|
||||
type ExportHtmlModule = {
|
||||
exportFromFile: (inputPath: string, outputPath: string) => Promise<string>;
|
||||
};
|
||||
|
||||
async function getPiPackageDir(): Promise<string | null> {
|
||||
try {
|
||||
const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule;
|
||||
return getPackageDir();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(fileName: string, inline: boolean): string {
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html";
|
||||
const disposition = inline ? "inline" : "attachment";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
async function getPiCliPath(): Promise<string | null> {
|
||||
const candidates = new Set<string>();
|
||||
const packageDir = await getPiPackageDir();
|
||||
|
||||
if (packageDir) {
|
||||
candidates.add(join(packageDir, "dist", "cli.js"));
|
||||
}
|
||||
|
||||
try {
|
||||
const resolver = (import.meta as ImportMeta & {
|
||||
resolve?: (specifier: string) => string | Promise<string>;
|
||||
}).resolve;
|
||||
if (typeof resolver === "function") {
|
||||
const indexUrl = await resolver("@earendil-works/pi-coding-agent");
|
||||
candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js"));
|
||||
}
|
||||
} catch {
|
||||
// Next.js production bundles can strip import.meta.resolve.
|
||||
}
|
||||
|
||||
candidates.add(
|
||||
join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js"
|
||||
)
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the exported HTML to fix recursive functions that overflow
|
||||
* the call stack on deep linear session trees (e.g., 5000+ entries).
|
||||
*
|
||||
* ## Root Cause
|
||||
* pi-coding-agent's template.js uses recursive helpers to render and
|
||||
* navigate the session tree in the exported HTML:
|
||||
*
|
||||
* 1. sortChildren(node) — recursively sorts children of every node.
|
||||
* Calls itself via node.children.forEach(sortChildren).
|
||||
* On a 5527-entry linear chain (no branches), this recurses 5527
|
||||
* levels deep → stack overflow.
|
||||
*
|
||||
* 2. mapNodes(node) — recursively indexes tree nodes the first time
|
||||
* a tree item is clicked. Same depth -> same overflow.
|
||||
*
|
||||
* 3. markActive(node) — recursively marks nodes on the active path.
|
||||
* Calls itself via markActive(child) for each child.
|
||||
* Same depth → same overflow.
|
||||
*
|
||||
* Both functions are inlined in the HTML by pi-coding-agent at export
|
||||
* time. We cannot modify template.js directly (it's in node_modules
|
||||
* and would be overwritten on npm install). Instead, we patch the
|
||||
* generated HTML string before returning it to the client.
|
||||
*
|
||||
* ## Fix
|
||||
* Replace each recursive function with an iterative equivalent:
|
||||
*
|
||||
* sortChildren → explicit stack (DFS pre-order, push children in
|
||||
* reverse to maintain order)
|
||||
* mapNodes → explicit stack (DFS pre-order)
|
||||
* markActive → two-stack post-order (stack1 for traversal,
|
||||
* stack2 for processing children before parent)
|
||||
*
|
||||
* ## Line Ending Normalization
|
||||
* This file (route.ts) uses CRLF (Windows), while template.js uses LF
|
||||
* (Unix). The template strings in the backtick literals inherit the
|
||||
* file's CRLF line endings. At runtime, readFileSync() also returns
|
||||
* CRLF on Windows. We normalize everything to LF before matching.
|
||||
*
|
||||
* The helper `n(s)` strips \r\n → \n on both the HTML and the
|
||||
* replacement strings, ensuring cross-platform matching.
|
||||
*/
|
||||
function patchExportHtml(html: string): string {
|
||||
// Normalize line endings: route.ts is CRLF, template.js is LF.
|
||||
// Without this, the replace() below would fail on Windows.
|
||||
const n = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
html = n(html);
|
||||
|
||||
const replaceRequired = (source: string, name: string, search: string, replacement: string) => {
|
||||
const normalizedSearch = n(search);
|
||||
const normalizedReplacement = n(replacement);
|
||||
const matches = source.split(normalizedSearch).length - 1;
|
||||
if (matches !== 1) {
|
||||
throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`);
|
||||
}
|
||||
return source.replace(normalizedSearch, normalizedReplacement);
|
||||
};
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"sortChildren",
|
||||
` function sortChildren(node) {
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
node.children.forEach(sortChildren);
|
||||
}`,
|
||||
` function sortChildren(root) {
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"mapNodes",
|
||||
` function mapNodes(node) {
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
node.children.forEach(mapNodes);
|
||||
}
|
||||
tree.forEach(mapNodes);`,
|
||||
` const stack = [...tree].reverse();
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"markActive",
|
||||
` function markActive(node) {
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (markActive(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
return has;
|
||||
}`,
|
||||
` function markActive(root) {
|
||||
// Post-order traversal using two stacks
|
||||
const stack1 = [root];
|
||||
const stack2 = [];
|
||||
while (stack1.length) {
|
||||
const node = stack1.pop();
|
||||
stack2.push(node);
|
||||
for (const child of node.children) {
|
||||
stack1.push(child);
|
||||
}
|
||||
}
|
||||
while (stack2.length) {
|
||||
const node = stack2.pop();
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (containsActive.get(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async function exportSession(filePath: string, outputPath: string): Promise<void> {
|
||||
const cliPath = await getPiCliPath();
|
||||
if (cliPath) {
|
||||
await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_OFFLINE: "1",
|
||||
PI_SKIP_VERSION_CHECK: "1",
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const packageDir = await getPiPackageDir();
|
||||
if (!packageDir) throw new Error("pi CLI not found");
|
||||
|
||||
const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href;
|
||||
const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule;
|
||||
await exportFromFile(filePath, outputPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const inline = new URL(req.url).searchParams.get("inline") === "1";
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const tempDir = join(tmpdir(), "pi-web-export");
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const sessionBase = basename(filePath, ".jsonl");
|
||||
const fileName = `pi-session-${sessionBase}.html`;
|
||||
const outputPath = join(tempDir, `${randomUUID()}.html`);
|
||||
|
||||
try {
|
||||
await exportSession(filePath, outputPath);
|
||||
|
||||
const html = readFileSync(outputPath, "utf8");
|
||||
const patchedHtml = patchExportHtml(html);
|
||||
return new Response(patchedHtml, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": getContentDisposition(fileName, inline),
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(outputPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
resolveSessionPath,
|
||||
resolveSessionIdByPath,
|
||||
invalidateSessionPathCache,
|
||||
invalidateSessionListCache,
|
||||
buildSessionContext,
|
||||
readSessionHeader,
|
||||
} from "@/lib/session-reader";
|
||||
import { sessionPathKey } from "@/lib/session-path";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
|
||||
// BranchNavigator still traverses recursively, so keep the response tree shallow.
|
||||
const MAX_PROJECTED_TREE_DEPTH = 200;
|
||||
|
||||
/**
|
||||
* Project the session tree into the shallow navigation tree sent to the client.
|
||||
* Keeps roots, branch points, and leaves while contracting single-child chains
|
||||
* without recursive traversal. Contracted entry IDs are attached to the next
|
||||
* visible node so the UI can still recognize an active leaf inside the chain.
|
||||
*/
|
||||
function projectTreeForResponse<T extends { entry: { id: string }; children: T[]; compressedEntryIds?: string[] }>(
|
||||
nodes: T[]
|
||||
): T[] {
|
||||
const keep = new Set<T>();
|
||||
const roots = new Set(nodes);
|
||||
const seen = new Set<T>();
|
||||
const stack = [...nodes];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (seen.has(node)) continue;
|
||||
seen.add(node);
|
||||
|
||||
if (
|
||||
roots.has(node) ||
|
||||
node.children.length !== 1
|
||||
) {
|
||||
keep.add(node);
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
const cloneNode = (node: T, compressedEntryIds?: string[]): T => ({
|
||||
...node,
|
||||
children: [],
|
||||
...(compressedEntryIds?.length ? { compressedEntryIds } : {}),
|
||||
});
|
||||
const projectedRoots = nodes.map((node) => cloneNode(node));
|
||||
const tasks = nodes.map((source, index) => ({
|
||||
source,
|
||||
projected: projectedRoots[index],
|
||||
depth: 1,
|
||||
}));
|
||||
|
||||
const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => {
|
||||
const pending = [{ node: source, compressedEntryIds: [] as string[] }];
|
||||
const flattenedSeen = new Set<T>();
|
||||
|
||||
while (pending.length > 0) {
|
||||
const { node, compressedEntryIds } = pending.pop()!;
|
||||
if (flattenedSeen.has(node)) continue;
|
||||
flattenedSeen.add(node);
|
||||
|
||||
if (keep.has(node)) {
|
||||
projectedParent.children.push(cloneNode(node, compressedEntryIds));
|
||||
}
|
||||
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
pending.push({
|
||||
node: node.children[i],
|
||||
compressedEntryIds: keep.has(node)
|
||||
? []
|
||||
: [...compressedEntryIds, node.entry.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while (tasks.length > 0) {
|
||||
const { source, projected, depth } = tasks.pop()!;
|
||||
|
||||
for (const sourceChild of source.children) {
|
||||
let child = sourceChild;
|
||||
|
||||
if (depth >= MAX_PROJECTED_TREE_DEPTH) {
|
||||
appendFlattenedKeptDescendants(child, projected);
|
||||
continue;
|
||||
}
|
||||
|
||||
const compressedEntryIds: string[] = [];
|
||||
while (!keep.has(child) && child.children.length === 1) {
|
||||
compressedEntryIds.push(child.entry.id);
|
||||
child = child.children[0];
|
||||
}
|
||||
|
||||
if (!keep.has(child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectedChild = cloneNode(child, compressedEntryIds);
|
||||
projected.children.push(projectedChild);
|
||||
tasks.push({ source: child, projected: projectedChild, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return projectedRoots;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const entries = sm.getEntries() as never;
|
||||
const leafId = sm.getLeafId();
|
||||
const tree = projectTreeForResponse(sm.getTree());
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const deferThinking = searchParams.has("deferThinking");
|
||||
const deferToolResultImages = searchParams.has("deferMedia");
|
||||
const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages });
|
||||
|
||||
const header = sm.getHeader();
|
||||
let modified = header?.timestamp ?? new Date().toISOString();
|
||||
try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ }
|
||||
const parentSessionId = header?.parentSession
|
||||
? await resolveSessionIdByPath(header.parentSession)
|
||||
: undefined;
|
||||
const info = header ? {
|
||||
path: filePath,
|
||||
id: header.id,
|
||||
cwd: header.cwd ?? "",
|
||||
name: sm.getSessionName(),
|
||||
created: header.timestamp,
|
||||
modified,
|
||||
messageCount: context.messages.length,
|
||||
firstMessage: context.messages.find((m) => m.role === "user")
|
||||
? (() => {
|
||||
const msg = context.messages.find((m) => m.role === "user")!;
|
||||
const c = (msg as { content: unknown }).content;
|
||||
return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)";
|
||||
})()
|
||||
: "(no messages)",
|
||||
parentSessionId,
|
||||
} : null;
|
||||
|
||||
return NextResponse.json({
|
||||
sessionId: id,
|
||||
filePath,
|
||||
info,
|
||||
leafId,
|
||||
tree,
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/sessions/[id] body: { name: string }
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { name } = await req.json() as { name?: string };
|
||||
if (typeof name !== "string") {
|
||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
}
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
const sm = SessionManager.open(filePath);
|
||||
sm.appendSessionInfo(name.trim());
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sessions/[id]
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Read only the bounded header before deleting.
|
||||
const parentSessionPath = readSessionHeader(filePath)?.parentSession;
|
||||
|
||||
// Re-attach all direct children to this session's parent (cascade re-parent)
|
||||
// Scan sibling files in the same directory
|
||||
const targetPathKey = sessionPathKey(filePath);
|
||||
const dir = dirname(filePath);
|
||||
try {
|
||||
const files = readdirSync(dir).filter(
|
||||
(file) => file.endsWith(".jsonl") && sessionPathKey(join(dir, file)) !== targetPathKey,
|
||||
);
|
||||
for (const file of files) {
|
||||
const childPath = join(dir, file);
|
||||
try {
|
||||
const content = readFileSync(childPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string };
|
||||
if (
|
||||
header.type === "session" &&
|
||||
header.parentSession &&
|
||||
sessionPathKey(header.parentSession) === targetPathKey
|
||||
) {
|
||||
// Rewrite header with new parentSession
|
||||
header.parentSession = parentSessionPath;
|
||||
lines[0] = JSON.stringify(header);
|
||||
writeFileSync(childPath, lines.join("\n"));
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
} catch { /* skip if dir unreadable */ }
|
||||
|
||||
getRpcSession(id)?.destroy();
|
||||
unlinkSync(filePath);
|
||||
invalidateSessionPathCache(id);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
if (!await resolveSessionPath(id)) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rpc = getRpcSession(id);
|
||||
if (!rpc?.isAlive()) return NextResponse.json({ running: false });
|
||||
|
||||
const state = await rpc.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listAllSessions } from "@/lib/session-reader";
|
||||
import { getRunningRpcSessionIds } from "@/lib/rpc-manager";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = await listAllSessions();
|
||||
return NextResponse.json({ sessions, runningSessionIds: getRunningRpcSessionIds() });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { SkillInstallScope } from "@/lib/api-types";
|
||||
import { checkSkillUpdates } from "@/lib/skill-updates";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
cwd?: unknown;
|
||||
package?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd : "";
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const pkg = typeof body.package === "string" ? body.package : undefined;
|
||||
const scope = body.scope === "global" || body.scope === "project"
|
||||
? body.scope as SkillInstallScope
|
||||
: undefined;
|
||||
if ((pkg && !scope) || (!pkg && scope)) {
|
||||
return NextResponse.json({ error: "package and scope must be provided together" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { skills } = await loadSkillsWithInstallInfo(cwd);
|
||||
const installs = skills
|
||||
.map((skill) => skill.install)
|
||||
.filter((install): install is NonNullable<typeof install> => Boolean(install))
|
||||
.filter((install) => !pkg || (install.package === pkg && install.scope === scope));
|
||||
|
||||
if (pkg && installs.length === 0) {
|
||||
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates = await checkSkillUpdates(installs, {
|
||||
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
|
||||
});
|
||||
return NextResponse.json({ updates });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
import { getProjectTrustStatus } from "@/lib/project-trust";
|
||||
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ANSI_RE = /\x1B\[[0-9;]*m/g;
|
||||
|
||||
// POST /api/skills/install body: { package: string; scope: "global" | "project"; cwd?: string }
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { package: pkg, scope, cwd } = await req.json() as { package?: string; scope?: string; cwd?: string };
|
||||
if (!pkg?.trim()) return NextResponse.json({ error: "package required" }, { status: 400 });
|
||||
|
||||
const isGlobal = isManagedRuntime() || scope !== "project";
|
||||
if (!isGlobal) {
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required for project install" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
if (!getProjectTrustStatus(cwd, getAgentDir()).trusted) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project resources must be trusted before installing project skills" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const args = ["skills", "add", pkg.trim(), "-y", "--agent", "pi"];
|
||||
if (isGlobal) args.push("-g");
|
||||
|
||||
console.log(`[skills/install] running: npx ${args.join(" ")}`);
|
||||
const { stdout, stderr } = await runNpx(args, {
|
||||
timeout: 60000,
|
||||
cwd: !isGlobal && cwd ? cwd : undefined,
|
||||
env: getSkillsCliEnvironment(),
|
||||
});
|
||||
|
||||
const output = (stdout + stderr).replace(ANSI_RE, "");
|
||||
const success = /Installation complete|Installed \d+ skill/.test(output);
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: output.slice(-300) || "Install failed" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ success: true, output });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = ((err.stdout ?? "") + (err.stderr ?? "")).replace(ANSI_RE, "");
|
||||
return NextResponse.json({ error: output || (err.message ?? String(e)) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/skills?cwd=<path>
|
||||
// Uses DefaultResourceLoader (same logic as AgentSession startup) so settings.json
|
||||
// skill paths, package skills, and .agents/skills directories are all included.
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const cwd = searchParams.get("cwd");
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(await loadSkillsWithInstallInfo(cwd));
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/skills — toggle disable-model-invocation on a SKILL.md file
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { filePath: string; disableModelInvocation: boolean };
|
||||
const { filePath, disableModelInvocation } = body;
|
||||
if (!filePath) return NextResponse.json({ error: "filePath required" }, { status: 400 });
|
||||
if (!existsSync(filePath)) return NextResponse.json({ error: "file not found" }, { status: 404 });
|
||||
const allowedRoots = new Set(await getAllowedFileRoots());
|
||||
allowedRoots.add(getAgentDir());
|
||||
if (isManagedRuntime()) {
|
||||
for (const root of getManagedRuntimePaths().managedSkillRoots) {
|
||||
if (existsSync(root)) allowedRoots.add(root);
|
||||
}
|
||||
} else {
|
||||
// Upstream-compatible mode keeps the CLI's user-wide skill root.
|
||||
const globalSkillsDir = path.join(homedir(), ".agents", "skills");
|
||||
if (existsSync(globalSkillsDir)) allowedRoots.add(globalSkillsDir);
|
||||
}
|
||||
if (!isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const key = "disable-model-invocation";
|
||||
|
||||
// Use parseFrontmatter to check current value, then do a surgical line edit
|
||||
// to preserve the original YAML formatting of all other fields.
|
||||
const { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);
|
||||
const alreadySet = Boolean(frontmatter[key]);
|
||||
|
||||
let updated = content;
|
||||
if (disableModelInvocation && !alreadySet) {
|
||||
// Add key after the opening --- line
|
||||
updated = content.replace(/^---\r?\n/, `---\n${key}: true\n`);
|
||||
// If no frontmatter exists, create one
|
||||
if (updated === content) updated = `---\n${key}: true\n---\n${content}`;
|
||||
} else if (!disableModelInvocation && alreadySet) {
|
||||
// Remove the key line entirely
|
||||
updated = content.replace(new RegExp(`^${key}\\s*:.*\\r?\\n`, "m"), "");
|
||||
}
|
||||
|
||||
writeFileSync(filePath, updated, "utf8");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import type { SkillSearchResult } from "@/lib/api-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ANSI_RE = /\x1B\[[0-9;]*m/g;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 50;
|
||||
const SEARCH_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh";
|
||||
|
||||
interface SkillsApiSkill {
|
||||
id?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
installs?: number;
|
||||
}
|
||||
|
||||
interface SkillsApiResponse {
|
||||
skills?: SkillsApiSkill[];
|
||||
}
|
||||
|
||||
function parseLimit(value: unknown): number {
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(num)) return DEFAULT_LIMIT;
|
||||
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(num)));
|
||||
}
|
||||
|
||||
function formatInstalls(count?: number): string {
|
||||
if (!count || count <= 0) return "";
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
|
||||
return `${count} install${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function parseSearchOutput(raw: string): SkillSearchResult[] {
|
||||
const clean = raw.replace(ANSI_RE, "");
|
||||
const results: SkillSearchResult[] = [];
|
||||
const lines = clean.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
// package line: "owner/repo@skill NNK installs"
|
||||
const pkgMatch = line.match(/^([\w.\-]+\/[\w.\-@:]+)\s+([\d.,]+[KMB]?\s+installs)$/);
|
||||
if (pkgMatch) {
|
||||
const urlLine = lines[i + 1]?.trim().replace(/^└\s*/, "");
|
||||
results.push({
|
||||
package: pkgMatch[1],
|
||||
installs: pkgMatch[2],
|
||||
url: urlLine?.startsWith("https://") ? urlLine : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function searchSkillsApi(query: string, limit: number): Promise<SkillSearchResult[]> {
|
||||
const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`skills.sh search failed: HTTP ${res.status}`);
|
||||
|
||||
const data = (await res.json()) as SkillsApiResponse;
|
||||
return (data.skills ?? [])
|
||||
.map((skill) => {
|
||||
const name = skill.name?.trim();
|
||||
const source = skill.source?.trim();
|
||||
const slug = skill.id?.trim();
|
||||
if (!name || (!source && !slug)) return null;
|
||||
|
||||
const pkg = `${source || slug}@${name}`;
|
||||
return {
|
||||
package: pkg,
|
||||
installs: formatInstalls(skill.installs),
|
||||
url: slug ? `${SEARCH_API_BASE}/${slug}` : "",
|
||||
};
|
||||
})
|
||||
.filter((skill): skill is SkillSearchResult => skill !== null)
|
||||
.sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs));
|
||||
}
|
||||
|
||||
function parseInstallCount(installs: string): number {
|
||||
const match = installs.match(/^([\d.]+)([KMB])?\s+installs?$/);
|
||||
if (!match) return 0;
|
||||
const value = Number(match[1]);
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1;
|
||||
return value * multiplier;
|
||||
}
|
||||
|
||||
// POST /api/skills/search body: { query: string, limit?: number }
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { query, limit: rawLimit } = await req.json() as { query?: string; limit?: unknown };
|
||||
if (!query?.trim()) return NextResponse.json({ error: "query required" }, { status: 400 });
|
||||
const limit = parseLimit(rawLimit);
|
||||
|
||||
try {
|
||||
const results = await searchSkillsApi(query.trim(), limit);
|
||||
return NextResponse.json({ results });
|
||||
} catch {
|
||||
const { stdout, stderr } = await runNpx(["skills", "find", query.trim()], {
|
||||
timeout: 20000,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
});
|
||||
|
||||
const results = parseSearchOutput(stdout + stderr).slice(0, limit);
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
||||
const raw = (err.stdout ?? "") + (err.stderr ?? "");
|
||||
const results = raw ? parseSearchOutput(raw) : [];
|
||||
if (results.length > 0) return NextResponse.json({ results });
|
||||
return NextResponse.json({ error: err.message ?? String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import type { SkillInstallScope } from "@/lib/api-types";
|
||||
import { buildSkillUpdateArgs } from "@/lib/skill-updates";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
cwd?: unknown;
|
||||
package?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd : "";
|
||||
const pkg = typeof body.package === "string" ? body.package : "";
|
||||
const scope = body.scope === "global" || body.scope === "project"
|
||||
? body.scope as SkillInstallScope
|
||||
: undefined;
|
||||
if (!cwd || !pkg || !scope) {
|
||||
return NextResponse.json({ error: "cwd, package, and scope are required" }, { status: 400 });
|
||||
}
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { skills } = await loadSkillsWithInstallInfo(cwd);
|
||||
const skill = skills.find(
|
||||
(item) => item.install?.package === pkg && item.install.scope === scope,
|
||||
);
|
||||
if (!skill?.install) {
|
||||
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
|
||||
}
|
||||
if (!skill.install.canCheckForUpdates) {
|
||||
return NextResponse.json({ error: "This skill cannot be updated automatically" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await runNpx(buildSkillUpdateArgs(skill.install), {
|
||||
timeout: 60_000,
|
||||
cwd: !isManagedRuntime() && scope === "project" ? cwd : undefined,
|
||||
env: getSkillsCliEnvironment(),
|
||||
});
|
||||
|
||||
const refreshed = await loadSkillsWithInstallInfo(cwd);
|
||||
const updatedSkill = refreshed.skills.find(
|
||||
(item) => item.install?.package === pkg && item.install.scope === scope,
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skill: updatedSkill,
|
||||
output: `${stdout}${stderr}`.slice(-500),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const detail = error as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = `${detail.stdout ?? ""}${detail.stderr ?? ""}`;
|
||||
return NextResponse.json(
|
||||
{ error: output || detail.message || String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { addWorktree, listWorktrees, removeWorktree, resolveProject } from "@/lib/worktree";
|
||||
import { allowFileRoot, getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed } from "@/lib/file-access";
|
||||
|
||||
/** Same gate as /api/files: only session cwds / project roots / explicitly
|
||||
* allowed dirs may be inspected or mutated through this endpoint. */
|
||||
async function checkCwdAllowed(cwd: string): Promise<NextResponse | null> {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots) || !isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/worktrees?cwd= → { projectRoot, isGit, isTopLevel, worktrees }
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const cwd = new URL(req.url).searchParams.get("cwd");
|
||||
if (!cwd) {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(cwd);
|
||||
if (denied) return denied;
|
||||
|
||||
const project = await resolveProject(cwd);
|
||||
let worktrees: Awaited<ReturnType<typeof listWorktrees>> = [];
|
||||
let isGit = true;
|
||||
try {
|
||||
// For a removed-worktree cwd (session of a deleted worktree), fall back
|
||||
// to the inferred project root so the switcher still shows the project.
|
||||
worktrees = await listWorktrees(existsSync(cwd) ? cwd : project.projectRoot);
|
||||
} catch {
|
||||
isGit = false;
|
||||
}
|
||||
// Every listed path is a git-verified worktree of this project; allow the
|
||||
// file explorer to browse them even before they have any session (the
|
||||
// in-memory allowlist from addWorktree does not survive server restarts).
|
||||
for (const w of worktrees) allowFileRoot(w.path);
|
||||
return NextResponse.json({
|
||||
projectRoot: project.projectRoot,
|
||||
isGit,
|
||||
isTopLevel: project.isTopLevel,
|
||||
worktrees,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/worktrees body: { cwd, branch } → { path, branch }
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; branch?: string };
|
||||
if (!body.cwd || typeof body.cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!body.branch || typeof body.branch !== "string") {
|
||||
return NextResponse.json({ error: "branch is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(body.cwd);
|
||||
if (denied) return denied;
|
||||
if (!existsSync(body.cwd)) {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${body.cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await addWorktree(body.cwd, body.branch);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/worktrees body: { cwd, path, force? }
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; path?: string; force?: boolean };
|
||||
if (!body.cwd || typeof body.cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!body.path || typeof body.path !== "string") {
|
||||
return NextResponse.json({ error: "path is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(body.cwd);
|
||||
if (denied) return denied;
|
||||
|
||||
await removeWorktree(body.cwd, body.path, body.force === true);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// git refuses to remove dirty worktrees without --force; surface that so
|
||||
// the UI can offer a force-remove confirmation.
|
||||
const dirty = /contains modified or untracked files|is dirty/i.test(message);
|
||||
return NextResponse.json({ error: message, dirty }, { status: dirty ? 409 : 400 });
|
||||
}
|
||||
}
|
||||
+1375
-2464
File diff suppressed because it is too large
Load Diff
+2202
-3446
File diff suppressed because it is too large
Load Diff
@@ -207,14 +207,11 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
isNew,
|
||||
sessionIdRef, messagesEndRef, scrollContainerRef,
|
||||
lastUserMsgRef,
|
||||
atBottom,
|
||||
followStreamRef,
|
||||
handleSend, handleAbort, handleFork, handleNavigate, handleModelChange,
|
||||
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
|
||||
handleRecallQueue,
|
||||
handleBuiltinSlashCommand,
|
||||
handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands,
|
||||
scrollToBottom,
|
||||
} = useAgentSession({
|
||||
session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked,
|
||||
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen,
|
||||
@@ -262,30 +259,6 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
container.scrollTop = restoreScrollTop(container.scrollHeight, prevScrollDistanceRef.current);
|
||||
prevScrollDistanceRef.current = null;
|
||||
}, [visibleCount, scrollContainerRef]);
|
||||
|
||||
// Live-follow the model's streaming output: pin the streaming bubble to
|
||||
// the bottom of the viewport on every chunk while auto-follow is engaged
|
||||
// (the default). Scrolling away from the bottom flips followStreamRef off
|
||||
// inside useAgentSession; scrolling back to the bottom or clicking the
|
||||
// scroll-to-bottom button re-engages it.
|
||||
const streamBubbleRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!streamState.isStreaming || !streamState.streamingMessage) return;
|
||||
if (!followStreamRef.current) return;
|
||||
streamBubbleRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||
}, [streamState]);
|
||||
|
||||
// Jump back to the newest content: scroll to the streaming bubble while a
|
||||
// stream is live (its bottom edge is the live bottom), otherwise to the
|
||||
// end of the conversation. Also re-engages auto-follow.
|
||||
const handleScrollToConversationBottom = useCallback(() => {
|
||||
followStreamRef.current = true;
|
||||
if (streamState.isStreaming && streamState.streamingMessage) {
|
||||
streamBubbleRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
} else {
|
||||
scrollToBottom("smooth");
|
||||
}
|
||||
}, [streamState, followStreamRef, scrollToBottom]);
|
||||
// Push session stats up to AppShell for the top bar.
|
||||
// Compare scalar fields to avoid loops from new object identity each render.
|
||||
const statsKey = sessionStats
|
||||
@@ -368,36 +341,6 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
onSteer={agentRunning ? handleSteer : undefined}
|
||||
onFollowUp={agentRunning ? handleFollowUp : undefined}
|
||||
onPromptWithStreamingBehavior={agentRunning ? handlePromptWithStreamingBehavior : undefined}
|
||||
scrollToBottomButton={
|
||||
isEmptyNew ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollToConversationBottom}
|
||||
aria-label={t("chat.scrollToBottom")}
|
||||
title={t("chat.scrollToBottom")}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
alignSelf: "flex-end",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
opacity: atBottom ? 0.55 : 1,
|
||||
transition: "opacity 0.15s, background 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
isStreaming={sessionBusy}
|
||||
model={displayModelValue}
|
||||
isAutoModelSelection={isAutoModelSelection}
|
||||
@@ -740,9 +683,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
);
|
||||
})()}
|
||||
{streamState.isStreaming && streamState.streamingMessage && (
|
||||
<div ref={streamBubbleRef}>
|
||||
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
|
||||
</div>
|
||||
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
|
||||
)}
|
||||
|
||||
{agentRunning && !streamState.streamingMessage && (
|
||||
@@ -769,6 +710,10 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentRunning && (
|
||||
<div style={{ height: scrollContainerRef.current ? scrollContainerRef.current.clientHeight : "80vh" }} />
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ExtensionInfo, ExtensionsResponse } from "@/lib/api-types";
|
||||
|
||||
function shortenPath(path: string): string {
|
||||
return path.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
function statusColor(status: ExtensionInfo["status"]): string {
|
||||
if (status === "enabled") return "var(--accent)";
|
||||
if (status === "blocked") return "#d97706";
|
||||
return "var(--text-dim)";
|
||||
}
|
||||
|
||||
function statusLabel(status: ExtensionInfo["status"], t: ReturnType<typeof useI18n>["t"]): string {
|
||||
if (status === "enabled") return t("extensions.enabled");
|
||||
if (status === "blocked") return t("extensions.blocked");
|
||||
return t("extensions.disabled");
|
||||
}
|
||||
|
||||
function scopeLabel(scope: ExtensionInfo["scope"], t: ReturnType<typeof useI18n>["t"]): string {
|
||||
if (scope === "project") return t("extensions.project");
|
||||
if (scope === "builtin") return t("extensions.builtin");
|
||||
return t("extensions.app");
|
||||
}
|
||||
|
||||
function extensionKey(extension: ExtensionInfo): string {
|
||||
return `${extension.scope}:${extension.path}`;
|
||||
}
|
||||
|
||||
export function ExtensionsConfig({ cwd, onCloseAction }: { cwd: string; onCloseAction: () => void }) {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ExtensionsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const loadExtensions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/extensions?cwd=${encodeURIComponent(cwd)}`);
|
||||
const next = (await response.json()) as ExtensionsResponse & { error?: string };
|
||||
if (!response.ok || next.error) throw new Error(next.error ?? `HTTP ${response.status}`);
|
||||
setData(next);
|
||||
setSelected((current) => (
|
||||
current && next.extensions.some((extension) => extensionKey(extension) === current)
|
||||
? current
|
||||
: next.extensions[0] ? extensionKey(next.extensions[0]) : null
|
||||
));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cwd]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadExtensions();
|
||||
}, [loadExtensions]);
|
||||
|
||||
const extensions = useMemo(() => data?.extensions ?? [], [data?.extensions]);
|
||||
const selectedExtension = extensions.find((extension) => extensionKey(extension) === selected) ?? null;
|
||||
const enabledCount = extensions.filter((extension) => extension.status === "enabled").length;
|
||||
const blockedCount = extensions.filter((extension) => extension.status === "blocked").length;
|
||||
const disabledCount = extensions.filter((extension) => extension.status === "disabled").length;
|
||||
const hasBlockedProjectExtensions = blockedCount > 0 && !data?.projectResourcesLoaded;
|
||||
const groupedExtensions = useMemo(() => {
|
||||
const groups: Array<{ scope: ExtensionInfo["scope"]; extensions: ExtensionInfo[] }> = [];
|
||||
for (const scope of ["project", "builtin", "global"] as const) {
|
||||
const scoped = extensions.filter((extension) => extension.scope === scope);
|
||||
if (scoped.length > 0) groups.push({ scope, extensions: scoped });
|
||||
}
|
||||
return groups;
|
||||
}, [extensions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onCloseAction();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "calc(100vw - 16px)" : 860,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
height: isMobile ? "calc(100dvh - 16px)" : "76vh",
|
||||
maxHeight: "calc(100dvh - 16px)",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("common.extensions")}</span>
|
||||
<code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{shortenPath(cwd)}
|
||||
</code>
|
||||
</div>
|
||||
<button type="button" onClick={onCloseAction} aria-label={t("i18n.close")} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasBlockedProjectExtensions && (
|
||||
<div style={{ padding: "8px 18px", borderBottom: "1px solid var(--border)", color: "#d97706", fontSize: 11 }}>
|
||||
{t("extensions.projectResourcesBlocked")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
|
||||
<div style={{ width: isMobile ? "100%" : 245, maxHeight: isMobile ? "40vh" : undefined, borderRight: isMobile ? "none" : "1px solid var(--border)", borderBottom: isMobile ? "1px solid var(--border)" : "none", display: "flex", flexDirection: "column", flexShrink: 0, background: "var(--bg-panel)" }}>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.loading")}</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 11, color: "#ef4444" }}>{error}</div>
|
||||
) : groupedExtensions.length === 0 ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>{t("extensions.noExtensions")}</div>
|
||||
) : (
|
||||
groupedExtensions.map((group) => (
|
||||
<div key={group.scope} style={{ marginBottom: 6 }}>
|
||||
<div style={{ padding: "4px 8px 3px", fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase" }}>
|
||||
{scopeLabel(group.scope, t)}
|
||||
</div>
|
||||
{group.extensions.map((extension) => {
|
||||
const key = extensionKey(extension);
|
||||
const isSelected = selected === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => setSelected(key)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
|
||||
onMouseEnter={(event) => { if (!isSelected) event.currentTarget.style.background = "var(--bg-hover)"; }}
|
||||
onMouseLeave={(event) => { if (!isSelected) event.currentTarget.style.background = "none"; }}
|
||||
>
|
||||
<span style={{ flexShrink: 0, width: 7, height: 7, borderRadius: "50%", background: statusColor(extension.status) }} />
|
||||
<span style={{ minWidth: 0, flex: 1, fontSize: 12, fontWeight: isSelected ? 600 : 400, color: extension.status === "disabled" ? "var(--text-dim)" : "var(--text)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{extension.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
|
||||
{selectedExtension ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20, maxWidth: 680 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor(selectedExtension.status) }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--text)", fontFamily: "var(--font-mono)" }}>{selectedExtension.name}</span>
|
||||
<span style={{ fontSize: 10, padding: "1px 5px", borderRadius: 3, background: "rgba(120,120,120,0.12)", color: "var(--text-dim)" }}>{scopeLabel(selectedExtension.scope, t)}</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(100px, 130px) minmax(0, 1fr)", gap: "9px 14px", fontSize: 12, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("extensions.status")}</div>
|
||||
<div style={{ color: statusColor(selectedExtension.status) }}>{statusLabel(selectedExtension.status, t)}</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("extensions.source")}</div>
|
||||
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>{selectedExtension.source}</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("extensions.path")}</div>
|
||||
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>{shortenPath(selectedExtension.path)}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !loading && !error ? (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>{t("extensions.noExtensions")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{data ? `${extensions.length} ${t("extensions.total")} · ${enabledCount} ${t("extensions.enabled")} · ${disabledCount} ${t("extensions.disabled")} · ${blockedCount} ${t("extensions.blocked")}${data.diagnostics.length ? ` · ${data.diagnostics.length} ${t("extensions.diagnostics")}` : ""}` : ""}
|
||||
</div>
|
||||
<button type="button" onClick={() => void loadExtensions()} disabled={loading} style={{ padding: "6px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: loading ? "not-allowed" : "pointer", opacity: loading ? 0.5 : 1, fontSize: 12 }}>{t("i18n.refresh")}</button>
|
||||
<button type="button" onClick={onCloseAction} style={{ padding: "6px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", fontSize: 12 }}>{t("i18n.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,6 @@ interface Props {
|
||||
sourceSessionId?: string | null;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void;
|
||||
onEditorSaved?: (filePath: string) => void;
|
||||
gitRefreshKey?: number;
|
||||
initialDisplayMode?: DisplayMode;
|
||||
}
|
||||
@@ -208,16 +207,6 @@ function getFileApiUrl(
|
||||
return `/api/files/${encoded}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
function getFileWriteApiUrl(
|
||||
filePath: string,
|
||||
sourceSessionId?: string | null,
|
||||
): string {
|
||||
const encoded = encodeFilePathForApi(filePath);
|
||||
const searchParams = new URLSearchParams({ type: "write" });
|
||||
if (sourceSessionId) searchParams.set("sessionId", sourceSessionId);
|
||||
return `/api/files/${encoded}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -794,7 +783,7 @@ function DocumentViewer({ filePath, cwd, sourceSessionId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
if (isImagePath(filePath)) {
|
||||
return <ImageViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
|
||||
}
|
||||
@@ -804,10 +793,10 @@ export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMenti
|
||||
if (isDocumentPreviewPath(filePath)) {
|
||||
return <DocumentViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
|
||||
}
|
||||
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} onEditorSaved={onEditorSaved} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
|
||||
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
|
||||
}
|
||||
|
||||
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<FileData | null>(null);
|
||||
@@ -821,13 +810,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const gitDiffRequestRef = useRef(0);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [selectedLineRange, setSelectedLineRange] = useState<SelectedLineRange | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||
|
||||
const fetchContent = useCallback((filePath: string) => {
|
||||
return fetch(getFileApiUrl(filePath, "read", sourceSessionId))
|
||||
@@ -986,88 +969,6 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
mentionLineRange(selectedLineRange);
|
||||
}, [mentionLineRange, selectedLineRange]);
|
||||
|
||||
const isDeleted = isDeletedDiff;
|
||||
// Every text file routed to this viewer (i.e. non-image/audio/document) can
|
||||
// be edited; the backend independently rejects binary/read-only types.
|
||||
const editable = !isDeleted && !!data;
|
||||
const dirty = isEditing && draft !== (data?.content ?? "");
|
||||
|
||||
const stopEditing = useCallback(() => {
|
||||
setConfirmDiscard(false);
|
||||
setIsEditing(false);
|
||||
setSaveError(null);
|
||||
setDraft("");
|
||||
}, []);
|
||||
|
||||
const exitEditingGuard = useCallback((handler: () => void) => {
|
||||
if (dirty) {
|
||||
setConfirmDiscard(true);
|
||||
return;
|
||||
}
|
||||
stopEditing();
|
||||
handler();
|
||||
}, [dirty, stopEditing]);
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
if (!data) return;
|
||||
setDraft(data.content);
|
||||
setSaveError(null);
|
||||
setConfirmDiscard(false);
|
||||
setIsEditing(true);
|
||||
requestAnimationFrame(() => editorRef.current?.focus());
|
||||
}, [data]);
|
||||
|
||||
const saveFile = useCallback(async () => {
|
||||
if (saving || !dirty) {
|
||||
if (!dirty && isEditing) stopEditing();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
getFileWriteApiUrl(filePath, sourceSessionId),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: draft }),
|
||||
},
|
||||
);
|
||||
const result = await response.json().catch(() => ({})) as { size?: number; error?: string };
|
||||
if (!response.ok) {
|
||||
setSaveError(result.error ?? `HTTP ${response.status}`);
|
||||
return;
|
||||
}
|
||||
setData((prev) => (prev ? { ...prev, content: draft, size: result.size ?? prev.size } : prev));
|
||||
stopEditing();
|
||||
void fetchGitDiff(filePath);
|
||||
onEditorSaved?.(filePath);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [dirty, draft, filePath, fetchGitDiff, isEditing, onEditorSaved, saving, sourceSessionId, stopEditing]);
|
||||
|
||||
// Reset edit state whenever the file path or content source changes.
|
||||
useEffect(() => {
|
||||
stopEditing();
|
||||
setSaveError(null);
|
||||
setSaving(false);
|
||||
}, [filePath, sourceSessionId, stopEditing]);
|
||||
|
||||
// Ctrl/Cmd+S saves while editing.
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat || event.key.toLowerCase() !== "s" || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void saveFile();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isEditing, saveFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onMentionLines || displayMode !== "source") return;
|
||||
|
||||
@@ -1168,7 +1069,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => exitEditingGuard(() => setDisplayMode(mode))}
|
||||
onClick={() => setDisplayMode(mode)}
|
||||
title={mode === "diff" ? t("i18n.compareHead") : undefined}
|
||||
aria-pressed={active}
|
||||
className="file-viewer-mode-button"
|
||||
@@ -1185,65 +1086,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
)}
|
||||
|
||||
<div className="file-viewer-actions">
|
||||
{effectiveDisplayMode === "source" && !isEditing && editable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
title={t("i18n.editFile")}
|
||||
aria-label={t("i18n.editFile")}
|
||||
className="file-viewer-icon-button"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveFile()}
|
||||
disabled={saving || !dirty}
|
||||
title={t("i18n.saveShortcut")}
|
||||
aria-label={t("i18n.saveFile")}
|
||||
className="file-viewer-icon-button"
|
||||
style={{
|
||||
color: dirty ? "#22c55e" : "var(--text-muted)",
|
||||
opacity: saving || !dirty ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite" }} aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 1 1-5.7-8.4" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
|
||||
<path d="M17 21v-8H7v8" />
|
||||
<path d="M7 3v5h8" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (dirty) setConfirmDiscard(true);
|
||||
else stopEditing();
|
||||
}}
|
||||
title={t("i18n.stopEditing")}
|
||||
aria-label={t("i18n.stopEditing")}
|
||||
className="file-viewer-icon-button"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{effectiveDisplayMode === "source" && !isEditing && (
|
||||
{effectiveDisplayMode === "source" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1285,88 +1128,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
|
||||
{/* Content area */}
|
||||
<div ref={contentRef} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
|
||||
{isEditing ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
||||
{saveError && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 12px",
|
||||
fontSize: 11,
|
||||
color: "#f87171",
|
||||
background: "color-mix(in srgb, #ef4444 8%, var(--bg))",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{t("i18n.saveError", { error: saveError })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSaveError(null)}
|
||||
title={t("i18n.close")}
|
||||
style={{ marginLeft: "auto", background: "none", border: "none", color: "var(--text-dim)", cursor: "pointer", padding: 2, display: "flex" }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="m6 6 12 12" />
|
||||
<path d="M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
aria-label={t("i18n.editFile")}
|
||||
style={{
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
minHeight: 0,
|
||||
resize: "none",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "var(--bg)",
|
||||
color: "var(--text)",
|
||||
padding: "14px 16px",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre",
|
||||
overflow: "auto",
|
||||
tabSize: 2,
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Tab inserts spaces instead of moving focus.
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
const indent = " ";
|
||||
const next = draft.slice(0, start) + indent + draft.slice(end);
|
||||
setDraft(next);
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = el.selectionEnd = start + indent.length;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : effectiveDisplayMode === "diff" && hasGitDiff ? (
|
||||
{effectiveDisplayMode === "diff" && hasGitDiff ? (
|
||||
<DiffView patch={gitDiff.patch!} />
|
||||
) : isHtml && effectiveDisplayMode === "preview" ? (
|
||||
<iframe
|
||||
@@ -1475,54 +1237,6 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmDiscard && (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("i18n.discardChanges")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.32)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "min(360px, calc(100% - 48px))",
|
||||
background: "var(--bg-panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
padding: "16px 16px 12px",
|
||||
boxShadow: "0 12px 32px rgba(0,0,0,0.28)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.45, color: "var(--text)" }}>
|
||||
{t("i18n.closeWithoutSaving")}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDiscard(false)}
|
||||
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "var(--bg)", color: "var(--text)", cursor: "pointer", fontSize: 11 }}
|
||||
>
|
||||
{t("i18n.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopEditing}
|
||||
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "transparent", color: "var(--text-muted)", cursor: "pointer", fontSize: 11 }}
|
||||
>
|
||||
{t("i18n.discardChanges")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,723 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
interface McpServerEntry {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
socket?: string;
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
auth?: "oauth" | "bearer" | false;
|
||||
bearerToken?: string;
|
||||
bearerTokenEnv?: string;
|
||||
lifecycle?: "keep-alive" | "lazy" | "lazy-keep-alive" | "eager";
|
||||
idleTimeout?: number;
|
||||
requestTimeoutMs?: number;
|
||||
exposeResources?: boolean;
|
||||
directTools?: boolean | string[];
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
debug?: boolean;
|
||||
trace?: boolean;
|
||||
disabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface McpConfigFile {
|
||||
mcpServers: Record<string, McpServerEntry>;
|
||||
settings?: Record<string, unknown>;
|
||||
imports?: string[];
|
||||
}
|
||||
|
||||
type TransportKind = "stdio" | "http";
|
||||
|
||||
function entrySummary(entry: McpServerEntry): string {
|
||||
if (entry.url) {
|
||||
try {
|
||||
return new URL(entry.url).host || entry.url;
|
||||
} catch {
|
||||
return entry.url;
|
||||
}
|
||||
}
|
||||
if (entry.socket) return entry.socket;
|
||||
const cmd = entry.command ?? "";
|
||||
const args = entry.args?.length ? ` ${entry.args.join(" ")}` : "";
|
||||
return `${cmd}${args}`;
|
||||
}
|
||||
|
||||
function transportOf(entry: McpServerEntry): TransportKind {
|
||||
return entry.url ? "http" : "stdio";
|
||||
}
|
||||
|
||||
function envToRows(env?: Record<string, string>): Array<{ key: string; value: string }> {
|
||||
return Object.entries(env ?? {}).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
function rowsToEnv(rows: Array<{ key: string; value: string }>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
const key = row.key.trim();
|
||||
if (key) env[key] = row.value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// ── Form helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "6px 9px",
|
||||
background: "var(--bg-panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
color: "var(--text)",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<label style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({ value, onChange, placeholder, mono }: { value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) {
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{ ...inputStyle, fontFamily: mono ? "var(--font-mono)" : "inherit" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArea({
|
||||
value,
|
||||
onChange,
|
||||
rows,
|
||||
placeholder,
|
||||
mono,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
rows: number;
|
||||
placeholder?: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
...inputStyle,
|
||||
resize: "vertical",
|
||||
lineHeight: 1.5,
|
||||
fontFamily: mono ? "var(--font-mono)" : "inherit",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
return <input type="number" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} style={inputStyle} />;
|
||||
}
|
||||
|
||||
function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: readonly string[] }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={{ ...inputStyle, color: value ? "var(--text)" : "var(--text-dim)" }}>
|
||||
<option value="">— {t("i18n.default")} —</option>
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonStyle(disabled?: boolean, danger?: boolean): React.CSSProperties {
|
||||
return {
|
||||
padding: "6px 12px",
|
||||
background: danger ? "rgba(239,68,68,0.08)" : "none",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
color: danger ? "#ef4444" : "var(--text-muted)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
fontSize: 12,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Env row editor ───────────────────────────────────────────────────────────
|
||||
|
||||
function EnvEditor({ rows, onChange }: { rows: Array<{ key: string; value: string }>; onChange: (rows: Array<{ key: string; value: string }>) => void }) {
|
||||
const { t } = useI18n();
|
||||
const update = (index: number, patch: Partial<{ key: string; value: string }>) => {
|
||||
onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
};
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) => update(i, { key: e.target.value })}
|
||||
placeholder="NAME"
|
||||
spellCheck={false}
|
||||
style={{ ...inputStyle, width: "38%", fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<input
|
||||
value={row.value}
|
||||
onChange={(e) => update(i, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
spellCheck={false}
|
||||
style={{ ...inputStyle, flex: 1, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(rows.filter((_, j) => j !== i))}
|
||||
title={t("i18n.remove")}
|
||||
style={{ ...buttonStyle(false, true), padding: "0 9px", flexShrink: 0 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => onChange([...rows, { key: "", value: "" }])} style={{ ...buttonStyle(false), alignSelf: "flex-start" }}>
|
||||
+ {t("mcp.addEnv")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Server detail form ───────────────────────────────────────────────────────
|
||||
|
||||
function ServerDetail({
|
||||
name,
|
||||
entry,
|
||||
onRename,
|
||||
onChange,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
entry: McpServerEntry;
|
||||
onRename: (newName: string) => void;
|
||||
onChange: (entry: McpServerEntry) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const transport = transportOf(entry);
|
||||
const [rawJson, setRawJson] = useState(() => JSON.stringify(entry, null, 2));
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [headersDraft, setHeadersDraft] = useState(() => JSON.stringify(entry.headers ?? {}, null, 2));
|
||||
const [headersInvalid, setHeadersInvalid] = useState(false);
|
||||
|
||||
const patch = (p: Partial<McpServerEntry>) => onChange({ ...entry, ...p });
|
||||
const num = (v: string): number | undefined => (v.trim() === "" ? undefined : Number(v));
|
||||
|
||||
const applyJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(rawJson) as McpServerEntry;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("object expected");
|
||||
setJsonError(null);
|
||||
onChange(parsed);
|
||||
setAdvancedOpen(false);
|
||||
} catch (e) {
|
||||
setJsonError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const setTransport = (kind: TransportKind) => {
|
||||
if (kind === transport) return;
|
||||
if (kind === "stdio") {
|
||||
const rest: McpServerEntry = { ...entry };
|
||||
delete rest.url;
|
||||
delete rest.headers;
|
||||
delete rest.auth;
|
||||
delete rest.bearerToken;
|
||||
delete rest.bearerTokenEnv;
|
||||
onChange(rest);
|
||||
} else {
|
||||
onChange({ ...entry, url: entry.url ?? "https://example.com/mcp" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18, maxWidth: 680 }}>
|
||||
{/* Header row: name + enable + delete */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<Field label={t("mcp.serverName")}>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => onRename(e.target.value)}
|
||||
spellCheck={false}
|
||||
style={{ ...inputStyle, width: 200, fontFamily: "var(--font-mono)", fontWeight: 600 }}
|
||||
/>
|
||||
<button type="button" onClick={onDelete} style={{ ...buttonStyle(false, true), flexShrink: 0 }}>
|
||||
{t("i18n.remove")}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--text-dim)" }}>{t("mcp.enabled")}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!entry.disabled}
|
||||
onChange={(e) => patch({ disabled: !e.target.checked })}
|
||||
style={{ width: 14, height: 14, accentColor: "var(--accent)", cursor: "pointer" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport */}
|
||||
<Field label={t("mcp.transport")}>
|
||||
<div style={{ display: "inline-flex", border: "1px solid var(--border)", borderRadius: 7, overflow: "hidden", alignSelf: "flex-start" }}>
|
||||
{(["stdio", "http"] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => setTransport(kind)}
|
||||
style={{
|
||||
padding: "5px 14px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
background: transport === kind ? "var(--accent)" : "transparent",
|
||||
color: transport === kind ? "#fff" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{kind.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{transport === "stdio" ? (
|
||||
<>
|
||||
<Field label={t("mcp.command")}>
|
||||
<TextInput value={entry.command ?? ""} onChange={(v) => patch({ command: v || undefined })} placeholder="npx" mono />
|
||||
</Field>
|
||||
<Field label={t("mcp.args")}>
|
||||
<TextArea
|
||||
value={(entry.args ?? []).join("\n")}
|
||||
onChange={(v) => patch({ args: v.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={4}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t("mcp.url")}>
|
||||
<TextInput value={entry.url ?? ""} onChange={(v) => patch({ url: v || undefined })} placeholder="https://…/mcp" mono />
|
||||
</Field>
|
||||
<Field label={t("mcp.headers")}>
|
||||
<TextArea
|
||||
value={headersDraft}
|
||||
onChange={(v) => {
|
||||
setHeadersDraft(v);
|
||||
try {
|
||||
const parsed = JSON.parse(v) as Record<string, string>;
|
||||
setHeadersInvalid(false);
|
||||
patch({ headers: parsed });
|
||||
} catch {
|
||||
setHeadersInvalid(true);
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
mono
|
||||
/>
|
||||
{headersInvalid && <div style={{ fontSize: 11, color: "#ef4444" }}>{t("mcp.invalidJson")}</div>}
|
||||
</Field>
|
||||
<Field label={t("mcp.auth")}>
|
||||
<Select
|
||||
value={entry.auth === undefined ? "" : String(entry.auth)}
|
||||
onChange={(v) => {
|
||||
if (v === "false") patch({ auth: false });
|
||||
else if (v === "") patch({ auth: undefined });
|
||||
else patch({ auth: v as "oauth" | "bearer" });
|
||||
}}
|
||||
options={["oauth", "bearer", "false"]}
|
||||
/>
|
||||
</Field>
|
||||
{entry.auth === "bearer" && (
|
||||
<Field label={t("mcp.bearerToken")}>
|
||||
<TextInput value={entry.bearerToken ?? ""} onChange={(v) => patch({ bearerToken: v || undefined })} mono />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* env */}
|
||||
<EnvEditor rows={envToRows(entry.env)} onChange={(rows) => patch({ env: rowsToEnv(rows) })} />
|
||||
|
||||
{/* Optional common fields */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0 14px" }}>
|
||||
<Field label={t("mcp.cwd")}>
|
||||
<TextInput value={entry.cwd ?? ""} onChange={(v) => patch({ cwd: v || undefined })} placeholder="${PI_AGENT_APP_ROOT}" mono />
|
||||
</Field>
|
||||
<Field label={t("mcp.lifecycle")}>
|
||||
<Select value={entry.lifecycle ?? ""} onChange={(v) => patch({ lifecycle: (v || undefined) as McpServerEntry["lifecycle"] })} options={["lazy", "keep-alive", "lazy-keep-alive", "eager"]} />
|
||||
</Field>
|
||||
<Field label={t("mcp.idleTimeout")}>
|
||||
<NumInput value={entry.idleTimeout === undefined ? "" : String(entry.idleTimeout)} onChange={(v) => patch({ idleTimeout: num(v) })} placeholder="5" />
|
||||
</Field>
|
||||
<Field label={t("mcp.requestTimeout")}>
|
||||
<NumInput value={entry.requestTimeoutMs === undefined ? "" : String(entry.requestTimeoutMs)} onChange={(v) => patch({ requestTimeoutMs: num(v) })} placeholder="120000" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Advanced raw JSON */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((o) => !o)}
|
||||
style={{ ...buttonStyle(false), alignSelf: "flex-start" }}
|
||||
>
|
||||
{advancedOpen ? "▾ " : "▸ "} {t("mcp.advanced")}
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<>
|
||||
<TextArea value={rawJson} onChange={(v) => { setRawJson(v); setJsonError(null); }} rows={12} mono />
|
||||
{jsonError && <div style={{ fontSize: 11, color: "#ef4444" }}>{t("mcp.invalidJson")}: {jsonError}</div>}
|
||||
<div>
|
||||
<button type="button" onClick={applyJson} style={{ ...buttonStyle(false), background: "var(--accent)", color: "#fff", borderColor: "var(--accent)" }}>
|
||||
{t("mcp.applyJson")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function McpConfig({ onClose }: { onClose: () => void }) {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useI18n();
|
||||
const [config, setConfig] = useState<McpConfigFile>({ mcpServers: {} });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [savedOk, setSavedOk] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [configPath, setConfigPath] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await fetch("/api/mcp");
|
||||
const data = (await res.json()) as { config?: McpConfigFile; path?: string; error?: string };
|
||||
if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
const next = data.config ?? { mcpServers: {} };
|
||||
setConfig(next);
|
||||
setConfigPath(data.path ?? "");
|
||||
setSelected((current) => (current && next.mcpServers[current] ? current : Object.keys(next.mcpServers)[0] ?? null));
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const updateServer = useCallback((name: string, entry: McpServerEntry) => {
|
||||
setConfig((prev) => ({ ...prev, mcpServers: { ...prev.mcpServers, [name]: entry } }));
|
||||
}, []);
|
||||
|
||||
const renameServer = useCallback((oldName: string, newName: string) => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed || trimmed === oldName) return;
|
||||
setConfig((prev) => {
|
||||
if (prev.mcpServers[trimmed]) return prev; // 同名已存在
|
||||
const mcpServers = { ...prev.mcpServers };
|
||||
mcpServers[trimmed] = mcpServers[oldName];
|
||||
delete mcpServers[oldName];
|
||||
return { ...prev, mcpServers };
|
||||
});
|
||||
setSelected(trimmed);
|
||||
}, []);
|
||||
|
||||
const deleteServer = useCallback((name: string) => {
|
||||
setConfig((prev) => {
|
||||
const mcpServers = { ...prev.mcpServers };
|
||||
delete mcpServers[name];
|
||||
return { ...prev, mcpServers };
|
||||
});
|
||||
setSelected((current) => {
|
||||
if (current !== name) return current;
|
||||
const remaining = Object.keys(config.mcpServers).filter((k) => k !== name);
|
||||
return remaining[0] ?? null;
|
||||
});
|
||||
}, [config.mcpServers]);
|
||||
|
||||
const addServer = useCallback(() => {
|
||||
let finalName = "new-server";
|
||||
let n = 1;
|
||||
while (config.mcpServers[finalName]) finalName = `new-server-${n++}`;
|
||||
setConfig((prev) => ({ ...prev, mcpServers: { ...prev.mcpServers, [finalName]: {} } }));
|
||||
setSelected(finalName);
|
||||
}, [config.mcpServers]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSavedOk(false);
|
||||
try {
|
||||
const res = await fetch("/api/mcp", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
const d = (await res.json()) as { success?: boolean; error?: string };
|
||||
if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`);
|
||||
else {
|
||||
setSavedOk(true);
|
||||
setTimeout(() => setSavedOk(false), 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
setSaveError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const names = useMemo(() => Object.entries(config.mcpServers), [config.mcpServers]);
|
||||
const selectedEntry = selected ? config.mcpServers[selected] : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
background: "rgba(0,0,0,0.35)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "calc(100vw - 16px)" : 860,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
height: isMobile ? "calc(100dvh - 16px)" : "78vh",
|
||||
maxHeight: "calc(100dvh - 16px)",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("common.mcp")}</span>
|
||||
<code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{configPath || "data/agent/mcp.json"}
|
||||
</code>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
|
||||
{/* Left: server list */}
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "100%" : 230,
|
||||
maxHeight: isMobile ? "40vh" : undefined,
|
||||
borderRight: isMobile ? "none" : "1px solid var(--border)",
|
||||
borderBottom: isMobile ? "1px solid var(--border)" : "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexShrink: 0,
|
||||
background: "var(--bg-panel)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.loading")}</div>
|
||||
) : names.length === 0 ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>{t("mcp.noServers")}</div>
|
||||
) : (
|
||||
names.map(([sName, sEntry]) => {
|
||||
const isSelected = selected === sName;
|
||||
return (
|
||||
<div
|
||||
key={sName}
|
||||
onClick={() => setSelected(sName)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.background = "none";
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: sEntry.disabled ? "var(--text-dim)" : "var(--accent)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
color: "var(--text)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{sName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-dim)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
marginTop: 2,
|
||||
}}
|
||||
title={entrySummary(sEntry)}
|
||||
>
|
||||
{entrySummary(sEntry) || (sEntry.disabled ? t("i18n.disabled") : "—")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* Add server */}
|
||||
<div style={{ borderTop: "1px solid var(--border)", padding: "8px 6px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addServer}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 5,
|
||||
width: "100%",
|
||||
padding: "6px 0",
|
||||
background: "none",
|
||||
border: "1px dashed var(--border)",
|
||||
borderRadius: 5,
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--accent)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
}}
|
||||
>
|
||||
+ {t("mcp.addServer")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: detail */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
|
||||
{loading ? null : selected && selectedEntry ? (
|
||||
<ServerDetail
|
||||
key={selected}
|
||||
name={selected}
|
||||
entry={selectedEntry}
|
||||
onRename={(n) => renameServer(selected, n)}
|
||||
onChange={(entry) => updateServer(selected, entry)}
|
||||
onDelete={() => deleteServer(selected)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>
|
||||
{t("mcp.selectServer")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{t("mcp.restartHint")}
|
||||
</div>
|
||||
{saveError && <span style={{ fontSize: 12, color: "#f87171", flexShrink: 0 }}>{saveError}</span>}
|
||||
<button onClick={onClose} style={buttonStyle(false)}>
|
||||
{t("i18n.close")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || savedOk}
|
||||
style={{
|
||||
position: "relative",
|
||||
padding: "6px 16px",
|
||||
minWidth: 92,
|
||||
background: savedOk ? "#16a34a" : saving ? "var(--bg-panel)" : "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
color: savedOk ? "#fff" : saving ? "var(--text-muted)" : "#fff",
|
||||
cursor: saving || savedOk ? "default" : "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{savedOk && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{savedOk ? t("i18n.saved") : saving ? t("i18n.saving") : t("i18n.save")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1783
-3491
File diff suppressed because it is too large
Load Diff
@@ -1,441 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
interface VisionConfigFile {
|
||||
backend?: "ollama" | "openai";
|
||||
ollama?: { host?: string; model?: string };
|
||||
openai?: { baseUrl?: string; apiKey?: string; model?: string };
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "6px 9px",
|
||||
background: "var(--bg-panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
color: "var(--text)",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<label
|
||||
style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
mono,
|
||||
type,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={type ?? "text"}
|
||||
style={{
|
||||
...inputStyle,
|
||||
fontFamily: mono ? "var(--font-mono)" : "inherit",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const saveButtonStyle = (primary: boolean): React.CSSProperties => ({
|
||||
padding: "6px 14px",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--border)",
|
||||
background: primary ? "var(--accent)" : "var(--bg-panel)",
|
||||
color: primary ? "#fff" : "var(--text)",
|
||||
});
|
||||
|
||||
/**
|
||||
* Vision backend settings (backend picker + fields + save), without modal
|
||||
* chrome. Embedded in the Models config panel and reused by the standalone
|
||||
* modal below.
|
||||
*/
|
||||
export function VisionConfigContent() {
|
||||
const { t } = useI18n();
|
||||
const [config, setConfig] = useState<VisionConfigFile>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [savedOk, setSavedOk] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await fetch("/api/vision-config");
|
||||
const data = (await res.json()) as {
|
||||
config?: VisionConfigFile;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok || data.error)
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
setConfig(data.config ?? {});
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSavedOk(false);
|
||||
try {
|
||||
const res = await fetch("/api/vision-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
const data = (await res.json()) as { success?: boolean; error?: string };
|
||||
if (!res.ok || !data.success)
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
setSavedOk(true);
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const backend = config.backend;
|
||||
const ollama = config.ollama ?? {};
|
||||
const openai = config.openai ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
padding: "14px 18px",
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{t("vision.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Backend picker */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["ollama", "openai"] as const).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setConfig((prev) => ({ ...prev, backend: b }))}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
border: `1px solid ${backend === b ? "var(--accent)" : "var(--border)"}`,
|
||||
background:
|
||||
backend === b
|
||||
? "color-mix(in srgb, var(--accent) 8%, var(--bg-panel))"
|
||||
: "var(--bg-panel)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{b === "ollama"
|
||||
? t("vision.backend.ollama")
|
||||
: t("vision.backend.openai")}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{b === "ollama"
|
||||
? t("vision.backend.ollamaHint")
|
||||
: t("vision.backend.openaiHint")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No backend selected yet */}
|
||||
{!backend && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
background: "var(--bg-panel)",
|
||||
border: "1px dashed var(--border)",
|
||||
borderRadius: 6,
|
||||
padding: "8px 10px",
|
||||
}}
|
||||
>
|
||||
{t("vision.notConfigured")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ollama settings */}
|
||||
{backend === "ollama" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Field label={t("vision.ollama.host")}>
|
||||
<TextInput
|
||||
value={ollama.host ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama, host: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.ollama.hostPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("vision.ollama.model")}>
|
||||
<TextInput
|
||||
value={ollama.model ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama, model: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.ollama.modelPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI-compatible settings */}
|
||||
{backend === "openai" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Field label={t("vision.openai.baseUrl")}>
|
||||
<TextInput
|
||||
value={openai.baseUrl ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai, baseUrl: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.openai.baseUrlPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("vision.openai.apiKey")}>
|
||||
<TextInput
|
||||
value={openai.apiKey ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai, apiKey: v },
|
||||
}))
|
||||
}
|
||||
type="password"
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("vision.openai.model")}>
|
||||
<TextInput
|
||||
value={openai.model ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai, model: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.openai.modelPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
borderTop: "1px solid var(--border)",
|
||||
paddingTop: 10,
|
||||
}}
|
||||
>
|
||||
{t("vision.effective")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div style={{ fontSize: 11, color: "var(--danger, #e5484d)" }}>
|
||||
{t("vision.loadFailed")}: {saveError}
|
||||
</div>
|
||||
)}
|
||||
{savedOk && (
|
||||
<div style={{ fontSize: 11, color: "var(--success, #30a46c)" }}>
|
||||
{t("vision.saved")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving || loading}
|
||||
style={{
|
||||
...saveButtonStyle(true),
|
||||
opacity: saving || loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{t("vision.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Standalone modal wrapper (kept for backward compatibility). */
|
||||
export function VisionConfig({ onClose }: { onClose: () => void }) {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useI18n();
|
||||
const [configPath, setConfigPath] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/vision-config")
|
||||
.then((r) => r.json())
|
||||
.then((d: { path?: string }) => setConfigPath(d.path ?? ""))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
padding: 8,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "calc(100vw - 16px)" : 560,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
maxHeight: "calc(100dvh - 16px)",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<span
|
||||
style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}
|
||||
>
|
||||
{t("vision.title")}
|
||||
</span>
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{configPath || "data/agent/vision.json"}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
padding: "2px 6px",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<VisionConfigContent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1539
-2206
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const { configureHttpDispatcher } = await import("@/lib/http-dispatcher");
|
||||
configureHttpDispatcher();
|
||||
}
|
||||
+1
-35
@@ -1,20 +1,4 @@
|
||||
// Structural copy of pi-coding-agent's ResourceDiagnostic/ResourceCollision so
|
||||
// the frontend no longer depends on the pi packages (the backend owns them).
|
||||
export interface ResourceCollision {
|
||||
resourceType: "extension" | "skill" | "prompt" | "theme";
|
||||
name: string;
|
||||
winnerPath: string;
|
||||
loserPath: string;
|
||||
winnerSource?: string;
|
||||
loserSource?: string;
|
||||
}
|
||||
|
||||
export interface ResourceDiagnostic {
|
||||
type: "warning" | "error" | "collision";
|
||||
message: string;
|
||||
path?: string;
|
||||
collision?: ResourceCollision;
|
||||
}
|
||||
import type { ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface SkillSearchResult {
|
||||
package: string;
|
||||
@@ -119,21 +103,3 @@ export interface PluginsResponse {
|
||||
diagnostics: PluginDiagnostic[];
|
||||
projectResourcesLoaded: boolean;
|
||||
}
|
||||
|
||||
export type ExtensionScope = "global" | "project" | "builtin";
|
||||
export type ExtensionStatus = "enabled" | "disabled" | "blocked";
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
source: string;
|
||||
scope: ExtensionScope;
|
||||
status: ExtensionStatus;
|
||||
}
|
||||
|
||||
export interface ExtensionsResponse {
|
||||
extensions: ExtensionInfo[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
projectResourcesLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -39,6 +39,7 @@ test("reads small output and rejects oversized inline output before buffering it
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects symbolic links when opening bash output", async (t) => {
|
||||
const { readUtf8FileWithinLimit } = await loadSubject();
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-bash-output-link-"));
|
||||
@@ -8,27 +8,17 @@ async function loadSubject() {
|
||||
return import("./directory-browser.ts");
|
||||
}
|
||||
|
||||
test("lists directories and directory symlinks without returning files", async (t) => {
|
||||
test("lists directories and directory symlinks without returning files", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "pi-web-browse-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "project"));
|
||||
await writeFile(path.join(root, "notes.txt"), "test", "utf8");
|
||||
let symlinkCreated = false;
|
||||
try {
|
||||
await symlink(path.join(root, "project"), path.join(root, "linked-project"));
|
||||
symlinkCreated = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EPERM") throw error;
|
||||
t.diagnostic("Creating symbolic links requires additional privileges on this platform");
|
||||
}
|
||||
await symlink(path.join(root, "project"), path.join(root, "linked-project"));
|
||||
|
||||
const { listDirectories } = await loadSubject();
|
||||
const directories = await listDirectories(root);
|
||||
|
||||
assert.deepEqual(
|
||||
directories.map((entry) => entry.name),
|
||||
symlinkCreated ? ["linked-project", "project"] : ["project"],
|
||||
);
|
||||
assert.deepEqual(directories.map((entry) => entry.name), ["linked-project", "project"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -20,9 +20,7 @@ export function normalizeDirectory(directory: string): string {
|
||||
export function getParentDirectory(directory: string): string | null {
|
||||
const pathApi = /^[a-zA-Z]:[\\/]/.test(directory) || directory.startsWith("\\\\")
|
||||
? path.win32
|
||||
: directory.startsWith("/")
|
||||
? path.posix
|
||||
: path;
|
||||
: path;
|
||||
const normalized = pathApi.normalize(directory);
|
||||
const parent = pathApi.dirname(normalized);
|
||||
return parent === normalized ? null : parent;
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import test from "node:test";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
];
|
||||
|
||||
test("configures HTTP_PROXY, HTTPS_PROXY, and NO_PROXY for global fetch", async (t) => {
|
||||
const originalEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of PROXY_ENV_KEYS) delete process.env[key];
|
||||
|
||||
const connectTargets = [];
|
||||
const tunneledRequests = [];
|
||||
const proxy = createServer((req, res) => {
|
||||
res.writeHead(204, { Connection: "close" });
|
||||
res.end();
|
||||
});
|
||||
proxy.on("connect", (req, socket) => {
|
||||
connectTargets.push(req.url);
|
||||
if (req.url?.endsWith(":80")) {
|
||||
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
socket.once("data", (chunk) => {
|
||||
tunneledRequests.push(chunk.toString("utf8").split("\r\n", 1)[0]);
|
||||
socket.end("HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
||||
});
|
||||
return;
|
||||
}
|
||||
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
proxy.listen(0, "127.0.0.1");
|
||||
await once(proxy, "listening");
|
||||
|
||||
t.after(async () => {
|
||||
for (const [key, value] of originalEnv) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
proxy.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
});
|
||||
|
||||
const address = proxy.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const proxyUrl = `http://127.0.0.1:${address.port}`;
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
process.env.HTTPS_PROXY = proxyUrl;
|
||||
process.env.NO_PROXY = "bypass.invalid";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const { configureHttpDispatcher } = await jiti.import("./http-dispatcher.ts");
|
||||
const { getGlobalDispatcher } = await import("undici");
|
||||
|
||||
assert.throws(() => configureHttpDispatcher(-1), /Invalid HTTP idle timeout/);
|
||||
configureHttpDispatcher(2_000);
|
||||
|
||||
const dispatcher = getGlobalDispatcher();
|
||||
configureHttpDispatcher(5_000);
|
||||
assert.equal(getGlobalDispatcher(), dispatcher, "configuration should be idempotent");
|
||||
|
||||
const httpResponse = await fetch("http://target.invalid/through-http-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
assert.equal(httpResponse.status, 204);
|
||||
assert.deepEqual(connectTargets, ["target.invalid:80"]);
|
||||
assert.deepEqual(tunneledRequests, ["GET /through-http-proxy HTTP/1.1"]);
|
||||
|
||||
await assert.rejects(fetch("https://target.invalid/through-https-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}));
|
||||
assert.deepEqual(connectTargets, ["target.invalid:80", "target.invalid:443"]);
|
||||
|
||||
const proxiedRequestCount = connectTargets.length;
|
||||
await assert.rejects(fetch("http://bypass.invalid:9/no-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}));
|
||||
assert.equal(connectTargets.length, proxiedRequestCount);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as undici from "undici";
|
||||
|
||||
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
|
||||
|
||||
type DispatcherGlobal = typeof globalThis & {
|
||||
__piWebHttpDispatcherConfigured?: boolean;
|
||||
};
|
||||
|
||||
const dispatcherGlobal = globalThis as DispatcherGlobal;
|
||||
const originalGlobalFetch = globalThis.fetch;
|
||||
const ignoreUndiciDispatcherError = (): void => {};
|
||||
|
||||
function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.toLowerCase() === "disabled") return 0;
|
||||
if (trimmed.length === 0) return undefined;
|
||||
return parseHttpIdleTimeoutMs(Number(trimmed));
|
||||
}
|
||||
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
// Undici can emit an internal Client error while terminating a response body.
|
||||
// The body stream still rejects; this prevents the EventEmitter error from
|
||||
// terminating the Next.js process first.
|
||||
function withUndiciErrorListener<T extends undici.Dispatcher>(dispatcher: T): T {
|
||||
if (dispatcher instanceof EventEmitter) {
|
||||
EventEmitter.prototype.on.call(dispatcher, "error", ignoreUndiciDispatcherError);
|
||||
}
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
function createUndiciClient(origin: string | URL, options: object): undici.Dispatcher {
|
||||
return withUndiciErrorListener(
|
||||
new undici.Client(origin, options as undici.Client.Options),
|
||||
);
|
||||
}
|
||||
|
||||
function createUndiciOriginDispatcher(origin: string | URL, options: object): undici.Dispatcher {
|
||||
const dispatcherOptions = options as undici.Pool.Options;
|
||||
if (dispatcherOptions.connections === 1) {
|
||||
return createUndiciClient(origin, dispatcherOptions);
|
||||
}
|
||||
|
||||
return withUndiciErrorListener(
|
||||
new undici.Pool(origin, {
|
||||
...dispatcherOptions,
|
||||
factory: createUndiciClient,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function configureHttpDispatcher(
|
||||
timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS,
|
||||
): void {
|
||||
if (dispatcherGlobal.__piWebHttpDispatcherConfigured) return;
|
||||
|
||||
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
|
||||
if (normalizedTimeoutMs === undefined) {
|
||||
throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);
|
||||
}
|
||||
|
||||
const dispatcher = withUndiciErrorListener(
|
||||
new undici.EnvHttpProxyAgent({
|
||||
allowH2: false,
|
||||
bodyTimeout: normalizedTimeoutMs,
|
||||
headersTimeout: normalizedTimeoutMs,
|
||||
clientFactory: createUndiciClient,
|
||||
factory: createUndiciOriginDispatcher,
|
||||
}),
|
||||
);
|
||||
undici.setGlobalDispatcher(dispatcher);
|
||||
|
||||
// Keep fetch and the dispatcher on the same undici implementation. Preserve
|
||||
// an intentional fetch override installed after this module was loaded.
|
||||
if (globalThis.fetch === originalGlobalFetch) {
|
||||
undici.install?.();
|
||||
}
|
||||
|
||||
dispatcherGlobal.__piWebHttpDispatcherConfigured = true;
|
||||
}
|
||||
@@ -8,11 +8,8 @@ export const enLocale: LocalePlugin = {
|
||||
"common.ok": "OK",
|
||||
"common.language": "Language",
|
||||
"common.models": "Models",
|
||||
"common.mcp": "MCP",
|
||||
"common.skills": "Skills",
|
||||
"common.plugins": "Plugins",
|
||||
"common.extensions": "Extensions",
|
||||
"common.vision": "Vision",
|
||||
"sidebar.hide": "Hide sidebar",
|
||||
"sidebar.show": "Show sidebar",
|
||||
"theme.light": "Switch to light mode",
|
||||
@@ -45,7 +42,6 @@ export const enLocale: LocalePlugin = {
|
||||
"session.output": "Output",
|
||||
"session.cacheRead": "Cache Read",
|
||||
"session.cacheWrite": "Cache Write",
|
||||
"session.cacheHitRate": "Cache Hit Rate (Main Session)",
|
||||
"session.cost": "Cost",
|
||||
"session.context": "Context",
|
||||
"session.copy": "Copy {value}",
|
||||
@@ -240,48 +236,6 @@ export const enLocale: LocalePlugin = {
|
||||
"chat.commandCopy": "Copy the last assistant message",
|
||||
"chat.compacted": "Compacted",
|
||||
"chat.tokensSaved": "{saved} saved",
|
||||
"chat.scrollToBottom": "Scroll to bottom",
|
||||
"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",
|
||||
@@ -350,16 +304,6 @@ export const enLocale: LocalePlugin = {
|
||||
"i18n.mentionSelectedLines": "Mention selected lines",
|
||||
"i18n.disableWrap": "Disable word wrap",
|
||||
"i18n.enableWrap": "Enable word wrap",
|
||||
"i18n.editFile": "Edit file",
|
||||
"i18n.stopEditing": "Stop editing",
|
||||
"i18n.saveFile": "Save file",
|
||||
"i18n.saveShortcut": "Save (Ctrl/Cmd+S)",
|
||||
"i18n.discardChanges": "Discard unsaved changes",
|
||||
"i18n.saveError": "Could not save file: {error}",
|
||||
"i18n.closeWithoutSaving": "You have unsaved changes. Discard them and exit editing?",
|
||||
"i18n.editing": "Editing",
|
||||
"i18n.unsaved": "unsaved changes",
|
||||
"i18n.readOnly": "Read-only",
|
||||
"i18n.htmlPreview": "HTML preview",
|
||||
"i18n.previewFile": "Preview {file}",
|
||||
"i18n.invalidMermaid": "Invalid Mermaid diagram",
|
||||
@@ -412,19 +356,6 @@ export const enLocale: LocalePlugin = {
|
||||
"i18n.configuredVersion": "configured {version}",
|
||||
"i18n.extensions": "Extensions",
|
||||
"i18n.prompts": "Prompts",
|
||||
"extensions.app": "App",
|
||||
"extensions.project": "Project",
|
||||
"extensions.builtin": "Built-in",
|
||||
"extensions.enabled": "enabled",
|
||||
"extensions.disabled": "disabled",
|
||||
"extensions.blocked": "blocked",
|
||||
"extensions.total": "extensions",
|
||||
"extensions.status": "Status",
|
||||
"extensions.source": "Source",
|
||||
"extensions.path": "Path",
|
||||
"extensions.noExtensions": "No direct extensions found",
|
||||
"extensions.projectResourcesBlocked": "Project extensions are blocked until this project is trusted.",
|
||||
"extensions.diagnostics": "diagnostics",
|
||||
"i18n.themes": "Themes",
|
||||
"i18n.resourceCount": "{count} {label}",
|
||||
"i18n.extensionShort": "ext",
|
||||
|
||||
@@ -8,11 +8,8 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"common.ok": "确定",
|
||||
"common.language": "语言",
|
||||
"common.models": "模型",
|
||||
"common.mcp": "MCP",
|
||||
"common.skills": "技能",
|
||||
"common.plugins": "插件",
|
||||
"common.extensions": "扩展",
|
||||
"common.vision": "视觉",
|
||||
"sidebar.hide": "隐藏侧边栏",
|
||||
"sidebar.show": "显示侧边栏",
|
||||
"theme.light": "切换到浅色模式",
|
||||
@@ -45,7 +42,6 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"session.output": "输出",
|
||||
"session.cacheRead": "缓存读取",
|
||||
"session.cacheWrite": "缓存写入",
|
||||
"session.cacheHitRate": "缓存命中率(主会话)",
|
||||
"session.cost": "费用",
|
||||
"session.context": "上下文",
|
||||
"session.copy": "复制{value}",
|
||||
@@ -240,48 +236,6 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"chat.commandCopy": "复制最后一条助手消息",
|
||||
"chat.compacted": "已压缩",
|
||||
"chat.tokensSaved": "节省 {saved}",
|
||||
"chat.scrollToBottom": "滚动到底部",
|
||||
"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": "已复制",
|
||||
@@ -350,16 +304,6 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"i18n.mentionSelectedLines": "提及所选行",
|
||||
"i18n.disableWrap": "禁用自动换行",
|
||||
"i18n.enableWrap": "启用自动换行",
|
||||
"i18n.editFile": "编辑文件",
|
||||
"i18n.stopEditing": "停止编辑",
|
||||
"i18n.saveFile": "保存文件",
|
||||
"i18n.saveShortcut": "保存(Ctrl/Cmd+S)",
|
||||
"i18n.discardChanges": "放弃未保存的更改",
|
||||
"i18n.saveError": "无法保存文件:{error}",
|
||||
"i18n.closeWithoutSaving": "您有未保存的更改。放弃并退出编辑?",
|
||||
"i18n.editing": "编辑中",
|
||||
"i18n.unsaved": "未保存的更改",
|
||||
"i18n.readOnly": "只读",
|
||||
"i18n.htmlPreview": "HTML 预览",
|
||||
"i18n.previewFile": "预览 {file}",
|
||||
"i18n.invalidMermaid": "Mermaid 图表无效",
|
||||
@@ -412,19 +356,6 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"i18n.configuredVersion": "已配置 {version}",
|
||||
"i18n.extensions": "扩展",
|
||||
"i18n.prompts": "提示词",
|
||||
"extensions.app": "应用",
|
||||
"extensions.project": "项目",
|
||||
"extensions.builtin": "内置",
|
||||
"extensions.enabled": "已启用",
|
||||
"extensions.disabled": "已禁用",
|
||||
"extensions.blocked": "已阻止",
|
||||
"extensions.total": "个扩展",
|
||||
"extensions.status": "状态",
|
||||
"extensions.source": "来源",
|
||||
"extensions.path": "路径",
|
||||
"extensions.noExtensions": "未找到直接扩展",
|
||||
"extensions.projectResourcesBlocked": "项目尚未受信任,项目扩展已被阻止。",
|
||||
"extensions.diagnostics": "条诊断信息",
|
||||
"i18n.themes": "主题",
|
||||
"i18n.resourceCount": "{count}{label}",
|
||||
"i18n.extensionShort": "扩展",
|
||||
|
||||
+39
-100
@@ -1,117 +1,56 @@
|
||||
export const MAX_ATTACHED_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_ATTACHED_IMAGES = 10;
|
||||
|
||||
/** 上传时自动压缩:长边超过此像素的 JPEG 缩到此边长(相机照片主场景,视觉模型输入上限 ~1344px,1600 无损)。 */
|
||||
export const MAX_IMAGE_EDGE_PX = 1600;
|
||||
/** JPEG 重编码质量:避免伪影糊掉边缘/小字。 */
|
||||
export const IMAGE_JPEG_QUALITY = 0.85;
|
||||
|
||||
export interface Base64ImageAttachment {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function isBase64DataChar(code: number): boolean {
|
||||
return (
|
||||
(code >= 0x41 && code <= 0x5a) ||
|
||||
(code >= 0x61 && code <= 0x7a) ||
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
code === 0x2b ||
|
||||
code === 0x2f
|
||||
);
|
||||
return (code >= 0x41 && code <= 0x5a)
|
||||
|| (code >= 0x61 && code <= 0x7a)
|
||||
|| (code >= 0x30 && code <= 0x39)
|
||||
|| code === 0x2b
|
||||
|| code === 0x2f;
|
||||
}
|
||||
|
||||
export function getBase64DecodedByteLength(data: string): number | null {
|
||||
if (!data || data.length % 4 !== 0) return null;
|
||||
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
||||
const dataEnd = data.length - padding;
|
||||
for (let index = 0; index < dataEnd; index += 1) {
|
||||
if (!isBase64DataChar(data.charCodeAt(index))) return null;
|
||||
}
|
||||
for (let index = dataEnd; index < data.length; index += 1) {
|
||||
if (data[index] !== "=") return null;
|
||||
}
|
||||
return (data.length / 4) * 3 - padding;
|
||||
if (!data || data.length % 4 !== 0) return null;
|
||||
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
||||
const dataEnd = data.length - padding;
|
||||
for (let index = 0; index < dataEnd; index += 1) {
|
||||
if (!isBase64DataChar(data.charCodeAt(index))) return null;
|
||||
}
|
||||
for (let index = dataEnd; index < data.length; index += 1) {
|
||||
if (data[index] !== "=") return null;
|
||||
}
|
||||
return (data.length / 4) * 3 - padding;
|
||||
}
|
||||
|
||||
export function isBase64ImageWithinLimits(
|
||||
value: unknown,
|
||||
): value is Base64ImageAttachment {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const image = value as Partial<Base64ImageAttachment>;
|
||||
if (
|
||||
typeof image.data !== "string" ||
|
||||
typeof image.mimeType !== "string" ||
|
||||
!image.mimeType.startsWith("image/")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const bytes = getBase64DecodedByteLength(image.data);
|
||||
return bytes !== null && bytes <= MAX_ATTACHED_IMAGE_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前压缩:JPEG 和 PNG 且长边超过 MAX_IMAGE_EDGE_PX 时缩放并统一转 JPEG。
|
||||
* PNG 截图(代码编辑器/UI)通常无透明通道,转 JPEG 体积大幅减小。
|
||||
* WebP/GIF 原样返回(WebP 已经高效;GIF 可能是动画)。
|
||||
* 重编码无收益(更小或失败)时退回原文件。
|
||||
*/
|
||||
export async function compressImageFile(file: File): Promise<File> {
|
||||
// 仅压缩 JPEG 和 PNG(最常见的截图/照片格式)
|
||||
if (
|
||||
!file.type ||
|
||||
!(file.type.startsWith("image/jpeg") || file.type.startsWith("image/png"))
|
||||
) {
|
||||
return file;
|
||||
}
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
return file; // 解码失败退化为原文件
|
||||
}
|
||||
try {
|
||||
const edge = Math.max(bitmap.width, bitmap.height);
|
||||
if (edge <= MAX_IMAGE_EDGE_PX) return file; // 本就不大,避免无谓重编码
|
||||
const scale = MAX_IMAGE_EDGE_PX / edge;
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/jpeg", IMAGE_JPEG_QUALITY),
|
||||
);
|
||||
if (!blob || blob.size >= file.size) return file; // 重编码无收益则用原文件
|
||||
return new File([blob], file.name.replace(/\.\w+$/, ".jpg"), {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
export function isBase64ImageWithinLimits(value: unknown): value is Base64ImageAttachment {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const image = value as Partial<Base64ImageAttachment>;
|
||||
if (typeof image.data !== "string" || typeof image.mimeType !== "string" || !image.mimeType.startsWith("image/")) {
|
||||
return false;
|
||||
}
|
||||
const bytes = getBase64DecodedByteLength(image.data);
|
||||
return bytes !== null && bytes <= MAX_ATTACHED_IMAGE_BYTES;
|
||||
}
|
||||
|
||||
/** Return an API-safe error for prompt, steering, and follow-up image arrays. */
|
||||
export function validateAgentImages(value: unknown): string | null {
|
||||
if (value === undefined) return null;
|
||||
if (!Array.isArray(value)) return "images must be an array";
|
||||
if (value.length > MAX_ATTACHED_IMAGES) {
|
||||
return `A message can include at most ${MAX_ATTACHED_IMAGES} images`;
|
||||
}
|
||||
for (const image of value) {
|
||||
if (
|
||||
!image ||
|
||||
typeof image !== "object" ||
|
||||
(image as { type?: unknown }).type !== "image"
|
||||
) {
|
||||
return "Each attachment must be an image";
|
||||
}
|
||||
if (!isBase64ImageWithinLimits(image)) {
|
||||
return `Each image must be valid base64 image data of ${MAX_ATTACHED_IMAGE_BYTES / (1024 * 1024)}MB or smaller`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
if (value === undefined) return null;
|
||||
if (!Array.isArray(value)) return "images must be an array";
|
||||
if (value.length > MAX_ATTACHED_IMAGES) {
|
||||
return `A message can include at most ${MAX_ATTACHED_IMAGES} images`;
|
||||
}
|
||||
for (const image of value) {
|
||||
if (!image || typeof image !== "object" || (image as { type?: unknown }).type !== "image") {
|
||||
return "Each attachment must be an image";
|
||||
}
|
||||
if (!isBase64ImageWithinLimits(image)) {
|
||||
return `Each image must be valid base64 image data of ${MAX_ATTACHED_IMAGE_BYTES / (1024 * 1024)}MB or smaller`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface ModelsData {
|
||||
models: Record<string, string>;
|
||||
modelList: { id: string; name: string; provider: string }[];
|
||||
defaultModel: { provider: string; modelId: string } | null;
|
||||
thinkingLevels: Record<string, string[]>;
|
||||
thinkingLevelMaps: Record<string, Record<string, string | null>>;
|
||||
modelError?: string;
|
||||
}
|
||||
|
||||
interface ModelsCacheState {
|
||||
entries: Map<string, { data: ModelsData; expiresAt: number }>;
|
||||
inFlight: Map<string, Promise<ModelsData>>;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piModelsCacheState: ModelsCacheState | undefined;
|
||||
}
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
const MAX_MODELS_CACHE_ENTRIES = 32;
|
||||
|
||||
function getModelsCacheState(): ModelsCacheState {
|
||||
if (!globalThis.__piModelsCacheState) {
|
||||
globalThis.__piModelsCacheState = {
|
||||
entries: new Map(),
|
||||
inFlight: new Map(),
|
||||
generation: 0,
|
||||
};
|
||||
}
|
||||
return globalThis.__piModelsCacheState;
|
||||
}
|
||||
|
||||
export function invalidateModelsCache(): void {
|
||||
const state = getModelsCacheState();
|
||||
state.generation += 1;
|
||||
state.entries.clear();
|
||||
state.inFlight.clear();
|
||||
}
|
||||
|
||||
export function withModelRuntimeError(data: ModelsData, modelError: string | undefined): ModelsData {
|
||||
return modelError ? { ...data, modelError } : data;
|
||||
}
|
||||
|
||||
export function loadModelsWithCache(cwd: string, loader: () => Promise<ModelsData>): Promise<ModelsData> {
|
||||
const state = getModelsCacheState();
|
||||
const cached = state.entries.get(cwd);
|
||||
if (cached) {
|
||||
if (cached.expiresAt > Date.now()) return Promise.resolve(cached.data);
|
||||
state.entries.delete(cwd);
|
||||
}
|
||||
|
||||
const existingLoad = state.inFlight.get(cwd);
|
||||
if (existingLoad) return existingLoad;
|
||||
|
||||
const generation = state.generation;
|
||||
const loadPromise: Promise<ModelsData> = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((data) => {
|
||||
if (state.generation === generation && state.inFlight.get(cwd) === loadPromise) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of state.entries) {
|
||||
if (entry.expiresAt <= now) state.entries.delete(key);
|
||||
}
|
||||
while (state.entries.size >= MAX_MODELS_CACHE_ENTRIES) {
|
||||
const oldestKey = state.entries.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
state.entries.delete(oldestKey);
|
||||
}
|
||||
state.entries.set(cwd, { data, expiresAt: now + MODELS_CACHE_TTL_MS });
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.inFlight.get(cwd) === loadPromise) state.inFlight.delete(cwd);
|
||||
});
|
||||
|
||||
state.inFlight.set(cwd, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
+141
-4
@@ -1,7 +1,10 @@
|
||||
// Frontend-only subset of the pi type surface. The full AgentSessionLike
|
||||
// contract (which references pi-coding-agent classes) lives in the backend
|
||||
// (server/src/lib/pi-types.ts); the UI only renders session stats reported
|
||||
// over HTTP, so this file must not import the pi packages.
|
||||
import type {
|
||||
AgentSessionEvent,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
SlashCommandInfo,
|
||||
Theme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface ContextUsage {
|
||||
percent: number | null;
|
||||
@@ -9,6 +12,22 @@ export interface ContextUsage {
|
||||
tokens: number | null;
|
||||
}
|
||||
|
||||
export interface ModelLike {
|
||||
id: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NavigateTreeResult {
|
||||
editorText?: string;
|
||||
cancelled: boolean;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionStatsInfo {
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
@@ -28,3 +47,121 @@ export interface SessionStatsInfo {
|
||||
cost: number;
|
||||
contextUsage?: ContextUsage;
|
||||
}
|
||||
|
||||
interface PromptTemplateLike {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}
|
||||
|
||||
interface SkillLike {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}
|
||||
|
||||
interface ResourceLoaderLike {
|
||||
getSkills(): { skills: SkillLike[] };
|
||||
}
|
||||
|
||||
interface ExtensionRunnerLike {
|
||||
getRegisteredCommands(): Array<{
|
||||
invocationName: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}>;
|
||||
setUIContext?(uiContext?: unknown, mode?: "tui" | "rpc" | "json" | "print"): void;
|
||||
}
|
||||
|
||||
type DialogOptionsLike = {
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
type WidgetOptionsLike = {
|
||||
placement?: "aboveEditor" | "belowEditor";
|
||||
};
|
||||
|
||||
export interface ExtensionUiContextLike {
|
||||
select(title: string, options: string[], opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
confirm(title: string, message: string, opts?: DialogOptionsLike): Promise<boolean>;
|
||||
input(title: string, placeholder?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
editor(title: string, prefill?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
onTerminalInput(): () => void;
|
||||
setStatus(key: string, text: string | undefined): void;
|
||||
setWorkingMessage(message?: string): void;
|
||||
setWorkingVisible(visible: boolean): void;
|
||||
setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
|
||||
setHiddenThinkingLabel(label?: string): void;
|
||||
setWidget(key: string, content: string[] | ((...args: never[]) => unknown) | undefined, options?: WidgetOptionsLike): void;
|
||||
setFooter(factory: unknown): void;
|
||||
setHeader(factory: unknown): void;
|
||||
setTitle(title: string): void;
|
||||
custom<T = unknown>(...args: unknown[]): Promise<T>;
|
||||
pasteToEditor(text: string): void;
|
||||
setEditorText(text: string): void;
|
||||
getEditorText(): string;
|
||||
addAutocompleteProvider(): void;
|
||||
setEditorComponent(): void;
|
||||
getEditorComponent(): undefined;
|
||||
readonly theme: Theme;
|
||||
getAllThemes(): unknown[];
|
||||
getTheme(name: string): undefined;
|
||||
setTheme(theme: unknown): { success: boolean; error?: string };
|
||||
getToolsExpanded(): boolean;
|
||||
setToolsExpanded(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export interface AgentSessionLike {
|
||||
readonly sessionId: string;
|
||||
readonly sessionFile: string | undefined;
|
||||
readonly isStreaming: boolean;
|
||||
readonly isCompacting: boolean;
|
||||
readonly autoCompactionEnabled: boolean;
|
||||
readonly autoRetryEnabled: boolean;
|
||||
readonly model: ModelLike | undefined;
|
||||
readonly modelRuntime: {
|
||||
getModel: (provider: string, modelId: string) => ModelLike | undefined;
|
||||
refresh: (options?: { allowNetwork?: boolean }) => Promise<unknown>;
|
||||
};
|
||||
readonly sessionManager: SessionManager;
|
||||
readonly settingsManager: SettingsManager;
|
||||
readonly agent: { state?: { systemPrompt?: string; thinkingLevel?: string } };
|
||||
readonly extensionRunner: ExtensionRunnerLike;
|
||||
readonly promptTemplates: readonly PromptTemplateLike[];
|
||||
readonly resourceLoader: ResourceLoaderLike;
|
||||
|
||||
readonly bindExtensions?: unknown;
|
||||
reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void>;
|
||||
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
||||
prompt(text: string, options?: {
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>;
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
source?: "interactive" | "rpc";
|
||||
}): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode?: number; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string }>;
|
||||
abortBash(): void;
|
||||
readonly isBashRunning: boolean;
|
||||
setModel(model: ModelLike): Promise<void>;
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<NavigateTreeResult>;
|
||||
setThinkingLevel(level: string): void;
|
||||
compact(customInstructions?: string): Promise<unknown>;
|
||||
setSessionName(name: string): void;
|
||||
getSessionStats(): Omit<SessionStatsInfo, "sessionName">;
|
||||
getLastAssistantText(): string | undefined;
|
||||
setAutoCompactionEnabled(enabled: boolean): void;
|
||||
setAutoRetryEnabled(enabled: boolean): void;
|
||||
steer(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
|
||||
followUp(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
|
||||
readonly pendingMessageCount: number;
|
||||
getSteeringMessages(): readonly string[];
|
||||
getFollowUpMessages(): readonly string[];
|
||||
clearQueue(): { steering: string[]; followUp: string[] };
|
||||
getAllTools(): ToolInfo[];
|
||||
getActiveToolNames(): string[];
|
||||
setActiveToolsByName(names: string[]): void;
|
||||
abortCompaction(): void;
|
||||
getContextUsage(): ContextUsage | undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -103,10 +103,10 @@ test("the reload resolver reads the latest persisted trust decision", async (t)
|
||||
|
||||
test("all project resource loaders and reloads enforce project trust", async () => {
|
||||
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
||||
const modelsSource = await readFile(new URL("../routes/models.ts", import.meta.url), "utf8");
|
||||
const modelsSource = await readFile(new URL("../app/api/models/route.ts", import.meta.url), "utf8");
|
||||
const skillsSource = await readFile(new URL("./skills-service.ts", import.meta.url), "utf8");
|
||||
const skillsInstallSource = await readFile(new URL("../routes/skills.ts", import.meta.url), "utf8");
|
||||
const pluginsSource = await readFile(new URL("../routes/config.ts", import.meta.url), "utf8");
|
||||
const skillsInstallSource = await readFile(new URL("../app/api/skills/install/route.ts", import.meta.url), "utf8");
|
||||
const pluginsSource = await readFile(new URL("../app/api/plugins/route.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(rpcSource, /projectTrustReloadOptions\(cwd, agentDir\)/);
|
||||
assert.match(rpcSource, /resourceLoaderReloadOptions: trustReloadOptions/);
|
||||
@@ -131,7 +131,7 @@ test("all project resource loaders and reloads enforce project trust", async ()
|
||||
});
|
||||
|
||||
test("the trust API invalidates cached models and restricted runtimes", async () => {
|
||||
const source = await readFile(new URL("../routes/misc.ts", import.meta.url), "utf8");
|
||||
const source = await readFile(new URL("../app/api/project-trust/route.ts", import.meta.url), "utf8");
|
||||
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /trustProject\(result\.cwd, agentDir\)/);
|
||||
@@ -1,4 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
async function loadSubject() {
|
||||
@@ -166,49 +166,3 @@ test("recognizes JSON request content types", async () => {
|
||||
headers: { "content-type": "text/plain" },
|
||||
})), false);
|
||||
});
|
||||
|
||||
test("allows frontend origins proxied to the backend host", async () => {
|
||||
// The standalone backend receives /api through the frontend's rewrites, so
|
||||
// the Host header points at the backend while Origin stays the frontend's.
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
const proxiedPost = new Request("http://127.0.0.1:30142/api/default-cwd", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:30142",
|
||||
origin: "http://127.0.0.1:30141",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
const proxiedLanPost = new Request("http://127.0.0.1:30142/api/default-cwd", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:30142",
|
||||
origin: "http://192.168.32.7:30141",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
assert.equal(isApiRequestAllowed(proxiedPost), true);
|
||||
assert.equal(isApiRequestAllowed(proxiedLanPost), true);
|
||||
});
|
||||
|
||||
test("still rejects cross-site and wrong-port origins through the proxy", async () => {
|
||||
const { isApiRequestAllowed } = await loadSubject();
|
||||
const crossSite = new Request("http://127.0.0.1:30142/api/default-cwd", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:30142",
|
||||
origin: "https://attacker.example",
|
||||
"sec-fetch-site": "cross-site",
|
||||
},
|
||||
});
|
||||
const wrongPort = new Request("http://127.0.0.1:30142/api/default-cwd", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:30142",
|
||||
origin: "http://127.0.0.1:8080",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
assert.equal(isApiRequestAllowed(crossSite), false);
|
||||
assert.equal(isApiRequestAllowed(wrongPort), false);
|
||||
});
|
||||
@@ -51,29 +51,6 @@ function getRequestOrigin(request: Request): string | null {
|
||||
return host ? canonicalOrigin(`${requestUrl.protocol}//${host}`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frontend proxies /api here server-side, which rewrites the Host header
|
||||
* to the backend address. The "origin equals request host" comparison below
|
||||
* would then reject every browser POST. Accept origins that belong to the
|
||||
* frontend itself: loopback names, IP literals, or operator-configured
|
||||
* hostnames on the frontend port. Cross-site origins (the DNS-rebinding
|
||||
* defense this check exists for) still fail.
|
||||
*/
|
||||
function isFrontendOrigin(origin: string): boolean {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
const expectedPort = process.env.PI_WEB_PORT ?? "30141";
|
||||
if ((url.port || "80") !== expectedPort) return false;
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
if (isLoopbackHostname(hostname) || isIP(hostname) !== 0) return true;
|
||||
return configuredHostnamesFromEnvironment().some(
|
||||
(configured) => normalizeConfiguredHostname(configured) === hostname,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isUserInitiatedSessionExportNavigation(request: Request): boolean {
|
||||
if (
|
||||
request.method !== "GET"
|
||||
@@ -116,7 +93,6 @@ export function isApiRequestOriginAllowed(request: Request): boolean {
|
||||
const fetchSite = request.headers.get("sec-fetch-site");
|
||||
if (fetchSite === "cross-site") return false;
|
||||
if (!origin) return true;
|
||||
if (isFrontendOrigin(origin)) return true;
|
||||
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
return requestOrigin !== null && canonicalOrigin(origin) === requestOrigin;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user