Compare commits

...
Author SHA1 Message Date
evandro 1314c5ab3e docs: update test counts and document the local server security model 2026-10-10 04:28:00 -03:00
evandro ceca5bf301 fix(extension): generate the API token with a CSPRNG and restrict the discovery file
The bearer token came from Math.random(), which is predictable. Use crypto.randomBytes
and write piagentui-port.json (which stores the token) with mode 0600.
2026-10-10 04:24:44 -03:00
evandro e8927ebf78 perf(extension): cache parsed session files by mtime and size
GET /api/sessions (and every session lookup) re-read and JSON.parsed every
.jsonl session file synchronously on each request, blocking the agent's
event loop. Parsed metadata is now cached per path (bounded to 500 entries)
and invalidated when mtime or size changes.
2026-10-10 04:23:16 -03:00
evandro 3dd3fa5d05 test: read SSE chunks until session.updated arrives
session.status and session.updated are two separate res.write calls, so the
test failed whenever TCP delivered them in different chunks.
2026-10-10 04:23:16 -03:00
evandro adf63ef9f7 chore: remove unused modules and ignore local env and coverage output
src/api/piContract.ts and src/hooks/usePanelResizeStatus.ts have no importers (knip and grep).
2026-10-10 04:20:27 -03:00
evandro 2d2cdf3b19 refactor: reduce cyclomatic complexity of settings, tool data and layout sanitizers
Add characterization tests first, then split the hot spots into small helpers:
- STT/TTS settings updates move to extensions/piagentui-speech-settings.ts (rule tables)
- defaultExtractData is split into diff/diagnostic/output extractors
- getToolSummaryCategory becomes an ordered rule table (toolSummary.ts)
- sanitizePersistedTerminalLayoutMap is split per level
- duplicated diff stats helpers are merged into utils/diffStats.ts
2026-10-10 04:19:34 -03:00
Evandro Fonseca Junior da09846708 Merge pull request #1 from evandrodevbr/codex/review-2026-09-30-049051
Deploy to GitHub Pages / build (push) Failing after 5m55s
Deploy to GitHub Pages / deploy (push) Skipped
Build & Push Frontend Docker Image / build-and-push (push) Failing after 19s
Build Validation / validate (push) Successful in 11m44s
fix: protect download bytes and recover local API errors
2026-09-30 23:04:06 -03:00
Evandro Fonseca Junior e8a252ecde fix: protect download bytes and recover local API errors 2026-09-30 17:08:35 -03:00
Evandro Fonseca Junior 039d25f084 docs: rewrite the READMEs against verified behavior
- README.md rewritten from the real code: quick start, container stack with the actual image namespaces, Pages deploy, verification with the commands that ran, honest limitations
- README.pt-BR.md rewritten; the es/ja translations get a warning pointing at the canonical README
2026-09-14 08:51:30 -03:00
Evandro Fonseca Junior 0e46e5857e chore: rebrand package metadata, pin engines and drop tooling cache
- package.json: homepage/repository point at this repository, node >=22 declared, @mariozechner/pi-coding-agent added as devDependency (extensions/piagentui-server.ts imports it)
- Cargo.toml: repository field filled
- .pi-lens/ ignored and its cached review graph removed from tracking
- three material icons replaced by their 32x32 sources (the committed 16x16 files were broken)
2026-09-14 08:51:30 -03:00
Evandro Fonseca Junior 1dcd951bd7 fix(docker): keep the frontend image on upstream until it is published here
- backend and gateway images come from ghcr.io/evandrodevbr/opencodeui-{backend,gateway} (published)
- ghcr.io/evandrodevbr/opencodeui-frontend was never published (docker-frontend.yml failing since 2026-06-01), so the frontend keeps pulling ghcr.io/lehhair/opencodeui-frontend
2026-09-14 08:51:23 -03:00
Evandro Fonseca Junior d5b4362ed7 fix(pages): deploy the build from main with the repository base path
- trigger on main (the default branch) instead of the non-existent dev
- VITE_BASE_PATH /OpenCodeUI/ -> /PiAgentUI/, matching the repository name
- enable Pages automatically on the first run
2026-09-14 08:51:23 -03:00
39 changed files with 4058 additions and 1038 deletions

No files matched your search

+7 -2
View File
@@ -2,7 +2,7 @@ name: Deploy to GitHub Pages
on:
push:
branches: [dev]
branches: [main]
workflow_dispatch:
permissions:
@@ -29,7 +29,12 @@ jobs:
- run: npm run build
env:
VITE_BASE_PATH: /OpenCodeUI/
VITE_BASE_PATH: /PiAgentUI/
- name: Setup Pages
uses: actions/configure-pages@v5
with:
enablement: true
- uses: actions/upload-pages-artifact@v5
with:
+6
View File
@@ -6,3 +6,9 @@ tmp/
*.log
.DS_Store
Thumbs.db
.pi-lens/
# Local env and test output
.env
.env.local
coverage/
-1
View File
@@ -1 +0,0 @@
{"version":"v2","builtAt":"2026-06-01T00:40:39.785Z","signature":"D:/Documents/Github/PiAgentUI/PiAgentUi/eslint.config.js:1486:1780022589270.6587|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.test.ts:5516:1780258362691.8145|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.ts:16383:1780258362659.5957|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.test.ts:4040:1780266481529.727|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.ts:7293:1780266481600.4917|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.test.ts:6112:1780253705167.7166|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.ts:5140:1780253889743.0134|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.test.ts:39380:1780272600127.7744|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts:83661:1780272653246.032|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.test.ts:2999:1780248290341.1304|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.ts:6608:1780250094524.6309|D:/Documents/Github/PiAgentUI/PiAgentUi/public/notification-sw.js:1287:1780022589275.1758|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/bump-version.mjs:7766:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/copy-material-icons.mjs:2053:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/prepare-release.mjs:2730:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/caddy.rs:2091:1780022589277.6882|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/config.rs:3669:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/main.rs:796:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/router.rs:12128:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/scanner.rs:2323:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/state.rs:2417:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/build.rs:40:1780022589282.198|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/args.rs:1765:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/event.rs:558:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/mod.rs:197:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/state.rs:4232:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/bridge.rs:13983:1780022589314.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/mod.rs:124:1780027014784.0298|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/pi_agent.rs:4814:1780027011663.1926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/utils.rs:1496:1780029769772.7441|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/dir_state.rs:773:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/mod.rs:10869:1780029779431.7087|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/service.rs:500:1780027024480.5833|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/lib.rs:100:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/main.rs:193:1780022589316.2898|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/agent.ts:929:1780026517593.7356|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.test.ts:1876:1780180406861.2058|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.ts:3901:1780257142048.0273|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.test.ts:2375:1780194572795.3748|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.ts:3632:1780194752659.6665|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/config.ts:1414:1780026299997.4521|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.test.ts:8606:1780119380006.358|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.ts:27784:1780121498237.752|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.test.ts:1680:1780258114988.9187|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.ts:1197:1780258069136.5046|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/file.ts:4453:1780026718153.368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/global.ts:981:1780026539178.5913|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/http.test.ts:1443:1780022589319.4368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/http.ts:1509:1780029793129.7656|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/index.ts:135:1780022589319.4368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/lsp.ts:657:1780026505282.3206|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/mcp.test.ts:1017:1780188851123.1152|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/mcp.ts:1868:1780188942993.4717|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/message.ts:4368:1780026289798.4277|D:/Documents/Github/PiAgentUI/PiAgLine truncated
+2
View File
@@ -6,6 +6,8 @@
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | **Español** | [日本語](./README.ja.md)
> Esta traducción puede quedar por detrás de la versión de referencia, que es el [README principal](./README.md). Repositorio: https://github.com/evandrodevbr/PiAgentUI
PiAgentUI es una interfaz moderna web y de escritorio para **Pi Agent**. No es solo una capa visual de chat: el objetivo es convertirse en una aplicación completa de agentes, en la misma categoría de producto que Codex, Claude Desktop y otros entornos de desarrollo agent-first.
PiAgentUI trata el runtime real de Pi como la fuente de verdad. Sesiones, metadatos de modelos, uso de contexto, tool calls, skills, configuración MCP y slash commands vienen de Pi siempre que sea posible, en lugar de recrearse como estado falso de interfaz.
+2
View File
@@ -6,6 +6,8 @@
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | **日本語**
> この翻訳は基準となる [メイン README](./README.md) より古い可能性があります。リポジトリ: https://github.com/evandrodevbr/PiAgentUI
PiAgentUI は **Pi Agent** のためのモダンな Web / デスクトップ UI です。単なるチャット画面ではありません。最終的には Codex や Claude Desktop のような、エージェント中心の本格的な開発アプリになることを目指しています。
PiAgentUI は、実際の Pi runtime を信頼できる唯一の情報源として扱います。セッション、モデルメタデータ、コンテキスト使用量、tool call、skills、MCP 設定、slash command は、可能な限り Pi から取得し、UI 側だけの仮の状態として再実装しません。
+160 -187
View File
@@ -1,241 +1,214 @@
<p align="center">
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="PiAgentUI logo" width="760" />
</p>
# PiAgentUI
<h1 align="center">PiAgentUI</h1>
<p align="center">
<strong>Professional Desktop & Web UI for AI Coding Agents</strong><br/>
<em>Pi Agent · Claude Code · Codex · OpenCode · Cursor · Copilot</em>
</p>
**Desktop and web workspace for AI coding agents: real Pi Agent sessions, streaming chat, MCP and skills panels, terminal, file explorer and a Tauri desktop app.**
<p align="center">
<img src="https://img.shields.io/badge/version-0.6.10-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-GPL--3.0-green" alt="License" />
<img src="https://img.shields.io/badge/platform-Tauri%202%20%7C%20Web-brightgreen" alt="Platform" />
<img src="https://img.shields.io/badge/framework-React%2019%20%7C%20TypeScript-61DAFB" alt="Framework" />
</p>
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white)
![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white)
![Node](https://img.shields.io/badge/Node-%3E%3D22-5FA04E?logo=nodedotjs&logoColor=white)
![Licença](https://img.shields.io/badge/license-GPL--3.0--only-green)
[![Build Validation](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml/badge.svg)](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml)
**English (US)** | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | [日本語](./README.ja.md)
## TL;DR
## About
PiAgentUI is a **full-featured workspace for AI coding agents** — sessions, model switching, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Built for Pi Agent but compatible with Claude Code, Codex, OpenCode, Cursor, and any agent that speaks SSE/HTTP.
PiAgentUI is a chat and workspace front end for the Pi Agent runtime. Instead of a bare terminal, it gives you sessions, model switching, tool calls rendered as cards, MCP server metadata, skills grouped by source, slash commands, Git controls, a terminal, and a file explorer in one window, as a web app or as a Tauri desktop app.
> **Category:** AI Agent UI · Agent Desktop Client · MCP GUI · Coding Copilot Interface
> **Status:** Active development — production-grade chat, evolving fast. Expect weekly releases.
The project is Pi-first: sessions, models, context usage, tool calls, skills, MCP configuration and slash commands come from the Pi runtime whenever possible. The UI caches and enriches that data for presentation but does not invent agent state. It is a rename and rework of the OpenCodeUI base, and part of that inheritance is still visible (see [Current state and limitations](#current-state-and-limitations)).
## Software preview
These screenshots show the current PiAgentUI experience: a dark, focused agent workspace with real Pi sessions, model selection, MCP metadata, settings, tool-aware chat, and side panels.
![PiAgentUI chat workspace with MCP side panel](./src/assets/images/img1.png)
![PiAgentUI settings and server connections dialog](./src/assets/images/img2.png)
![PiAgentUI model selector inside an active agent session](./src/assets/images/img3.png)
## Quick Install
```bash
# Clone and install
git clone https://github.com/lehhair/OpenCodeUI.git
cd OpenCodeUI
npm install
# Start dev server
npm run dev
# Or build desktop app
npm run tauri dev
```
Requires **Node.js ≥ 22** and **Pi Agent** installed. The UI auto-discovers the Pi runtime via `~/.pi/agent/piagentui-port.json`.
## What PiAgentUI does today
- **Agent chat with streaming** — live assistant output through SSE, stable message reconciliation, markdown rendering, code highlighting, reasoning parts, and tool cards.
- **Real Pi sessions** — session list, active-session routing, restart-safe history loading, derived session titles, and selected-session send routing.
- **Tool output as tool cards** — tool calls/results stay attached to assistant messages instead of leaking as normal chat text.
- **Runtime context usage** — context usage comes from Pi runtime when available; unknown/compacted context is shown safely instead of `NaN`.
- **Model metadata** — Pi provider/model identity remains authoritative, with optional OpenRouter catalog metadata used only for extra details like context, output, modality, pricing, and tokenizer hints.
- **Skills panel** — real skills are parsed from the effective Pi prompt and grouped by source: global, project, package, and other.
- **MCP metadata panel** — reads configured MCP servers from local/global config files and surfaces transport, command, URL, lifecycle, direct tools, and source.
- **Pi slash commands** — `/api/commands/list` exposes Pi built-in commands plus dynamic extension, prompt, and skill commands.
- **Attachments and multimodal input** — file/image/PDF/audio/video attachment affordances follow the selected model capabilities.
- **Terminal and file tooling** — integrated terminal, file explorer, syntax highlighting, diff-oriented components, and Tauri desktop integration.
- **Keyboard-first UI** — command palette, configurable keybindings, split panes, model selector, project/session navigation, and responsive layouts.
## Product direction
PiAgentUI is moving toward a complete agent app:
1. **Visual actions for every Pi command** — slash commands remain available, but frequent actions should become buttons, menus, dialogs, or panels.
2. **Voice input and realtime transcription** — microphone recording, partial transcript deltas, final transcript insertion into the composer, and OpenAI-compatible STT provider configuration.
3. **Deeper MCP integration** — beyond metadata: connect, authenticate, inspect tools, and execute MCP workflows from the UI.
4. **Full session control** — fork, clone, tree navigation, import/export/share, branch summaries, and context management as first-class UI flows.
5. **Desktop-grade experience** — a polished Tauri app with local runtime discovery, secure settings, notifications, and richer workspace controls.
## Audio and transcription roadmap
PiAgentUI already supports attaching audio files when the selected model declares audio input support. The planned voice system adds live speech-to-text directly in the composer.
Recommended architecture:
- **Default realtime provider:** OpenAI Realtime Transcription with `gpt-realtime-whisper` for low-latency partial transcripts.
- **Default file fallback:** OpenAI Audio Transcriptions with `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, or `whisper-1`.
- **Custom providers:** user-configurable OpenAI-compatible STT APIs, such as providers that implement `POST /v1/audio/transcriptions`.
Planned UX:
- microphone button in the composer;
- recording and permission states;
- partial transcript overlay;
- final transcript inserted into the input;
- optional append/replace behavior;
- auto-send disabled by default;
- recoverable error handling for permissions, network failures, and provider errors.
Planned provider config shape:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "en"
}
```
Realtime support is treated as a separate capability because OpenAI-compatible HTTP transcription does not guarantee WebSocket compatibility.
## Architecture
## How it works
```text
PiAgentUI
├─ React/Vite frontend
│ ├─ chat, message rendering, input, panels, settings
│ ├─ local stores for UI preferences
│ └─ SSE/API clients for PiAgentUI local endpoints
├─ Pi extension backend
│ └─ extensions/piagentui-server.ts
│ ├─ exposes local /api/* endpoints
│ ├─ bridges to Pi runtime/session/model APIs
│ ├─ streams Pi events to the browser
│ └─ reads local metadata such as MCP config and skills
└─ Pi Agent runtime
├─ sessions
├─ models/providers
├─ tools
├─ skills
├─ MCP
└─ slash commands
browser window / Tauri webview
|
| HTTP /api/* + SSE /global/event + WebSocket
v
Pi extension server (extensions/piagentui-server.ts, Node http + ws)
|-- serves the built UI from dist/ and injects window.PI_BOOT_DATA {baseUrl, token}
|-- writes connection info to ~/.pi/agent/piagentui-port.json {port, token}
|-- enforces bearer-token auth on /api/* and loopback-only access by default
|-- bridges sessions, models, tools, MCP, skills and commands
^
| loaded by the runtime
Pi Agent process
```
The UI should not invent runtime state when Pi already knows the answer. PiAgentUI can cache and enrich data for UX, but Pi remains authoritative for agent behavior.
- On session start the extension listens on port `58785` (or the next free port) and publishes the selected port plus a random token in the discovery file.
- `GET /global/health` is open; every other `/api/*` route requires `Authorization: Bearer <token>`.
- The Tauri app reads the same discovery file through the `get_pi_agent_connection` Rust command, so the desktop build connects to the local runtime without manual configuration.
- `npm run dev` (Vite) is a front-end-only development server; it proxies `/api` to `127.0.0.1:4096` with the `/api` prefix stripped, so point it at a compatible server or use the extension-served build for the Pi flow.
## Tech stack
## Stack
| Area | Stack |
| ------------------- | --------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Styling | Tailwind CSS v4 plus project design tokens |
| Desktop | Tauri 2 |
| Markdown | Streamdown / markdown rendering pipeline |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Testing | Vitest, Testing Library |
| Local backend | Pi extension, Node HTTP server, WebSocket/SSE |
| Area | Choice |
|---|---|
| UI | React 19, TypeScript 5.9 |
| Build | Vite 8 (rolldown), `tsc -b` as the type gate |
| Styling | Tailwind CSS 4 plus project design tokens |
| Desktop / mobile | Tauri 2 (Rust 1.85+), Android targets generated in `src-tauri/gen` |
| Markdown and code | Streamdown, Shiki, KaTeX |
| Terminal | xterm.js |
| i18n | i18next, locales `en`, `pt-BR`, `es`, `ja`, `zh-CN`, `hi`, `bn`, `ar` |
| Tests | Vitest 4, Testing Library, jsdom |
| Local backend | Pi extension: Node HTTP server plus `ws` (SSE and WebSocket) |
| Gateway (containers) | Rust router (`src-router`, axum) in front of Caddy |
| Packages | npm with `package-lock.json` |
## Local development
## Requirements
Install dependencies:
- Node.js `>=22` (CI runs Node 22; verified locally on Node 24.20.0)
- npm `>=10` (the repository is npm-based; `package-lock.json` is authoritative)
- Rust `1.85+` only if you build the desktop app or the gateway
- Docker only for the container stack in `docker/`
- Pi Agent installed locally to have real sessions and models; without it the UI starts in standalone mode and only shows the server connection dialog
## Quick start
```bash
npm install
git clone https://github.com/evandrodevbr/PiAgentUI.git
cd PiAgentUI
npm ci # 615 packages, runs scripts/copy-material-icons.mjs
npm run build # tsc -b && vite build -> dist/
npm run dev # Vite dev server on http://localhost:5173
```
Start the frontend dev server:
Web UI served by the Pi extension (this is the path that talks to a real Pi runtime):
```bash
npm run dev
npm run build # the extension serves dist/
# with Pi Agent running this repository as a Pi package (see package.json -> "pi")
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
# open http://127.0.0.1:58785
```
Build the app:
Desktop app:
```bash
npm run build
npm run tauri dev # development window, uses the Vite dev server
npm run tauri build # bundles per src-tauri/tauri.conf.json (deb on Linux, dmg, nsis)
```
Run tests:
Android (see `scripts/dev-android.sh` and the Android job in the release workflow):
```bash
npm run test:run
npm run tauri android dev
```
Run type checks:
## Usage
Local endpoints exposed by the extension server. All of them, except `/global/health` and the SSE route, require the bearer token from the discovery file.
| Endpoint | Purpose |
|---|---|
| `GET /global/health` | Liveness probe, returns `{"status":"ok","pi":"ready"}` |
| `GET /global/event` | SSE event stream (sessions, message parts, status) |
| `GET /api/models` | Model list with normalized capabilities |
| `GET /api/sessions` | Session list |
| `POST /api/sessions` | Create a session |
| `POST /api/sessions/abort` | Abort the running turn |
| `GET /api/sessions/:id/messages` | Session history normalized for rendering |
| `GET /api/sessions/:id/context` | Context usage of the active session |
| `POST /api/messages/send` | Send a user message to a session |
| `GET /api/skills` | Effective skills grouped by source |
| `GET /api/mcp/status` | Configured MCP servers (transport, command, URL, source) |
| `GET /api/commands/list` | Built-in and dynamic slash commands |
| `GET /api/agents`, `GET /api/files/list`, `GET /api/permissions/list`, `GET /api/questions/list` | Runtime inventory for the panels |
| `GET /api/vcs/status`, `/info`, `/branches` | Git state used by the composer controls |
| `GET /api/extensions/list` | Extension catalog for the extensions panel |
| `GET /api/settings/stt`, `/api/settings/tts` | Voice settings (STT/TTS) |
| `GET /api/network/access`, `POST /api/network/access` | LAN access toggle (loopback-only by default) |
The full route table, including Git and extension mutations, lives in [`extensions/piagentui-server.ts`](extensions/piagentui-server.ts). Ports and paths can change; the discovery file is the source of truth for the port and token.
Auditing the agent from a shell works too, for example:
```bash
npm run typecheck
npm run typecheck:extensions
TOKEN=$(node -e "console.log(require(process.env.HOME+'/.pi/agent/piagentui-port.json').token)")
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:58785/api/models | head -c 300
```
Full validation:
## Production and deploy
```bash
npm run validate
npm run build # tsc -b && vite build; output in dist/ (about 14 MB, static)
npm run preview # serves dist/ locally for a final check
```
## Pi extension runtime
- Static hosting: `dist/` is a plain SPA. If it is mounted under a subpath, build with `VITE_BASE_PATH=/subpath/ npm run build`.
- Container (front end plus Caddy): `docker/Dockerfile.frontend` builds the UI on Node 22 and serves it from Caddy on port 3000.
- Full container stack: `docker-compose.yml` runs `gateway` (Caddy plus the Rust router on 6658/6659), `frontend` and `backend`. Copy `.env.example` to `.env` first. Images: `backend` and `gateway` come from `ghcr.io/evandrodevbr/opencodeui-{backend,gateway}` (published by the `docker-backend.yml` and `docker-gateway.yml` workflows); the `frontend` image still points at the upstream `ghcr.io/lehhair/opencodeui-frontend` because `docker-frontend.yml` has been failing since 2026-06-01 and no `ghcr.io/evandrodevbr/opencodeui-frontend` was ever published. To build all three locally use `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`.
- GitHub Pages: `.github/workflows/deploy.yml` builds `dist/` with `VITE_BASE_PATH=/PiAgentUI/` and publishes it on every push to `main` (enable Pages with source "GitHub Actions" the first time).
- Desktop and mobile releases: `.github/workflows/release.yml` runs on `v*` tags and attaches Windows, macOS, Linux and Android artifacts to the GitHub release.
PiAgentUI is registered as a Pi extension in `package.json`:
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
When the extension starts, it writes local discovery metadata to:
## Project structure
```text
~/.pi/agent/piagentui-port.json
extensions/ Pi extension: HTTP/WebSocket server, settings store, git and
extension managers, all with Vitest tests
src/ React front end (Vite entry in index.html)
├── api/ HTTP/SSE clients and the request pipeline
├── components/ shared UI pieces (code blocks, diffs, command palette)
├── features/ chat, input, messages, tools, sessions, settings, slash commands
├── hooks/ session, events, transcription, permissions
├── locales/ 8 translated bundles
├── store/ Zustand-style stores (sessions, messages, layout, theme)
└── constants/ API base URL, storage keys, UI metrics
src-router/ Rust gateway router (axum) behind Caddy
src-tauri/ Tauri 2 desktop and mobile shell (Rust), Android project in gen/
docker/ Dockerfiles, Caddy configs, entrypoints
scripts/ icon copy, version bump, release preparation, Android dev helper
docs/superpowers/ design specs and plans written during the migration
```
The web app uses that local port/token information to talk to the running extension server. Some backend changes require restarting or reloading the Pi extension process before the browser sees the new behavior.
## Verification
## Important local endpoints
Everything below was executed on a clean clone in this repository state:
| Endpoint | Purpose |
| -------------------------------- | ------------------------------------------------------ |
| `GET /api/models` | Real Pi model list plus normalized capability metadata |
| `GET /api/sessions` | Session list parsed from Pi session files |
| `GET /api/sessions/:id/messages` | Session history normalized for UI rendering |
| `GET /api/sessions/:id/context` | Runtime context usage for the active session |
| `POST /api/messages/send` | Send user message to the requested Pi session |
| `GET /api/skills` | Effective Pi skills grouped by source |
| `GET /api/mcp/status` | Configured MCP server metadata |
| `GET /api/commands/list` | Built-in and dynamic Pi slash commands |
| `GET /global/event` | SSE event stream from PiAgentUI backend |
| Command | Result |
|---|---|
| `npm ci` | 615 packages installed, postinstall copies 266 material icons |
| `npm run typecheck` | exit 0 (`tsc -b`, app + extension projects) |
| `npm run typecheck:extensions` | exit 0 |
| `npm run lint` | exit 0 (0 errors, 109 warnings) |
| `npm run build` | exit 0, `dist/` about 14 MB |
| `npm run test:run` | 95 files, 426 tests, all passing (on Node 22 in CI; see the Node 25+ note below) |
| `npm run validate` | exit 0 (the same gate CI runs in `build.yml`) |
| `cargo check` in `src-router/` | exit 0 |
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 warning) |
| extension server over HTTP | `/global/health` 200, `/` 200 with `PI_BOOT_DATA`, `/api/models` 200 with token and 401 without, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` all 200, `/global/event` 200 `text/event-stream` |
## Development principles
Two things to know when running the suite: `npm run test:run` needs `dist/` to exist, because the extension server test fetches the served `index.html`, so `npm run build` must run first (that is the order in `npm run validate`); and `npm run format:check` currently reports 66 files that are not Prettier-formatted, so `npm run check` fails on that step even though the CI gate passes. On Node 25 and newer, Node's built-in `localStorage` shadows the jsdom one and two `settingsBackup` tests fail; running them with `NODE_OPTIONS=--no-experimental-webstorage` makes them pass.
- Prefer real Pi runtime data over mocked UI-only state.
- Keep OpenRouter metadata as catalog enrichment only; never replace Pi provider/model identity with it.
- Treat unknown runtime values explicitly instead of rendering misleading numbers.
- Add tests before behavior changes whenever possible.
- Keep tool results inside tool cards.
- Keep UI work aligned with the existing theme, motion, spacing, and panel patterns.
- Document what is implemented separately from what is planned.
## Current state and limitations
## Repository notes
- Active single-developer project, pre-1.0 (`0.6.10`). The chat, sessions, MCP, skills and command panels work against a real Pi runtime; some areas are still moving.
- Naming inheritance from the OpenCodeUI base is only partially cleaned: the Rust crate is `opencodeui-router`, container images are `opencodeui-*`, `.env.example` comments are in Chinese, and the Docker stack runs an `opencode serve` backend that belongs to the inherited deployment, not to the Pi extension flow.
- There is no test for the Tauri shell or the Rust gateway beyond compilation; the Android and desktop bundles are only built by the release workflow.
- The suite is component- and server-level; there is no end-to-end browser test and no coverage threshold.
- `@mariozechner/pi-coding-agent` is a devDependency for types only (the runtime provides the real extension API), and npm marks that package line as deprecated in favour of `@earendil-works/pi-coding-agent`.
- The front end talks to one runtime per window: the discovery file or a manually configured server. There is no multi-runtime aggregation.
- Voice input exists in the UI (`src/hooks/useVoiceTranscription.ts`) and requires an OpenAI-compatible STT endpoint configured in settings; it is not covered by an automated browser test.
- Security model of the local server: it binds `0.0.0.0` but answers only loopback `Host`/`Origin` values until LAN access is enabled, and `/api/*` needs the bearer token from the discovery file (generated with `crypto.randomBytes` and stored with mode `0600`). Once LAN access is enabled in settings, requests from private-network hosts are accepted without a token, so turn it on only on networks you trust: the API can read project files and manage Git and extensions. There is no per-user authentication.
- No published npm package and no hosted web demo; both README and code assume a local runtime.
- `docker-frontend.yml` last ran on 2026-06-01 and failed at the build step, so `ghcr.io/evandrodevbr/opencodeui-frontend` was never published (verified against GHCR with the owner credentials); the compose files pull that one image from upstream until the workflow is fixed.
This project still contains inherited naming from the original OpenCodeUI base in a few places, including package metadata and older documentation files. The current direction is PiAgentUI-first, and future cleanup should migrate remaining OpenCodeUI naming without breaking local workflows.
## Documentation
| Document | Content |
|---|---|
| [`docs/superpowers/specs/`](docs/superpowers/specs/) | Design specs for the Pi extension migration, sessions, STT and Git controls |
| [`docs/superpowers/plans/`](docs/superpowers/plans/) | Implementation plans and the roadmap for context, skills and MCP |
| [`CHANGELOG.md`](CHANGELOG.md) | Release history used by the release workflow |
| [`docker/`](docker/) | Dockerfiles, Caddy configs and entrypoints for the container stack |
## License
This repository currently follows the license declared in `package.json`: `GPL-3.0-only`.
GPL-3.0-only, as declared in `package.json` and [`LICENSE`](LICENSE). PiAgentUI is a fork and rework of OpenCodeUI; the original authors are credited in `src-tauri/Cargo.toml`.
## Review checks (2026-09-30)
Browser downloads include exactly the requested byte view, without adjacent bytes from its backing buffer. Session statistics skip the full message-text scan when real token usage is available. LAN discovery is best effort: restricted network-interface enumeration keeps the local API usable. Failed requests close partial responses without attempting to send headers twice.
Run `npm run validate` for application/extension type checks, lint, build and tests. Focused checks: `npm run test:run -- src/utils/downloadUtils.test.ts src/hooks/useSessionStats.test.tsx extensions/piagentui-server-core.test.ts`. Dependencies, native desktop/mobile builds and live agent flows require their own checks; an application unit-test pass does not replace those validations.
+154 -155
View File
@@ -4,209 +4,208 @@
# PiAgentUI
**Workspace desktop e web para agentes de código: sessões reais do Pi Agent, chat com streaming, painéis de MCP e skills, terminal, explorador de arquivos e app desktop em Tauri.**
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white)
![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white)
![Node](https://img.shields.io/badge/Node-%3E%3D22-5FA04E?logo=nodedotjs&logoColor=white)
![Licença](https://img.shields.io/badge/license-GPL--3.0--only-green)
[![Build Validation](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml/badge.svg)](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml)
[English (US)](./README.md) | **Português (BR)** | [Español](./README.es.md) | [日本語](./README.ja.md)
PiAgentUI é uma interface moderna web e desktop para o **Pi Agent**. Ele não é apenas uma camada visual de chat: o objetivo é evoluir para um aplicativo completo de agentes, na mesma categoria de produto de Codex, Claude Desktop e outros ambientes agent-first para desenvolvimento.
## Sobre
O PiAgentUI trata o runtime real do Pi como fonte de verdade. Sessões, metadados de modelo, uso de contexto, tool calls, skills, configuração MCP e slash commands vêm do Pi sempre que possível, em vez de serem recriados como estado falso de interface.
PiAgentUI é uma interface de chat e um workspace para o runtime do Pi Agent. No lugar de um terminal cru, ele reúne sessões, troca de modelo, tool calls em cards, metadados de servidores MCP, skills agrupadas por origem, slash commands, controles de Git, terminal e explorador de arquivos, como aplicação web ou como app desktop Tauri.
> **Status do projeto:** desenvolvimento local ativo. Algumas áreas já estão próximas de produto, enquanto outras ainda mudam rapidamente.
O projeto é Pi-first: sessões, modelos, uso de contexto, tool calls, skills, configuração MCP e slash commands vêm do runtime do Pi sempre que possível. A UI cacheia e enriquece esses dados para apresentação, mas não inventa estado do agente. É um fork e uma reescrita da base OpenCodeUI, e parte dessa herança ainda aparece (veja [Estado atual e limitações](#estado-atual-e-limitações)).
## Demonstração do software
Estas capturas mostram a experiência atual do PiAgentUI: um workspace escuro e focado para agentes, com sessões reais do Pi, seleção de modelos, metadados MCP, settings, chat com ferramentas e painéis laterais.
![Workspace de chat do PiAgentUI com painel lateral MCP](./src/assets/images/img1.png)
![Diálogo de settings e conexões de servidor do PiAgentUI](./src/assets/images/img2.png)
![Seletor de modelos do PiAgentUI dentro de uma sessão ativa](./src/assets/images/img3.png)
## O que o PiAgentUI já faz hoje
- **Chat com streaming** — saída ao vivo via SSE, reconciliação estável de mensagens, markdown, code highlight, partes de reasoning e cards de ferramentas.
- **Sessões reais do Pi** — lista de sessões, roteamento para sessão ativa, histórico seguro após restart, títulos derivados e envio para a sessão selecionada.
- **Tool output em cards** — tool calls/results ficam anexados às mensagens do assistente, sem vazar como texto normal no chat.
- **Uso real de contexto** — o uso de contexto vem do runtime do Pi quando disponível; contexto desconhecido/compactado é exibido de forma segura em vez de `NaN`.
- **Metadados de modelos** — identidade de provider/modelo vem do Pi; o catálogo OpenRouter local é usado apenas como enriquecimento de detalhes.
- **Painel de Skills** — skills reais são lidas do prompt efetivo do Pi e agrupadas por origem: global, project, package e other.
- **Painel MCP** — lê servidores MCP configurados em arquivos locais/globais e mostra transporte, comando, URL, lifecycle, direct tools e origem.
- **Slash commands do Pi** — `/api/commands/list` expõe comandos built-in do Pi e comandos dinâmicos de extensões, prompts e skills.
- **Anexos multimodais** — suporte visual a arquivos/imagens/PDF/áudio/vídeo conforme as capacidades do modelo selecionado.
- **Terminal e arquivos** — terminal integrado, explorador de arquivos, syntax highlighting, componentes de diff e integração desktop via Tauri.
- **UI keyboard-first** — command palette, atalhos configuráveis, split panes, seletor de modelo, navegação por projeto/sessão e layouts responsivos.
## Direção do produto
O PiAgentUI está caminhando para virar um app completo de agentes:
1. **Ações visuais para todos os comandos do Pi** — slash commands continuam disponíveis, mas ações frequentes devem virar botões, menus, diálogos ou painéis.
2. **Entrada por voz e transcrição em tempo real** — gravação de microfone, deltas parciais, inserção da transcrição final no composer e configuração de provedores STT OpenAI-compatible.
3. **Integração MCP mais profunda** — além de metadados: conectar, autenticar, inspecionar ferramentas e executar workflows MCP pela UI.
4. **Controle completo de sessões** — fork, clone, navegação em árvore, import/export/share, branch summaries e gerenciamento de contexto como fluxos nativos.
5. **Experiência desktop completa** — app Tauri polido com descoberta local de runtime, settings seguros, notificações e controles ricos de workspace.
## Roadmap de áudio e transcrição
O PiAgentUI já aceita anexos de áudio quando o modelo selecionado declara suporte a áudio. O sistema planejado de voz adiciona speech-to-text ao vivo diretamente no composer.
Arquitetura recomendada:
- **Provedor realtime padrão:** OpenAI Realtime Transcription com `gpt-realtime-whisper` para transcrição parcial de baixa latência.
- **Fallback por arquivo:** OpenAI Audio Transcriptions com `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` ou `whisper-1`.
- **Provedores customizados:** APIs STT OpenAI-compatible configuráveis pelo usuário, como provedores que implementam `POST /v1/audio/transcriptions`.
UX planejada:
- botão de microfone no composer;
- estados de gravação e permissão;
- overlay de transcrição parcial;
- texto final inserido no input;
- comportamento configurável de append/replace;
- auto-send desligado por padrão;
- tratamento recuperável para erros de permissão, rede e provedor.
Exemplo de configuração de provider:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "pt"
}
```
Suporte realtime é tratado como capability separada, porque compatibilidade HTTP com a OpenAI não garante compatibilidade WebSocket.
## Arquitetura
## Como funciona
```text
PiAgentUI
├─ Frontend React/Vite
│ ├─ chat, renderização de mensagens, input, painéis e settings
│ ├─ stores locais para preferências de UI
│ └─ clientes SSE/API para endpoints locais do PiAgentUI
├─ Backend como extensão Pi
│ └─ extensions/piagentui-server.ts
│ ├─ expõe endpoints locais /api/*
│ ├─ faz ponte com runtime/sessões/modelos do Pi
│ ├─ transmite eventos do Pi para o browser
│ └─ lê metadados locais como MCP config e skills
└─ Runtime Pi Agent
├─ sessões
├─ modelos/providers
├─ ferramentas
├─ skills
├─ MCP
└─ slash commands
janela do navegador / webview Tauri
|
| HTTP /api/* + SSE /global/event + WebSocket
v
Servidor da extensão Pi (extensions/piagentui-server.ts, Node http + ws)
|-- serve o build da UI a partir de dist/ e injeta window.PI_BOOT_DATA {baseUrl, token}
|-- grava os dados de conexão em ~/.pi/agent/piagentui-port.json {port, token}
|-- exige bearer token em /api/* e restringe o acesso ao loopback por padrão
|-- faz a ponte para sessões, modelos, ferramentas, MCP, skills e comandos
^
| carregado pelo runtime
Processo do Pi Agent
```
A UI não deve inventar estado de runtime quando o Pi já sabe a resposta. O PiAgentUI pode cachear e enriquecer dados para UX, mas o Pi continua autoritativo para o comportamento do agente.
- Ao iniciar uma sessão, a extensão escuta na porta `58785` (ou na próxima livre) e publica a porta escolhida e um token aleatório no arquivo de descoberta.
- `GET /global/health` é aberto; todas as outras rotas `/api/*` exigem `Authorization: Bearer <token>`.
- O app Tauri lê o mesmo arquivo de descoberta pelo comando Rust `get_pi_agent_connection`, então o build desktop se conecta ao runtime local sem configuração manual.
- `npm run dev` (Vite) serve apenas o front end em desenvolvimento; ele faz proxy de `/api` para `127.0.0.1:4096` removendo o prefixo `/api`. Aponte para um servidor compatível ou use o build servido pela extensão para o fluxo Pi.
## Stack técnica
## Stack
| Área | Stack |
| ------------------- | -------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Estilo | Tailwind CSS v4 e design tokens do projeto |
| Desktop | Tauri 2 |
| Markdown | Streamdown / pipeline de markdown |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Testes | Vitest, Testing Library |
| Backend local | Extensão Pi, Node HTTP server, WebSocket/SSE |
| Área | Escolha |
|---|---|
| UI | React 19, TypeScript 5.9 |
| Build | Vite 8 (rolldown), com `tsc -b` como gate de tipos |
| Estilo | Tailwind CSS 4 e design tokens do projeto |
| Desktop e mobile | Tauri 2 (Rust 1.85+), alvos Android gerados em `src-tauri/gen` |
| Markdown e código | Streamdown, Shiki, KaTeX |
| Terminal | xterm.js |
| i18n | i18next, locales `en`, `pt-BR`, `es`, `ja`, `zh-CN`, `hi`, `bn`, `ar` |
| Testes | Vitest 4, Testing Library, jsdom |
| Backend local | Extensão Pi: servidor HTTP Node com `ws` (SSE e WebSocket) |
| Gateway (containers) | Roteador Rust (`src-router`, axum) na frente do Caddy |
| Pacotes | npm com `package-lock.json` |
## Desenvolvimento local
## Requisitos
Instalar dependências:
- Node.js `>=22` (o CI roda Node 22; verificado localmente em Node 24.20.0)
- npm `>=10` (o repositório é npm; `package-lock.json` é a referência)
- Rust `1.85+` apenas para buildar o app desktop ou o gateway
- Docker apenas para a stack de containers em `docker/`
- Pi Agent instalado para ter sessões e modelos reais; sem ele a UI sobe em modo standalone e mostra apenas o diálogo de conexão com servidores
## Início rápido
```bash
npm install
git clone https://github.com/evandrodevbr/PiAgentUI.git
cd PiAgentUI
npm ci # 615 pacotes; roda scripts/copy-material-icons.mjs
npm run build # tsc -b && vite build -> dist/
npm run dev # servidor de desenvolvimento Vite em http://localhost:5173
```
Iniciar o frontend em modo dev:
UI web servida pela extensão Pi (é o caminho que conversa com o runtime Pi de verdade):
```bash
npm run dev
npm run build # a extensão serve o dist/
# com o Pi Agent carregando este repositório como pacote Pi (veja "pi" em package.json)
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
# abra http://127.0.0.1:58785
```
Build:
App desktop:
```bash
npm run build
npm run tauri dev # janela de desenvolvimento, usa o servidor Vite
npm run tauri build # empacota conforme src-tauri/tauri.conf.json (deb no Linux, dmg, nsis)
```
Testes:
Android (veja `scripts/dev-android.sh` e o job de Android no workflow de release):
```bash
npm run test:run
npm run tauri android dev
```
Type checks:
## Uso
Rotas locais expostas pelo servidor da extensão. Todas, exceto `/global/health` e a rota SSE, exigem o bearer token do arquivo de descoberta.
| Rota | Para que serve |
|---|---|
| `GET /global/health` | Prova de vida, responde `{"status":"ok","pi":"ready"}` |
| `GET /global/event` | Stream SSE de eventos (sessões, partes de mensagem, status) |
| `GET /api/models` | Lista de modelos com capacidades normalizadas |
| `GET /api/sessions` | Lista de sessões |
| `POST /api/sessions` | Cria sessão |
| `POST /api/sessions/abort` | Aborta o turno em execução |
| `GET /api/sessions/:id/messages` | Histórico da sessão normalizado para renderização |
| `GET /api/sessions/:id/context` | Uso de contexto da sessão ativa |
| `POST /api/messages/send` | Envia mensagem do usuário para uma sessão |
| `GET /api/skills` | Skills efetivas agrupadas por origem |
| `GET /api/mcp/status` | Servidores MCP configurados (transporte, comando, URL, origem) |
| `GET /api/commands/list` | Slash commands built-in e dinâmicos |
| `GET /api/agents`, `GET /api/files/list`, `GET /api/permissions/list`, `GET /api/questions/list` | Inventário do runtime para os painéis |
| `GET /api/vcs/status`, `/info`, `/branches` | Estado do Git usado pelos controles do composer |
| `GET /api/extensions/list` | Catálogo de extensões do painel de extensões |
| `GET /api/settings/stt`, `/api/settings/tts` | Configurações de voz (STT/TTS) |
| `GET /api/network/access`, `POST /api/network/access` | Chave de acesso em LAN (padrão: só loopback) |
A tabela completa de rotas, incluindo as mutações de Git e de extensões, está em [`extensions/piagentui-server.ts`](extensions/piagentui-server.ts). Portas e caminhos podem mudar; o arquivo de descoberta é a fonte de verdade para porta e token.
Auditar o agente pelo shell também funciona, por exemplo:
```bash
npm run typecheck
npm run typecheck:extensions
TOKEN=$(node -e "console.log(require(process.env.HOME+'/.pi/agent/piagentui-port.json').token)")
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:58785/api/models | head -c 300
```
Validação completa:
## Produção e deploy
```bash
npm run validate
npm run build # tsc -b && vite build; saída em dist/ (cerca de 14 MB, estático)
npm run preview # serve o dist/ localmente para conferência final
```
## Runtime da extensão Pi
- Hospedagem estática: `dist/` é uma SPA comum. Se for montada em um subcaminho, build com `VITE_BASE_PATH=/subcaminho/ npm run build`.
- Container (front end com Caddy): `docker/Dockerfile.frontend` builda a UI em Node 22 e serve pelo Caddy na porta 3000.
- Stack completa de containers: `docker-compose.yml` sobe `gateway` (Caddy com o roteador Rust em 6658/6659), `frontend` e `backend`. Copie `.env.example` para `.env` antes. As imagens vêm de `ghcr.io/evandrodevbr/opencodeui-{backend,frontend,gateway}`, publicadas pelos workflows `docker-*`; para buildar localmente use `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`.
- GitHub Pages: `.github/workflows/deploy.yml` builda o `dist/` com `VITE_BASE_PATH=/PiAgentUI/` e publica a cada push em `main` (na primeira vez, habilite o Pages com origem "GitHub Actions").
- Releases desktop e mobile: `.github/workflows/release.yml` roda em tags `v*` e anexa artefatos de Windows, macOS, Linux e Android ao release do GitHub.
O PiAgentUI é registrado como extensão Pi em `package.json`:
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
Quando a extensão inicia, ela escreve metadados de descoberta local em:
## Estrutura do projeto
```text
~/.pi/agent/piagentui-port.json
extensions/ Extensão Pi: servidor HTTP/WebSocket, store de settings,
gerentes de git e de extensões, todos com testes Vitest
src/ Front end React (entrada Vite em index.html)
├── api/ Clientes HTTP/SSE e pipeline de requisições
├── components/ Peças de UI compartilhadas (code blocks, diffs, command palette)
├── features/ chat, input, mensagens, ferramentas, sessões, settings, slash commands
├── hooks/ sessão, eventos, transcrição, permissões
├── locales/ 8 bundles traduzidos
├── store/ Stores (sessões, mensagens, layout, tema)
└── constants/ URL base da API, chaves de storage, métricas de UI
src-router/ Roteador do gateway em Rust (axum), atrás do Caddy
src-tauri/ Shell desktop e mobile Tauri 2 (Rust), projeto Android em gen/
docker/ Dockerfiles, configs do Caddy, entrypoints
scripts/ cópia de ícones, bump de versão, preparação de release, helper Android
docs/superpowers/ Specs de design e planos escritos durante a migração
```
O app web usa a porta/token desse arquivo para falar com o servidor local da extensão. Algumas mudanças de backend exigem reiniciar ou recarregar o processo da extensão Pi antes de aparecerem no browser.
## Verificação
## Endpoints locais importantes
Tudo abaixo foi executado em um clone limpo neste estado do repositório:
| Endpoint | Finalidade |
| -------------------------------- | ----------------------------------------------------- |
| `GET /api/models` | Lista real de modelos Pi com capacidades normalizadas |
| `GET /api/sessions` | Lista de sessões lida dos arquivos de sessão do Pi |
| `GET /api/sessions/:id/messages` | Histórico normalizado para renderização na UI |
| `GET /api/sessions/:id/context` | Uso de contexto runtime para a sessão ativa |
| `POST /api/messages/send` | Envia mensagem para a sessão Pi solicitada |
| `GET /api/skills` | Skills efetivas do Pi agrupadas por origem |
| `GET /api/mcp/status` | Metadados dos servidores MCP configurados |
| `GET /api/commands/list` | Slash commands built-in e dinâmicos do Pi |
| `GET /global/event` | Stream SSE de eventos do backend PiAgentUI |
| Comando | Resultado |
|---|---|
| `npm ci` | 615 pacotes instalados, postinstall copia 266 ícones |
| `npm run typecheck` | exit 0 (`tsc -b`, projetos app e extensão) |
| `npm run typecheck:extensions` | exit 0 |
| `npm run lint` | exit 0 (0 erros, 109 avisos) |
| `npm run build` | exit 0, `dist/` com cerca de 14 MB |
| `npm run test:run` | 95 arquivos, 426 testes, todos passando (no Node 22 do CI; veja a nota sobre Node 25+ abaixo) |
| `npm run validate` | exit 0 (o mesmo gate que o CI roda em `build.yml`) |
| `cargo check` em `src-router/` | exit 0 |
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 aviso) |
| servidor da extensão via HTTP | `/global/health` 200, `/` 200 com `PI_BOOT_DATA`, `/api/models` 200 com token e 401 sem, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` todos 200, `/global/event` 200 `text/event-stream` |
## Princípios de desenvolvimento
Dois detalhes ao rodar a suíte: `npm run test:run` precisa do `dist/` existir, porque o teste do servidor da extensão busca o `index.html` servido, então `npm run build` deve rodar antes (é a ordem em `npm run validate`); e `npm run format:check` hoje acusa 66 arquivos fora do padrão do Prettier, então `npm run check` falha nesse passo mesmo com o gate do CI passando. No Node 25 e superior, o `localStorage` nativo do Node sobrepõe o do jsdom e dois testes de `settingsBackup` falham; rodá-los com `NODE_OPTIONS=--no-experimental-webstorage` faz passarem.
- Preferir dados reais do runtime Pi em vez de estado fake só da UI.
- Manter OpenRouter como enriquecimento de catálogo, nunca como substituto da identidade provider/modelo do Pi.
- Tratar valores runtime desconhecidos explicitamente.
- Escrever testes antes de mudanças de comportamento sempre que possível.
- Manter tool results dentro dos cards de ferramenta.
- Seguir o sistema visual existente: tema, movimento, espaçamento e padrões de painéis.
- Separar claramente o que está implementado do que é roadmap.
## Estado atual e limitações
## Notas do repositório
- Projeto ativo de um desenvolvedor, pré-1.0 (`0.6.10`). Chat, sessões, MCP, skills e painéis de comandos funcionam contra um runtime Pi real; algumas áreas ainda mudam rápido.
- A herança de nomes da base OpenCodeUI foi limpa só em parte: o crate Rust se chama `opencodeui-router`, as imagens de container são `opencodeui-*`, os comentários do `.env.example` estão em chinês e a stack Docker roda um backend `opencode serve` que pertence ao deploy herdado, não ao fluxo da extensão Pi.
- Não há teste do shell Tauri nem do gateway Rust além da compilação; os pacotes desktop e Android só são gerados pelo workflow de release.
- A suíte é de componentes e de servidor; não há teste end-to-end de navegador nem limite de cobertura.
- `@mariozechner/pi-coding-agent` é devDependency só para tipos (o runtime fornece a API real da extensão) e o npm marca essa linha como depreciada em favor de `@earendil-works/pi-coding-agent`.
- O front end conversa com um runtime por janela: o arquivo de descoberta ou um servidor configurado à mão. Não há agregação de múltiplos runtimes.
- A entrada de voz existe na UI (`src/hooks/useVoiceTranscription.ts`) e exige um endpoint STT compatível com OpenAI configurado nos settings; não há teste automatizado de navegador para ela.
- Modelo de segurança do servidor local: ele escuta em `0.0.0.0`, mas só responde a valores de `Host`/`Origin` de loopback até o acesso pela LAN ser ativado, e `/api/*` exige o token bearer do arquivo de descoberta (gerado com `crypto.randomBytes` e gravado com modo `0600`). Com o acesso pela LAN ativado nas configurações, requisições de hosts de rede privada são aceitas sem token; ative só em redes confiáveis, pois a API lê arquivos do projeto e gerencia Git e extensões. Não há autenticação por usuário.
- Sem pacote publicado no npm e sem demo web hospedada; o README e o código assumem um runtime local.
O projeto ainda contém nomes herdados da base original OpenCodeUI em alguns pontos, incluindo metadados do pacote e documentação antiga. A direção atual é PiAgentUI-first, e uma limpeza futura deve migrar esses nomes sem quebrar os workflows locais.
## Documentação
| Documento | Conteúdo |
|---|---|
| [`docs/superpowers/specs/`](docs/superpowers/specs/) | Specs de design da migração para extensão Pi, sessões, STT e controles de Git |
| [`docs/superpowers/plans/`](docs/superpowers/plans/) | Planos de implementação e roadmap de contexto, skills e MCP |
| [`CHANGELOG.md`](CHANGELOG.md) | Histórico de releases usado pelo workflow de release |
| [`docker/`](docker/) | Dockerfiles, configs do Caddy e entrypoints da stack de containers |
## Licença
Este repositório segue a licença declarada em `package.json`: `GPL-3.0-only`.
GPL-3.0-only, conforme `package.json` e [`LICENSE`](LICENSE). PiAgentUI é um fork e uma reescrita do OpenCodeUI; os autores originais estão creditados em `src-tauri/Cargo.toml`.
+2
View File
@@ -12,6 +12,8 @@
services:
frontend:
# Imagem do upstream: ghcr.io/evandrodevbr/opencodeui-frontend ainda não foi publicada
# (docker-frontend.yml falha desde 2026-06-01). Para build local use docker-compose.build.yml.
image: ghcr.io/lehhair/opencodeui-frontend:latest
container_name: opencodeui
restart: unless-stopped
+4 -2
View File
@@ -18,7 +18,7 @@
services:
# ---- 统一入口:网关 ----
gateway:
image: ghcr.io/lehhair/opencodeui-gateway:latest
image: ghcr.io/evandrodevbr/opencodeui-gateway:latest
container_name: opencode-gateway
restart: unless-stopped
ports:
@@ -46,6 +46,8 @@ services:
# ---- 前端:预构建镜像 ----
frontend:
# A imagem do próprio namespace ainda não existe (docker-frontend.yml falha desde 2026-06-01);
# mantém a do upstream até o workflow publicar ghcr.io/evandrodevbr/opencodeui-frontend.
image: ghcr.io/lehhair/opencodeui-frontend:latest
container_name: opencode-frontend
restart: unless-stopped
@@ -54,7 +56,7 @@ services:
# ---- 后端:预构建镜像 ----
backend:
image: ghcr.io/lehhair/opencodeui-backend:latest
image: ghcr.io/evandrodevbr/opencodeui-backend:latest
container_name: opencode-backend
restart: unless-stopped
deploy:
+51 -1
View File
@@ -1,4 +1,10 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import * as os from 'node:os'
vi.mock('node:os', async importOriginal => ({
...(await importOriginal<typeof import('node:os')>()),
networkInterfaces: vi.fn().mockReturnValue({}),
}))
import {
isLoopbackHost,
validateBearerToken,
@@ -8,9 +14,47 @@ import {
isRequestBodySizeAllowed,
getLocalNetworkUrls,
validateSttBaseUrl,
sendInternalServerError,
} from './piagentui-server-core.js'
describe('PiAgentUi Server Core Security Helpers', () => {
describe('sendInternalServerError', () => {
const response = () => ({
headersSent: false,
writableEnded: false,
destroyed: false,
writeHead: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
})
it('sends a JSON error when the response has not started', () => {
const res = response()
sendInternalServerError(res)
expect(res.writeHead).toHaveBeenCalledWith(500, { 'Content-Type': 'application/json' })
expect(res.end).toHaveBeenCalledWith('{"error":"Internal Server Error"}')
expect(res.destroy).not.toHaveBeenCalled()
})
it('closes a partial response without writing another header', () => {
const res = { ...response(), headersSent: true }
sendInternalServerError(res)
expect(res.destroy).toHaveBeenCalledOnce()
expect(res.writeHead).not.toHaveBeenCalled()
expect(res.end).not.toHaveBeenCalled()
})
it('leaves completed and destroyed responses alone', () => {
for (const state of [{ writableEnded: true }, { destroyed: true }]) {
const res = { ...response(), ...state }
sendInternalServerError(res)
expect(res.writeHead).not.toHaveBeenCalled()
expect(res.end).not.toHaveBeenCalled()
expect(res.destroy).not.toHaveBeenCalled()
}
})
})
describe('isLoopbackHost', () => {
it('should accept loopback hostnames and IPs', () => {
expect(isLoopbackHost('localhost')).toBe(true)
@@ -84,6 +128,12 @@ describe('PiAgentUi Server Core Security Helpers', () => {
})
describe('getLocalNetworkUrls', () => {
it('preserves local access when network enumeration is unavailable', () => {
vi.mocked(os.networkInterfaces).mockImplementationOnce(() => {
throw new Error('network enumeration denied')
})
expect(getLocalNetworkUrls(58785)).toEqual([])
})
it('should build LAN URLs from non-internal IPv4 interfaces', () => {
const urls = getLocalNetworkUrls(58785, {
WiFi: [
+23 -4
View File
@@ -1,5 +1,19 @@
import * as path from 'node:path'
import * as os from 'node:os'
import type { ServerResponse } from 'node:http'
/** Finish a failed request without sending headers twice or leaving a partial response open. */
export function sendInternalServerError(
response: Pick<ServerResponse, 'headersSent' | 'writableEnded' | 'destroyed' | 'writeHead' | 'end' | 'destroy'>,
): void {
if (response.writableEnded || response.destroyed) return
if (response.headersSent) {
response.destroy()
return
}
response.writeHead(500, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ error: 'Internal Server Error' }))
}
/**
* Checks if a host string matches a loopback host (localhost or 127.0.0.1 or ::1)
@@ -123,10 +137,15 @@ export function validateHostAndOriginForAccess(
return true
}
export function getLocalNetworkUrls(
port: number,
interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces(),
): string[] {
export function getLocalNetworkUrls(port: number, interfaces?: NodeJS.Dict<os.NetworkInterfaceInfo[]>): string[] {
if (!interfaces) {
try {
interfaces = os.networkInterfaces()
} catch {
// LAN discovery is best effort; local API access still works without it.
return []
}
}
const physicalUrls = new Set<string>()
const fallbackUrls = new Set<string>()
+7 -2
View File
@@ -1025,8 +1025,13 @@ describe('PiAgentUi Extension Server', () => {
// Trigger agent_start - should broadcast session.status and session.updated!
await listeners['agent_start']()
const chunk3 = await reader?.read()
const text3 = new TextDecoder().decode(chunk3?.value)
// The two events are separate writes, so TCP may deliver them in different chunks.
let text3 = ''
while (!text3.includes('session.updated')) {
const chunk = await reader?.read()
if (!chunk || chunk.done) break
text3 += new TextDecoder().decode(chunk.value)
}
expect(text3).toContain('session.status')
expect(text3).toContain('working')
expect(text3).toContain('session.updated')
+81 -139
View File
@@ -3,7 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws'
import * as http from 'node:http'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { randomUUID } from 'node:crypto'
import { randomBytes, randomUUID } from 'node:crypto'
import {
validateBearerToken,
preventPathTraversal,
@@ -12,6 +12,7 @@ import {
isRequestBodySizeAllowed,
getLocalNetworkUrls,
validateSttBaseUrl,
sendInternalServerError,
} from './piagentui-server-core.js'
import {
createPiAgentUiSettingsStore,
@@ -20,6 +21,8 @@ import {
type StoredSttSettings,
type StoredTtsSettings,
} from './piagentui-settings-store.js'
import { createStatCache } from './piagentui-stat-cache.js'
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
import {
buildExtensionCatalog,
executeExtensionInstallCommand,
@@ -516,48 +519,76 @@ export default function (pi: ExtensionAPI) {
.slice(0, 120)
}
type ParsedSessionFile = {
id: string
title: string
cwd: string | undefined
headerTimestamp: string | undefined
messageCount: number
}
// GET /api/sessions parses every session file; re-parse only files whose mtime/size changed.
const sessionFileCache = createStatCache<ParsedSessionFile | null>()
function parseSessionFile(filePath: string): ParsedSessionFile | null {
const content = fs.readFileSync(filePath, 'utf8')
const lines = content.trim().split('\n').filter(Boolean)
if (lines.length === 0) return null
const header = JSON.parse(lines[0])
if (header.type !== 'session' || !header.id) return null
let sessionInfoTitle = ''
let firstUserMessage = ''
let messageCount = 0
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line)
if (entry.type === 'session_info') {
sessionInfoTitle = normalizeSessionTitle(entry.name || '')
} else if (entry.type === 'message') {
messageCount++
if (!firstUserMessage && entry.message?.role === 'user') {
firstUserMessage = normalizeSessionTitle(getMessageText(entry.message))
}
}
} catch {
// ignore malformed entries
}
}
return {
id: header.id,
title: sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id),
cwd: header.cwd,
headerTimestamp: header.timestamp,
messageCount,
}
}
function readSessionMetadata(filePath: string): any | null {
try {
const content = fs.readFileSync(filePath, 'utf8')
const lines = content.trim().split('\n').filter(Boolean)
if (lines.length === 0) return null
const header = JSON.parse(lines[0])
if (header.type !== 'session' || !header.id) return null
let sessionInfoTitle = ''
let firstUserMessage = ''
let messageCount = 0
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line)
if (entry.type === 'session_info') {
sessionInfoTitle = normalizeSessionTitle(entry.name || '')
} else if (entry.type === 'message') {
messageCount++
if (!firstUserMessage && entry.message?.role === 'user') {
firstUserMessage = normalizeSessionTitle(getMessageText(entry.message))
}
}
} catch {
// ignore malformed entries
}
const stats = fs.statSync(filePath)
let cached = sessionFileCache.get(filePath, stats)
if (!cached) {
cached = { value: parseSessionFile(filePath) }
sessionFileCache.set(filePath, stats, cached.value)
}
const stats = fs.statSync(filePath)
const createdTime = header.timestamp ? new Date(header.timestamp).getTime() : stats.birthtimeMs
const title = sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id)
const parsed = cached.value
if (!parsed) return null
const createdTime = parsed.headerTimestamp ? new Date(parsed.headerTimestamp).getTime() : stats.birthtimeMs
return {
id: header.id,
title,
directory: header.cwd || latestCtx?.cwd || process.cwd(),
id: parsed.id,
title: parsed.title,
directory: parsed.cwd || latestCtx?.cwd || process.cwd(),
time: {
created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime,
updated: stats.mtimeMs,
},
summary: { deletions: 0, files: 0, additions: messageCount },
summary: { deletions: 0, files: 0, additions: parsed.messageCount },
}
} catch {
return null
@@ -690,7 +721,8 @@ export default function (pi: ExtensionAPI) {
}
function generateBearerToken(): string {
return Array.from({ length: 4 }, () => Math.random().toString(36).substring(2)).join('-')
// CSPRNG: the token is the only credential guarding the local API.
return randomBytes(24).toString('base64url')
}
const settingsStore = createPiAgentUiSettingsStore(resolvePiAgentUiSettingsPaths())
@@ -773,56 +805,11 @@ export default function (pi: ExtensionAPI) {
function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } {
const current = getLocalSettings()
const existing = current.stt || {}
const next: StoredSttSettings = { ...existing }
const result = applySttSettingsUpdate(current.stt || {}, update)
if (!result.ok) return result
if (update.enabled !== undefined) next.enabled = update.enabled === true
if (update.providerKind !== undefined) {
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
return { ok: false, error: 'Invalid providerKind' }
}
next.providerKind = update.providerKind
}
if (update.mode !== undefined) {
if (update.mode !== 'file' && update.mode !== 'realtime') return { ok: false, error: 'Invalid mode' }
next.mode = update.mode
}
if (update.baseUrl !== undefined) {
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
return { ok: false, error: 'Invalid baseUrl' }
}
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
}
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
if (update.transcriptionEndpoint !== undefined) {
if (typeof update.transcriptionEndpoint !== 'string' || !update.transcriptionEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid transcriptionEndpoint' }
}
next.transcriptionEndpoint = update.transcriptionEndpoint
}
if (update.transcriptionModel !== undefined) {
if (typeof update.transcriptionModel !== 'string' || !update.transcriptionModel.trim()) {
return { ok: false, error: 'Invalid transcriptionModel' }
}
next.transcriptionModel = update.transcriptionModel.trim()
}
if (update.language !== undefined) {
if (update.language === null || update.language === '') delete next.language
else if (typeof update.language === 'string') next.language = update.language.trim()
else return { ok: false, error: 'Invalid language' }
}
if (update.insertMode !== undefined) {
if (update.insertMode !== 'append' && update.insertMode !== 'replace')
return { ok: false, error: 'Invalid insertMode' }
next.insertMode = update.insertMode
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
saveLocalSettings({ ...current, stt: next })
return { ok: true, settings: sanitizeSttSettings(next) }
saveLocalSettings({ ...current, stt: result.next })
return { ok: true, settings: sanitizeSttSettings(result.next) }
}
function getEffectiveTtsSettings(): StoredTtsSettings {
@@ -856,62 +843,13 @@ export default function (pi: ExtensionAPI) {
}
}
function isTtsResponseFormat(value: unknown): value is NonNullable<StoredTtsSettings['responseFormat']> {
return (
value === 'mp3' || value === 'opus' || value === 'aac' || value === 'flac' || value === 'wav' || value === 'pcm'
)
}
function updateTtsSettings(update: any): { ok: true; settings: SanitizedTtsSettings } | { ok: false; error: string } {
const current = getLocalSettings()
const existing = current.tts || {}
const next: StoredTtsSettings = { ...existing }
const result = applyTtsSettingsUpdate(current.tts || {}, update)
if (!result.ok) return result
if (update.enabled !== undefined) next.enabled = update.enabled === true
if (update.providerKind !== undefined) {
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
return { ok: false, error: 'Invalid providerKind' }
}
next.providerKind = update.providerKind
}
if (update.baseUrl !== undefined) {
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
return { ok: false, error: 'Invalid baseUrl' }
}
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
}
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
if (update.speechEndpoint !== undefined) {
if (typeof update.speechEndpoint !== 'string' || !update.speechEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid speechEndpoint' }
}
next.speechEndpoint = update.speechEndpoint
}
if (update.model !== undefined) {
if (typeof update.model !== 'string' || !update.model.trim()) return { ok: false, error: 'Invalid model' }
next.model = update.model.trim()
}
if (update.voice !== undefined) {
if (typeof update.voice !== 'string' || !update.voice.trim()) return { ok: false, error: 'Invalid voice' }
next.voice = update.voice.trim()
}
if (update.responseFormat !== undefined) {
if (!isTtsResponseFormat(update.responseFormat)) return { ok: false, error: 'Invalid responseFormat' }
next.responseFormat = update.responseFormat
}
if (update.speed !== undefined) {
if (typeof update.speed !== 'number' || update.speed < 0.25 || update.speed > 4) {
return { ok: false, error: 'Invalid speed' }
}
next.speed = update.speed
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
saveLocalSettings({ ...current, tts: next })
return { ok: true, settings: sanitizeTtsSettings(next) }
saveLocalSettings({ ...current, tts: result.next })
return { ok: true, settings: sanitizeTtsSettings(result.next) }
}
async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> {
@@ -2075,8 +2013,7 @@ export default function (pi: ExtensionAPI) {
const urlPath = req.url || ''
if (urlPath.startsWith('/api/') || urlPath.startsWith('/global/')) {
handleApiRequest(req, res).catch(() => {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Internal Server Error' }))
sendInternalServerError(res)
})
} else {
handleStaticFile(req, res, urlPath)
@@ -2129,7 +2066,12 @@ export default function (pi: ExtensionAPI) {
if (!fs.existsSync(piAgentDir)) {
fs.mkdirSync(piAgentDir, { recursive: true })
}
fs.writeFileSync(discoveryPath, JSON.stringify({ port: localPort, token: localToken }, null, 2), 'utf8')
// The file holds the API token: keep it readable by the owner only (mode is ignored on Windows).
fs.writeFileSync(discoveryPath, JSON.stringify({ port: localPort, token: localToken }, null, 2), {
encoding: 'utf8',
mode: 0o600,
})
fs.chmodSync(discoveryPath, 0o600)
console.log(`[PiAgentUi] Wrote connection info to: ${discoveryPath}`)
} catch (err) {
console.error('[PiAgentUi] Failed to write discovery file:', err)
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
function stt(update: Record<string, unknown>, existing = {}) {
return applySttSettingsUpdate(existing, update)
}
function tts(update: Record<string, unknown>, existing = {}) {
return applyTtsSettingsUpdate(existing, update)
}
describe('applySttSettingsUpdate', () => {
it('returns the existing settings untouched for an empty update', () => {
const existing = { enabled: true, language: 'pt' }
expect(stt({}, existing)).toEqual({ ok: true, next: existing })
})
it('applies a valid update of every field', () => {
const result = stt({
enabled: true,
providerKind: 'openai',
mode: 'realtime',
baseUrl: 'https://api.example.com/v1///',
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: ' whisper-1 ',
language: ' en ',
insertMode: 'replace',
apiKey: ' secret ',
})
expect(result).toEqual({
ok: true,
next: {
enabled: true,
providerKind: 'openai',
mode: 'realtime',
baseUrl: 'https://api.example.com/v1',
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'whisper-1',
language: 'en',
insertMode: 'replace',
apiKey: 'secret',
},
})
})
it('does not mutate the existing settings', () => {
const existing = { enabled: false }
stt({ enabled: true }, existing)
expect(existing).toEqual({ enabled: false })
})
it.each([
['providerKind', { providerKind: 'other' }],
['mode', { mode: 'stream' }],
['baseUrl', { baseUrl: 'ftp://host' }],
['baseUrl', { baseUrl: 42 }],
['transcriptionEndpoint', { transcriptionEndpoint: 'audio' }],
['transcriptionModel', { transcriptionModel: ' ' }],
['language', { language: 5 }],
['insertMode', { insertMode: 'prepend' }],
])('rejects an invalid %s', (field, update) => {
expect(stt(update)).toEqual({ ok: false, error: `Invalid ${field}` })
})
it('coerces enabled/apiKeyRequired to booleans', () => {
expect(stt({ enabled: 'yes', apiKeyRequired: 1 })).toEqual({
ok: true,
next: { enabled: false, apiKeyRequired: false },
})
})
it('clears language with null or empty string', () => {
expect(stt({ language: null }, { language: 'pt' })).toEqual({ ok: true, next: {} })
expect(stt({ language: '' }, { language: 'pt' })).toEqual({ ok: true, next: {} })
})
it('handles apiKey: null clears, blank string keeps, non-string keeps', () => {
expect(stt({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} })
expect(stt({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
expect(stt({ apiKey: 12 }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
})
})
describe('applyTtsSettingsUpdate', () => {
it('returns the existing settings untouched for an empty update', () => {
const existing = { voice: 'alloy' }
expect(tts({}, existing)).toEqual({ ok: true, next: existing })
})
it('applies a valid update of every field', () => {
const result = tts({
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://localhost:8880/v1/',
apiKeyRequired: true,
speechEndpoint: '/audio/speech',
model: ' tts-1-hd ',
voice: ' nova ',
responseFormat: 'wav',
speed: 1.5,
apiKey: ' k ',
})
expect(result).toEqual({
ok: true,
next: {
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://localhost:8880/v1',
apiKeyRequired: true,
speechEndpoint: '/audio/speech',
model: 'tts-1-hd',
voice: 'nova',
responseFormat: 'wav',
speed: 1.5,
apiKey: 'k',
},
})
})
it.each([
['providerKind', { providerKind: 'x' }],
['baseUrl', { baseUrl: 'not a url' }],
['speechEndpoint', { speechEndpoint: 'speech' }],
['model', { model: '' }],
['voice', { voice: 3 }],
['responseFormat', { responseFormat: 'ogg' }],
['speed', { speed: '1' }],
['speed', { speed: 0.1 }],
['speed', { speed: 4.5 }],
])('rejects an invalid %s', (field, update) => {
expect(tts(update)).toEqual({ ok: false, error: `Invalid ${field}` })
})
it('accepts speed at both bounds', () => {
expect(tts({ speed: 0.25 })).toEqual({ ok: true, next: { speed: 0.25 } })
expect(tts({ speed: 4 })).toEqual({ ok: true, next: { speed: 4 } })
})
it('handles apiKey: null clears, blank string keeps', () => {
expect(tts({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} })
expect(tts({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
})
})
describe('isTtsResponseFormat', () => {
it('accepts supported formats only', () => {
for (const f of ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']) expect(isTtsResponseFormat(f)).toBe(true)
expect(isTtsResponseFormat('ogg')).toBe(false)
expect(isTtsResponseFormat(undefined)).toBe(false)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { validateSttBaseUrl } from './piagentui-server-core.js'
import type { StoredSttSettings, StoredTtsSettings } from './piagentui-settings-store.js'
export type SettingsUpdateResult<T> = { ok: true; next: T } | { ok: false; error: string }
type Fields = Record<string, unknown>
/** Assigns a validated value to `next`; returns false when the value is invalid. */
type FieldRule = (value: unknown, next: Fields) => boolean
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'] as const
export function isTtsResponseFormat(value: unknown): value is NonNullable<StoredTtsSettings['responseFormat']> {
return typeof value === 'string' && (TTS_RESPONSE_FORMATS as readonly string[]).includes(value)
}
const booleanField =
(key: string): FieldRule =>
(value, next) => {
next[key] = value === true
return true
}
const oneOfField =
(key: string, allowed: readonly string[]): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !allowed.includes(value)) return false
next[key] = value
return true
}
const endpointField =
(key: string): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !value.startsWith('/')) return false
next[key] = value
return true
}
const trimmedField =
(key: string): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !value.trim()) return false
next[key] = value.trim()
return true
}
const baseUrlField: FieldRule = (value, next) => {
if (typeof value !== 'string' || !validateSttBaseUrl(value)) return false
next.baseUrl = value.replace(/\/+$/, '')
return true
}
const languageField: FieldRule = (value, next) => {
if (value === null || value === '') delete next.language
else if (typeof value === 'string') next.language = value.trim()
else return false
return true
}
const speedField: FieldRule = (value, next) => {
if (typeof value !== 'number' || value < 0.25 || value > 4) return false
next.speed = value
return true
}
const PROVIDER_KINDS = ['openai', 'openai-compatible'] as const
const STT_RULES: Array<[string, FieldRule]> = [
['enabled', booleanField('enabled')],
['providerKind', oneOfField('providerKind', PROVIDER_KINDS)],
['mode', oneOfField('mode', ['file', 'realtime'])],
['baseUrl', baseUrlField],
['apiKeyRequired', booleanField('apiKeyRequired')],
['transcriptionEndpoint', endpointField('transcriptionEndpoint')],
['transcriptionModel', trimmedField('transcriptionModel')],
['language', languageField],
['insertMode', oneOfField('insertMode', ['append', 'replace'])],
]
const TTS_RULES: Array<[string, FieldRule]> = [
['enabled', booleanField('enabled')],
['providerKind', oneOfField('providerKind', PROVIDER_KINDS)],
['baseUrl', baseUrlField],
['apiKeyRequired', booleanField('apiKeyRequired')],
['speechEndpoint', endpointField('speechEndpoint')],
['model', trimmedField('model')],
['voice', trimmedField('voice')],
['responseFormat', oneOfField('responseFormat', TTS_RESPONSE_FORMATS)],
['speed', speedField],
]
function applyUpdate<T extends object>(
existing: T,
update: Record<string, unknown>,
rules: Array<[string, FieldRule]>,
): SettingsUpdateResult<T> {
const next: Fields = { ...(existing as Fields) }
for (const [field, rule] of rules) {
if (update[field] === undefined) continue
if (!rule(update[field], next)) return { ok: false, error: `Invalid ${field}` }
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
return { ok: true, next: next as T }
}
/** Validates a PATCH-style STT update against the stored settings. Pure: does not persist anything. */
export function applySttSettingsUpdate(
existing: StoredSttSettings,
update: Record<string, unknown>,
): SettingsUpdateResult<StoredSttSettings> {
return applyUpdate(existing, update, STT_RULES)
}
/** Validates a PATCH-style TTS update against the stored settings. Pure: does not persist anything. */
export function applyTtsSettingsUpdate(
existing: StoredTtsSettings,
update: Record<string, unknown>,
): SettingsUpdateResult<StoredTtsSettings> {
return applyUpdate(existing, update, TTS_RULES)
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { createStatCache } from './piagentui-stat-cache.js'
const stamp = (mtimeMs: number, size = 10) => ({ mtimeMs, size })
describe('createStatCache', () => {
it('misses for unknown paths and hits for the same stamp', () => {
const cache = createStatCache<string>()
expect(cache.get('a', stamp(1))).toBeUndefined()
cache.set('a', stamp(1), 'x')
expect(cache.get('a', stamp(1))).toEqual({ value: 'x' })
})
it('treats a changed mtime or size as a miss and drops the stale entry', () => {
const cache = createStatCache<string>()
cache.set('a', stamp(1, 10), 'x')
expect(cache.get('a', stamp(2, 10))).toBeUndefined()
expect(cache.size).toBe(0)
cache.set('a', stamp(1, 10), 'x')
expect(cache.get('a', stamp(1, 11))).toBeUndefined()
})
it('can cache null values', () => {
const cache = createStatCache<string | null>()
cache.set('a', stamp(1), null)
expect(cache.get('a', stamp(1))).toEqual({ value: null })
})
it('evicts the oldest entries beyond the limit', () => {
const cache = createStatCache<number>(2)
cache.set('a', stamp(1), 1)
cache.set('b', stamp(1), 2)
cache.set('c', stamp(1), 3)
expect(cache.size).toBe(2)
expect(cache.get('a', stamp(1))).toBeUndefined()
expect(cache.get('b', stamp(1))).toEqual({ value: 2 })
expect(cache.get('c', stamp(1))).toEqual({ value: 3 })
})
it('refreshing a key moves it to the newest position', () => {
const cache = createStatCache<number>(2)
cache.set('a', stamp(1), 1)
cache.set('b', stamp(1), 2)
cache.set('a', stamp(2), 11)
cache.set('c', stamp(1), 3)
expect(cache.get('b', stamp(1))).toBeUndefined()
expect(cache.get('a', stamp(2))).toEqual({ value: 11 })
})
})
+34
View File
@@ -0,0 +1,34 @@
export type FileStamp = { mtimeMs: number; size: number }
/**
* Small bounded cache of values derived from file contents, keyed by path and
* invalidated when the file's mtime or size changes. Oldest entries are evicted first.
*/
export function createStatCache<T>(maxEntries = 500) {
const entries = new Map<string, FileStamp & { value: T }>()
return {
/** Returns `{ value }` on a hit (the value itself may be null), `undefined` on a miss or stale entry. */
get(filePath: string, stamp: FileStamp): { value: T } | undefined {
const entry = entries.get(filePath)
if (!entry) return undefined
if (entry.mtimeMs !== stamp.mtimeMs || entry.size !== stamp.size) {
entries.delete(filePath)
return undefined
}
return { value: entry.value }
},
set(filePath: string, stamp: FileStamp, value: T): void {
entries.delete(filePath)
entries.set(filePath, { mtimeMs: stamp.mtimeMs, size: stamp.size, value })
while (entries.size > maxEntries) {
const oldest = entries.keys().next().value
if (oldest === undefined) break
entries.delete(oldest)
}
},
get size(): number {
return entries.size
},
}
}
+2450 -156
View File
File diff suppressed because it is too large. Load diff
+8 -4
View File
@@ -2,13 +2,16 @@
"name": "piagentui",
"version": "0.6.10",
"description": "Professional AI Agent Desktop & Web UI — sessions, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Works with Pi Agent, Claude Code, Codex, OpenCode, Cursor, and any SSE-compatible agent.",
"homepage": "https://github.com/lehhair/OpenCodeUI",
"homepage": "https://github.com/evandrodevbr/PiAgentUI",
"repository": {
"type": "git",
"url": "https://github.com/lehhair/OpenCodeUI.git"
"url": "git+https://github.com/evandrodevbr/PiAgentUI.git"
},
"license": "GPL-3.0-only",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "vite",
"typecheck": "tsc -b",
@@ -21,9 +24,9 @@
"format:check": "prettier . --check",
"test": "vitest",
"test:run": "vitest run",
"validate": "npm run typecheck && npm run typecheck:extensions && npm run lint && npm run test:run && npm run build",
"validate": "npm run typecheck && npm run typecheck:extensions && npm run lint && npm run build && npm run test:run",
"release:prepare": "node scripts/prepare-release.mjs",
"check": "npm run format:check && npm run lint && npm run test:run && npm run build",
"check": "npm run format:check && npm run lint && npm run build && npm run test:run",
"preview": "vite preview",
"tauri": "tauri",
"postinstall": "node scripts/copy-material-icons.mjs"
@@ -61,6 +64,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@mariozechner/pi-coding-agent": "^0.73.1",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2.10.0",
"@testing-library/jest-dom": "^6.9.1",
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff6e40" d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2v-1H2V2h2V1Zm10 0v1h2v12h-2v1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1ZM6 3c0 1 0 1-1 1v1h1v2h1V3Zm3 0a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm0 1h1v2H9ZM6 9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1Zm4 0c0 1 0 1-1 1v1h1v2h1V9Zm-4 1h1v2H6Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff6e40" d="M8 6V2H4a2 2 0 0 0-2 2v24a2 2 0 0 0 2 2h4v-4H4V6Zm16-4v4h4v20h-4v4h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Zm-4 4h-2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2m-2 6V8h2v4Zm-4 6h-2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2m-2 6v-4h2v4Zm0-18c0 2 0 2-2 2v2h2v4h2V6Zm8 12c0 2 0 2-2 2v2h2v4h2v-8Z"/></svg>

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 415 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M14.483 6H4.721a1 1 0 0 0-.949.684L2 12V5h12a1 1 0 0 0-1-1H7.562a1 1 0 0 1-.64-.232l-.644-.536A1 1 0 0 0 5.638 3H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h11l2.403-5.606A1 1 0 0 0 14.483 6"/><path fill="#ffecb3" d="M12 5a4 2 0 0 0-4 2 4 2 0 0 0 4 2 4 2 0 0 0 4-2 4 2 0 0 0-4-2M8 8v2c0 1.105 1.791 2 4 2s4-.895 4-2V8c0 1.105-1.791 2-4 2s-4-.895-4-2m0 3v2c0 1.105 1.791 2 4 2s4-.895 4-2v-2c0 1.105-1.791 2-4 2s-4-.895-4-2"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M28.967 12H9.442a2 2 0 0 0-1.898 1.368L4 24V10h24a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464l-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h22l4.805-11.212A2 2 0 0 0 28.967 12"/><ellipse cx="24" cy="14" fill="#ffecb3" rx="8" ry="4"/><path fill="#ffecb3" d="M24 26c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/><path fill="#ffecb3" d="M24 20c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/></svg>

Before

Width:  |  Height:  |  Size: 502 B

After

Width:  |  Height:  |  Size: 553 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="m6.922 3.768-.644-.536A1 1 0 0 0 5.638 3H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H7.562a1 1 0 0 1-.64-.232"/><path fill="#ffecb3" d="M12 5a4 2 0 0 0-4 2 4 2 0 0 0 4 2 4 2 0 0 0 4-2 4 2 0 0 0-4-2M8 8v2c0 1.105 1.791 2 4 2s4-.895 4-2V8c0 1.105-1.791 2-4 2s-4-.895-4-2m0 3v2c0 1.105 1.791 2 4 2s4-.895 4-2v-2c0 1.105-1.791 2-4 2s-4-.895-4-2"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="m13.844 7.536-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h24a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464"/><ellipse cx="24" cy="14" fill="#ffecb3" rx="8" ry="4"/><path fill="#ffecb3" d="M24 26c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/><path fill="#ffecb3" d="M24 20c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/></svg>

Before

Width:  |  Height:  |  Size: 454 B

After

Width:  |  Height:  |  Size: 500 B

+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.6.10"
description = "Pi Agent UI - Tauri Desktop & Mobile App"
authors = ["lehhair"]
license = "GPL-3.0-only"
repository = ""
repository = "https://github.com/evandrodevbr/PiAgentUI"
edition = "2021"
rust-version = "1.85.0"
-51
View File
@@ -1,51 +0,0 @@
import type { Session, Model } from '../types/api/compat'
export interface SessionListResponse {
sessions: Session[]
}
export interface SessionCreateRequest {
title?: string
directory?: string
}
export interface SessionUpdateRequest {
title?: string
}
export interface SessionForkRequest {
messageID?: string
directory?: string
}
export interface SendMessageRequest {
sessionId: string
text: string
model?: { providerID: string; modelID: string }
directory?: string
}
export interface SendMessageResponse {
messageID: string
sessionID: string
}
export interface ModelsResponse {
models: Model[]
}
export interface PartDeltaPayload {
sessionID: string
messageID: string
partID: string
field: 'text' | 'reasoning' | 'tool'
delta: string
}
export interface PiGlobalEvent {
directory?: string
payload: {
type: string
properties: unknown
}
}
+3 -93
View File
@@ -1,6 +1,5 @@
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { animate } from 'motion/mini'
import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons'
import { CopyButton, SmoothHeight } from '../../components/ui'
@@ -25,6 +24,8 @@ import {
MessageErrorView,
} from './parts'
import { extractToolData } from './tools'
import { getToolSummaryCategory, type ToolSummaryCategory } from './toolSummary'
import { computeDiffStats } from '../../utils/diffStats'
import type {
Message,
Part,
@@ -597,7 +598,7 @@ const ToolGroup = memo(function ToolGroup({
for (const part of parts) {
if (part.state?.status === 'error') continue
const data = extractToolData(part)
const stats = data.diffStats || computePartDiffStats(data)
const stats = data.diffStats || computeDiffStats(data)
if (stats) {
additions += stats.additions
deletions += stats.deletions
@@ -774,21 +775,6 @@ function formatTokens(
return `${total} ${t('tokens')}`
}
type ToolSummaryCategory =
| 'execute'
| 'write'
| 'edit'
| 'read'
| 'search'
| 'list'
| 'network'
| 'task'
| 'todo'
| 'question'
| 'skill'
| 'think'
| 'other'
type ToolSummaryPhase = 'done' | 'active' | 'failed'
interface SummarySegment {
@@ -897,45 +883,6 @@ function formatToolSummarySegment(
return t(key, { count })
}
function getToolSummaryCategory(toolName: string): ToolSummaryCategory {
const lower = toolName.toLowerCase()
if (lower.includes('todo')) return 'todo'
if (lower === 'task') return 'task'
if (lower.includes('question') || lower.includes('ask')) return 'question'
if (lower.includes('skill')) return 'skill'
if (
lower.includes('bash') ||
lower === 'sh' ||
lower.includes('cmd') ||
lower.includes('terminal') ||
lower.includes('shell')
) {
return 'execute'
}
if (lower.includes('write') || lower.includes('save')) {
return 'write'
}
if (lower.includes('edit') || lower.includes('replace') || lower.includes('patch')) {
return 'edit'
}
if (
lower.includes('web') ||
lower.includes('fetch') ||
lower.includes('http') ||
lower.includes('browse') ||
lower.includes('network') ||
lower.includes('exa')
) {
return 'network'
}
if (lower.includes('read') || lower.includes('cat')) return 'read'
if (lower.includes('grep') || lower.includes('search')) return 'search'
if (lower.includes('glob') || lower.includes('find')) return 'list'
if (lower.includes('think') || lower.includes('reason') || lower.includes('plan')) return 'think'
return 'other'
}
function isToolPartActive(part: ToolPart): boolean {
return part.state?.status === 'running' || part.state?.status === 'pending'
}
@@ -946,43 +893,6 @@ function getTaskChildSessionId(part: ToolPart): string | undefined {
return metadata?.sessionId as string | undefined
}
/** 从 extractToolData 的结果计算 diff stats(当 metadata 没给 diffStats 时) */
function computePartDiffStats(data: {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}): { additions: number; deletions: number } | undefined {
if (data.files?.length) {
let a = 0,
d = 0
for (const f of data.files) {
if (f.additions !== undefined) a += f.additions
if (f.deletions !== undefined) d += f.deletions
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
const s = diffPairStats(f.before, f.after)
a += s.additions
d += s.deletions
}
}
return a || d ? { additions: a, deletions: d } : undefined
}
if (data.diff && typeof data.diff === 'object') {
const s = diffPairStats(data.diff.before, data.diff.after)
return s.additions || s.deletions ? s : undefined
}
return undefined
}
function diffPairStats(before: string, after: string): { additions: number; deletions: number } {
const changes = diffLines(before, after)
let additions = 0,
deletions = 0
for (const c of changes) {
if (c.added) additions += c.count || 0
if (c.removed) deletions += c.count || 0
}
return { additions, deletions }
}
// ============================================
// Helper: Group parts for rendering
// ============================================
+2 -43
View File
@@ -1,6 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { computeDiffStats } from '../../../utils/diffStats'
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
import type { ToolPart } from '../../../types/message'
import { useDelayedRender } from '../../../hooks'
@@ -203,7 +203,7 @@ export const ToolPartView = memo(function ToolPartView({
if (descriptive) {
const hasDiffFiles = !!toolData.files?.length
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData)
const diffStats = toolData.diffStats || computeDiffStats(toolData)
return (
<div className="group py-0.5">
@@ -451,47 +451,6 @@ function isReadableTool(toolName: string): boolean {
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase())
}
/** 从 diff 数据计算 diffStats(当 metadata 没给 diffStats 时用) */
function computeDiffStatsFromData(data: {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}): { additions: number; deletions: number } | undefined {
// 多文件
if (data.files?.length) {
let additions = 0,
deletions = 0
for (const f of data.files) {
if (f.additions !== undefined) additions += f.additions
if (f.deletions !== undefined) deletions += f.deletions
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
const s = computeDiffPair(f.before, f.after)
additions += s.additions
deletions += s.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}
// 单个 diff
if (data.diff && typeof data.diff === 'object') {
const s = computeDiffPair(data.diff.before, data.diff.after)
return s.additions || s.deletions ? s : undefined
}
return undefined
}
function computeDiffPair(before: string, after: string): { additions: number; deletions: number } {
const changes = diffLines(before, after)
let additions = 0,
deletions = 0
for (const c of changes) {
if (c.added) additions += c.count || 0
if (c.removed) deletions += c.count || 0
}
return { additions, deletions }
}
// ============================================
// ToolBody - 根据工具类型选择渲染器
// ============================================
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { getToolSummaryCategory } from './toolSummary'
describe('getToolSummaryCategory', () => {
it.each([
['TodoWrite', 'todo'],
['task', 'task'],
['AskUserQuestion', 'question'],
['skill', 'skill'],
['bash', 'execute'],
['sh', 'execute'],
['run_cmd', 'execute'],
['terminal', 'execute'],
['shell_exec', 'execute'],
['write', 'write'],
['save_file', 'write'],
['edit', 'edit'],
['str_replace', 'edit'],
['apply_patch', 'edit'],
['webfetch', 'network'],
['http_get', 'network'],
['browse', 'network'],
['exa_search', 'network'],
['read', 'read'],
['cat', 'read'],
['grep', 'search'],
['search', 'search'],
['glob', 'list'],
['find_files', 'list'],
['think', 'think'],
['plan_mode', 'think'],
['unknown_tool', 'other'],
])('maps %s to %s', (tool, category) => {
expect(getToolSummaryCategory(tool)).toBe(category)
})
it('is case insensitive', () => {
expect(getToolSummaryCategory('BASH')).toBe('execute')
})
it('matches "task" and "sh" only exactly', () => {
expect(getToolSummaryCategory('tasks')).toBe('question') // contains "ask", not an exact "task"
expect(getToolSummaryCategory('push')).toBe('other')
})
it('keeps the precedence of earlier rules', () => {
// "todo" beats "write"; "ask" beats "read" in "ask_read"; "shell" (execute) beats "cat" in "shell_cat"
expect(getToolSummaryCategory('todowrite')).toBe('todo')
expect(getToolSummaryCategory('ask_read')).toBe('question')
expect(getToolSummaryCategory('shell_cat')).toBe('execute')
// "web" (network) beats "search"
expect(getToolSummaryCategory('websearch')).toBe('network')
})
})
+47
View File
@@ -0,0 +1,47 @@
export type ToolSummaryCategory =
| 'execute'
| 'write'
| 'edit'
| 'read'
| 'search'
| 'list'
| 'network'
| 'task'
| 'todo'
| 'question'
| 'skill'
| 'think'
| 'other'
interface CategoryRule {
category: ToolSummaryCategory
/** Exact (lowercase) tool names. */
exact?: readonly string[]
/** Substrings looked up in the lowercase tool name. */
includes?: readonly string[]
}
/** Order matters: the first matching rule wins (e.g. "todowrite" is a todo, not a write). */
const CATEGORY_RULES: readonly CategoryRule[] = [
{ category: 'todo', includes: ['todo'] },
{ category: 'task', exact: ['task'] },
{ category: 'question', includes: ['question', 'ask'] },
{ category: 'skill', includes: ['skill'] },
{ category: 'execute', exact: ['sh'], includes: ['bash', 'cmd', 'terminal', 'shell'] },
{ category: 'write', includes: ['write', 'save'] },
{ category: 'edit', includes: ['edit', 'replace', 'patch'] },
{ category: 'network', includes: ['web', 'fetch', 'http', 'browse', 'network', 'exa'] },
{ category: 'read', includes: ['read', 'cat'] },
{ category: 'search', includes: ['grep', 'search'] },
{ category: 'list', includes: ['glob', 'find'] },
{ category: 'think', includes: ['think', 'reason', 'plan'] },
]
function matchesRule(lowerName: string, rule: CategoryRule): boolean {
return !!rule.exact?.includes(lowerName) || !!rule.includes?.some(keyword => lowerName.includes(keyword))
}
export function getToolSummaryCategory(toolName: string): ToolSummaryCategory {
const lower = toolName.toLowerCase()
return CATEGORY_RULES.find(rule => matchesRule(lower, rule))?.category ?? 'other'
}
+138 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ToolPart } from '../../../types/message'
import { defaultExtractData } from './registry'
import { defaultExtractData, extractToolData } from './registry'
describe('defaultExtractData', () => {
it('extracts files and diagnostics from metadata', () => {
@@ -43,3 +43,140 @@ describe('defaultExtractData', () => {
])
})
})
function makePart(state: Record<string, unknown> | undefined): ToolPart {
return {
type: 'tool',
tool: 'custom',
id: 'tool-1',
callID: 'call-1',
sessionID: 'session-1',
messageID: 'message-1',
state,
} as unknown as ToolPart
}
describe('defaultExtractData branches', () => {
it('returns an empty result for a part without state', () => {
expect(defaultExtractData(makePart(undefined))).toEqual({})
})
it('serializes input as JSON only when it has keys', () => {
expect(defaultExtractData(makePart({ input: { a: 1 } }))).toMatchObject({
input: JSON.stringify({ a: 1 }, null, 2),
inputLang: 'json',
})
expect(defaultExtractData(makePart({ input: {} })).input).toBeUndefined()
})
it('extracts the error as a string', () => {
expect(defaultExtractData(makePart({ error: 'boom' })).error).toBe('boom')
})
it('prefers metadata.filepath over input.filePath and detects the output language', () => {
const fromMetadata = defaultExtractData(
makePart({ input: { filePath: 'b.py' }, metadata: { filepath: 'a.ts' }, output: 'x' }),
)
expect(fromMetadata.filePath).toBe('a.ts')
expect(fromMetadata.outputLang).toBeTruthy()
expect(defaultExtractData(makePart({ input: { filePath: 'b.py' } })).filePath).toBe('b.py')
})
it('extracts a numeric exit code only', () => {
expect(defaultExtractData(makePart({ metadata: { exit: 2 } })).exitCode).toBe(2)
expect(defaultExtractData(makePart({ metadata: { exit: '2' } })).exitCode).toBeUndefined()
})
it('maps metadata.files with the unknown fallback for the name', () => {
const data = defaultExtractData(makePart({ metadata: { files: [{ file: 'f.ts' }, {}] } }))
expect(data.files?.map(f => f.filePath)).toEqual(['f.ts', 'unknown'])
})
it('uses a unified metadata.diff with filediff stats', () => {
const data = defaultExtractData(
makePart({ metadata: { diff: '@@', filediff: { additions: 3 } }, output: 'ignored' }),
)
expect(data.diff).toBe('@@')
expect(data.diffStats).toEqual({ additions: 3, deletions: 0 })
expect(data.output).toBeUndefined()
})
it('keeps a unified metadata.diff without stats when filediff has no counters', () => {
const data = defaultExtractData(makePart({ metadata: { diff: '@@', filediff: {} } }))
expect(data.diffStats).toBeUndefined()
})
it('reads filediff.patch, falling back to before/after', () => {
const patch = defaultExtractData(makePart({ metadata: { filediff: { patch: 'P', additions: 1, deletions: 2 } } }))
expect(patch.diff).toBe('P')
expect(patch.diffStats).toEqual({ additions: 1, deletions: 2 })
const pair = defaultExtractData(makePart({ metadata: { filediff: { before: 'a', after: 'b' } } }))
expect(pair.diff).toEqual({ before: 'a', after: 'b' })
expect(pair.diffStats).toBeUndefined()
expect(defaultExtractData(makePart({ metadata: { filediff: { before: 'a' } } })).diff).toBeUndefined()
})
it('keeps only error and warning diagnostics and skips malformed entries', () => {
const data = defaultExtractData(
makePart({
metadata: {
diagnostics: {
'C:\\proj\\a.ts': [
{ severity: 2, message: 'warn' },
{ severity: 3, message: 'info' },
{ severity: 9, message: 'unknown severity' },
{ message: 'no severity' },
null,
],
'b.ts': 'not-an-array',
},
},
}),
)
expect(data.diagnostics).toEqual([{ file: 'a.ts', severity: 'warning', message: 'warn', line: 0, column: 0 }])
})
it('omits diagnostics when nothing is an error or warning', () => {
const data = defaultExtractData(makePart({ metadata: { diagnostics: { 'a.ts': [{ severity: 4 }] } } }))
expect(data.diagnostics).toBeUndefined()
})
it('uses state.output, stringifying non-string values and inferring json', () => {
expect(defaultExtractData(makePart({ output: 'plain' }))).toMatchObject({ output: 'plain' })
expect(defaultExtractData(makePart({ output: 'plain' })).outputLang).toBeUndefined()
expect(defaultExtractData(makePart({ output: ' {"a":1} ' })).outputLang).toBe('json')
expect(defaultExtractData(makePart({ output: '[1]' })).outputLang).toBe('json')
expect(defaultExtractData(makePart({ output: { a: 1 } })).output).toBe(JSON.stringify({ a: 1 }, null, 2))
})
it('falls back to the interrupted metadata output only when interrupted', () => {
expect(defaultExtractData(makePart({ metadata: { interrupted: true, output: 'partial' } })).output).toBe('partial')
expect(defaultExtractData(makePart({ metadata: { output: 'partial' } })).output).toBeUndefined()
})
it('does not set output when files are present', () => {
const data = defaultExtractData(makePart({ output: 'x', metadata: { files: [{ filePath: 'a' }] } }))
expect(data.output).toBeUndefined()
})
})
describe('extractToolData for bash', () => {
const bash = (state: Record<string, unknown>) => extractToolData({ ...makePart(state), tool: 'bash' } as ToolPart)
it('shows the command as bash input and trims the working directory', () => {
const data = bash({ input: { command: 'ls -la', workdir: ' /tmp ' } })
expect(data).toMatchObject({ input: 'ls -la', inputLang: 'bash', cwd: '/tmp' })
})
it('falls back to metadata cwd and ignores blank or non-string values', () => {
expect(bash({ input: { command: 'pwd' }, metadata: { cwd: '/srv' } }).cwd).toBe('/srv')
expect(bash({ input: { command: 'pwd', workdir: ' ' } }).cwd).toBeUndefined()
expect(bash({ input: { command: 'pwd', cwd: 5 } }).cwd).toBeUndefined()
})
it('keeps the JSON input when there is no command', () => {
expect(bash({ input: { a: 1 } }).inputLang).toBe('json')
})
})
+167 -116
View File
@@ -60,6 +60,156 @@ interface MetadataDiagnosticEntry {
// Default Data Extractor
// ============================================
type DiffStats = { additions: number; deletions: number }
type MetadataFileDiff = {
patch?: string
before?: string
after?: string
additions?: number
deletions?: number
}
function toDiffStats(fd: { additions?: number; deletions?: number }): DiffStats | undefined {
if (fd.additions === undefined && fd.deletions === undefined) return undefined
return { additions: fd.additions || 0, deletions: fd.deletions || 0 }
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function toFileEntries(files: MetadataFileEntry[]): NonNullable<ExtractedToolData['files']> {
return files.map(file => ({
filePath: file.filePath || file.file || 'unknown',
diff: file.diff,
patch: file.patch,
before: file.before,
after: file.after,
additions: file.additions,
deletions: file.deletions,
}))
}
/** 上游 v1.4.0+ metadata.filediff 用 patch 格式;否则回退到 before/after */
function diffFromFileDiff(filediff: MetadataFileDiff): ExtractedToolData['diff'] {
if (filediff.patch) return filediff.patch
if (filediff.before !== undefined && filediff.after !== undefined) {
return { before: filediff.before, after: filediff.after }
}
return undefined
}
/** Extracts files / diff / diffStats from tool metadata (files > unified diff > filediff). */
function extractDiffData(metadata: Record<string, unknown>): Pick<ExtractedToolData, 'files' | 'diff' | 'diffStats'> {
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
return { files: toFileEntries(metadata.files as MetadataFileEntry[]) }
}
const filediff = isObject(metadata.filediff) ? (metadata.filediff as MetadataFileDiff) : undefined
const diffStats = filediff && toDiffStats(filediff)
// 优先使用 unified diff,统计取自 filediff
if (typeof metadata.diff === 'string') return { diff: metadata.diff, diffStats }
if (!filediff) return {}
return { diff: diffFromFileDiff(filediff), diffStats }
}
// severity: 1=error, 2=warning, 3=info, 4=hint
const DIAGNOSTIC_SEVERITIES: Record<number, DiagnosticInfo['severity']> = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint',
}
function toSeverity(value: unknown): DiagnosticInfo['severity'] {
return (typeof value === 'number' && DIAGNOSTIC_SEVERITIES[value]) || 'info'
}
function toDiagnostic(file: string, item: MetadataDiagnosticEntry): DiagnosticInfo {
const start = item.range?.start
return {
file: file.split(/[/\\]/).pop() || file,
severity: toSeverity(item.severity),
message: item.message || '',
line: start?.line ?? 0,
column: start?.character ?? 0,
}
}
/** Extracts only the error/warning diagnostics from tool metadata. */
function extractDiagnostics(raw: unknown): DiagnosticInfo[] | undefined {
if (!isObject(raw)) return undefined
const diagnostics: DiagnosticInfo[] = []
for (const [file, items] of Object.entries(raw as Record<string, MetadataDiagnosticEntry[]>)) {
if (!Array.isArray(items)) continue
for (const item of items) {
if (isObject(item)) diagnostics.push(toDiagnostic(file, item))
}
}
// 只保留 error 和 warning
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
return filtered.length > 0 ? filtered : undefined
}
function looksLikeJson(text: string): boolean {
const trimmed = text.trim()
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))
}
function extractFilePath(
inputObj: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
if (typeof metadata?.filepath === 'string' && metadata.filepath) return metadata.filepath
return inputObj?.filePath ? String(inputObj.filePath) : undefined
}
/** Tool output (or the output kept by an interrupted run), stringified. */
function extractOutputText(
state: ToolPart['state'] | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
const stateOutput = state && 'output' in state ? state.output : undefined
const interrupted =
metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
const output = stateOutput ?? interrupted
if (!output) return undefined
return typeof output === 'string' ? output : JSON.stringify(output, null, 2)
}
/** Fields derived from `metadata`: exit code, files / diff / diffStats and diagnostics. */
function extractMetadataFields(metadata: Record<string, unknown> | undefined): ExtractedToolData {
if (!metadata) return {}
const result: ExtractedToolData = {}
if (typeof metadata.exit === 'number') result.exitCode = metadata.exit
const diffData = extractDiffData(metadata)
if (diffData.files) result.files = diffData.files
if (diffData.diff !== undefined) result.diff = diffData.diff
if (diffData.diffStats) result.diffStats = diffData.diffStats
const diagnostics = extractDiagnostics(metadata.diagnostics)
if (diagnostics) result.diagnostics = diagnostics
return result
}
/** Output text plus its language (inferred as json when it looks like a JSON document). */
function extractOutputFields(
result: ExtractedToolData,
state: ToolPart['state'] | undefined,
metadata: Record<string, unknown> | undefined,
): ExtractedToolData {
const output = extractOutputText(state, metadata)
if (result.files || result.diff || !output) return {}
if (!result.outputLang && looksLikeJson(output)) return { output, outputLang: 'json' }
return { output }
}
export function defaultExtractData(part: ToolPart): ExtractedToolData {
const { state } = part
const inputObj = state?.input as Record<string, unknown> | undefined
@@ -78,120 +228,15 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
result.error = String(state.error)
}
// FilePath
if (metadata && typeof metadata.filepath === 'string') {
result.filePath = metadata.filepath
}
if (!result.filePath && inputObj?.filePath) {
result.filePath = String(inputObj.filePath)
// FilePath (+ output language derived from it)
const filePath = extractFilePath(inputObj, metadata)
if (filePath) {
result.filePath = filePath
result.outputLang = detectLanguage(filePath)
}
// Exit code
if (metadata && typeof metadata.exit === 'number') {
result.exitCode = metadata.exit
}
// Diff / Files (from metadata)
if (metadata) {
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
result.files = (metadata.files as MetadataFileEntry[]).map(file => ({
filePath: file.filePath || file.file || 'unknown',
diff: file.diff,
patch: file.patch,
before: file.before,
after: file.after,
additions: file.additions,
deletions: file.deletions,
}))
} else if (typeof metadata.diff === 'string') {
// 优先使用 unified diff
result.diff = metadata.diff
// 从 filediff 获取统计
if (metadata.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as { additions?: number; deletions?: number }
if (fd.additions !== undefined || fd.deletions !== undefined) {
result.diffStats = {
additions: fd.additions || 0,
deletions: fd.deletions || 0,
}
}
}
} else if (metadata.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as {
patch?: string
before?: string
after?: string
additions?: number
deletions?: number
}
// 上游 v1.4.0+ metadata.filediff 用 patch 格式
if (fd.patch) {
result.diff = fd.patch
} else if (fd.before !== undefined && fd.after !== undefined) {
result.diff = { before: fd.before, after: fd.after }
}
if (fd.additions !== undefined || fd.deletions !== undefined) {
result.diffStats = {
additions: fd.additions || 0,
deletions: fd.deletions || 0,
}
}
}
// 提取 diagnostics
if (metadata.diagnostics && typeof metadata.diagnostics === 'object') {
const diagMap = metadata.diagnostics as Record<string, MetadataDiagnosticEntry[]>
const diagnostics: DiagnosticInfo[] = []
for (const [file, items] of Object.entries(diagMap)) {
if (!Array.isArray(items)) continue
for (const item of items) {
if (!item || typeof item !== 'object') continue
// severity: 1=error, 2=warning, 3=info, 4=hint
const severityMap: Record<number, DiagnosticInfo['severity']> = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint',
}
diagnostics.push({
file: file.split(/[/\\]/).pop() || file,
severity: typeof item.severity === 'number' ? (severityMap[item.severity] ?? 'info') : 'info',
message: item.message || '',
line: item.range?.start?.line ?? 0,
column: item.range?.start?.character ?? 0,
})
}
}
// 只保留 error 和 warning
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
if (filtered.length > 0) {
result.diagnostics = filtered
}
}
}
// Output language from filePath
if (result.filePath) {
result.outputLang = detectLanguage(result.filePath)
}
// Output
const stateOutput = state && 'output' in state ? state.output : undefined
const interruptedOutput = metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
const output = stateOutput ?? interruptedOutput
if (!result.files && !result.diff && output) {
result.output = typeof output === 'string' ? output : JSON.stringify(output, null, 2)
// 推断语言
if (!result.outputLang && result.output) {
const trimmed = result.output.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
result.outputLang = 'json'
}
}
}
Object.assign(result, extractMetadataFields(metadata))
Object.assign(result, extractOutputFields(result, state, metadata))
return result
}
@@ -200,6 +245,14 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
// Tool-Specific Data Extractors
// ============================================
function resolveCwd(
inputObj: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined
}
function bashExtractData(part: ToolPart): ExtractedToolData {
const base = defaultExtractData(part)
const inputObj = part.state?.input as Record<string, unknown> | undefined
@@ -210,10 +263,8 @@ function bashExtractData(part: ToolPart): ExtractedToolData {
base.inputLang = 'bash'
}
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
if (typeof cwd === 'string' && cwd.trim()) {
base.cwd = cwd.trim()
}
const cwd = resolveCwd(inputObj, metadata)
if (cwd) base.cwd = cwd
return base
}
-21
View File
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react'
import { flushSync } from 'react-dom'
export function usePanelResizeStatus(): boolean {
const [isResizing, setIsResizing] = useState(false)
useEffect(() => {
const onStart = () => flushSync(() => setIsResizing(true))
const onEnd = () => setIsResizing(false)
window.addEventListener('panel-resize-start', onStart)
window.addEventListener('panel-resize-end', onEnd)
return () => {
window.removeEventListener('panel-resize-start', onStart)
window.removeEventListener('panel-resize-end', onEnd)
}
}, [])
return isResizing
}
+29
View File
@@ -9,6 +9,35 @@ vi.mock('../store', () => ({
}))
describe('useSessionStats', () => {
it('does not scan message text when actual usage is available', () => {
const readText = vi.fn(() => 'large historical message')
useMessageStoreMock.mockReturnValue({
messages: [
{
info: {
role: 'assistant',
cost: 0,
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [
{
type: 'text',
get text() {
return readText()
},
},
],
},
],
})
const { result } = renderHook(() => useSessionStats(100))
expect(result.current.contextUsed).toBe(15)
expect(result.current.contextEstimated).toBe(false)
expect(readText).not.toHaveBeenCalled()
})
it('switches to estimated context after a compaction turn', () => {
useMessageStoreMock.mockReturnValue({
messages: [
+2 -3
View File
@@ -235,13 +235,12 @@ export function useSessionStats(
}
}
const totalOrEstimatedContext = estimateCurrentContext(messages)
const contextEstimated = shouldUseEstimatedContext(messages, lastAssistantWithTokensIndex, lastAssistantWithTokens)
const rawContextUsed = contextEstimated
? totalOrEstimatedContext
? estimateCurrentContext(messages)
: lastAssistantWithTokens
? tokenTotal(lastAssistantWithTokens.tokens)
: totalOrEstimatedContext
: estimateCurrentContext(messages)
const contextUsed = Number.isFinite(rawContextUsed) ? rawContextUsed : 0
const rawContextPercent = contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0
const contextPercent = Number.isFinite(rawContextPercent) ? Math.min(100, Math.max(0, rawContextPercent)) : 0
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { sanitizePersistedTerminalLayoutMap } from './layoutStore'
const EMPTY = { version: 1, directories: {} }
function entry(overrides: Record<string, unknown> = {}) {
return {
order: { bottom: ['a'], right: ['b'] },
activeTabId: { bottom: 'a', right: 'b' },
...overrides,
}
}
function sanitize(directories: Record<string, unknown>) {
return sanitizePersistedTerminalLayoutMap({ version: 1, directories })
}
describe('sanitizePersistedTerminalLayoutMap', () => {
it.each([null, undefined, 'x', 3, { version: 2, directories: {} }, { version: 1 }, { version: 1, directories: 'x' }])(
'falls back to an empty map for invalid root %j',
raw => {
expect(sanitizePersistedTerminalLayoutMap(raw)).toEqual(EMPTY)
},
)
it('keeps a well-formed directory entry', () => {
expect(sanitize({ '/proj': entry() })).toEqual({
version: 1,
directories: {
'/proj': { order: { bottom: ['a'], right: ['b'] }, activeTabId: { bottom: 'a', right: 'b' }, sessions: {} },
},
})
})
it('skips entries with an empty key, a non-object value, or missing order / activeTabId', () => {
const result = sanitize({
'': entry(),
'/null': null,
'/string': 'x',
'/no-order': { activeTabId: { bottom: null, right: null } },
'/order-not-object': entry({ order: 'x' }),
'/no-active': { order: { bottom: [], right: [] } },
'/active-not-object': entry({ activeTabId: 'x' }),
'/ok': entry(),
})
expect(Object.keys(result.directories)).toEqual(['/ok'])
})
it('filters the order lists down to non-empty strings and tolerates non-arrays', () => {
const result = sanitize({
'/a': entry({ order: { bottom: ['x', '', 5, null, 'y'], right: 'nope' } }),
})
expect(result.directories['/a'].order).toEqual({ bottom: ['x', 'y'], right: [] })
})
it('nulls non-string active tab ids independently', () => {
const result = sanitize({
'/a': entry({ activeTabId: { bottom: 'a', right: 7 } }),
'/b': entry({ activeTabId: { bottom: 7, right: 'b' } }),
})
expect(result.directories['/a'].activeTabId).toEqual({ bottom: 'a', right: null })
expect(result.directories['/b'].activeTabId).toEqual({ bottom: null, right: 'b' })
})
it('keeps typed session fields, drops mistyped ones and skips invalid sessions', () => {
const result = sanitize({
'/a': entry({
sessions: {
t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 },
t2: { title: 5, shellTitle: 5, customTitle: 5, buffer: 5, scrollY: '1', cursor: '2', rows: '3', cols: '4' },
'': { title: 'ignored' },
t3: null,
t4: 'x',
},
}),
})
expect(result.directories['/a'].sessions).toEqual({
t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 },
t2: {
title: undefined,
shellTitle: undefined,
customTitle: undefined,
buffer: undefined,
scrollY: undefined,
cursor: undefined,
rows: undefined,
cols: undefined,
},
})
})
it('uses an empty sessions map when sessions is not an object', () => {
expect(sanitize({ '/a': entry({ sessions: 'x' }) }).directories['/a'].sessions).toEqual({})
expect(sanitize({ '/a': entry() }).directories['/a'].sessions).toEqual({})
})
})
+61 -49
View File
@@ -230,61 +230,73 @@ function sanitizePersistedPanelLayout(raw: unknown): PersistedPanelLayout | null
}
}
function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
if (!raw || typeof raw !== 'object') {
return { version: 1, directories: {} }
}
function sanitizeIdList(raw: unknown): string[] {
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
}
const data = raw as Partial<PersistedTerminalLayoutMap>
if (data.version !== 1 || !data.directories || typeof data.directories !== 'object') {
function sanitizeActiveTabId(raw: { bottom?: unknown; right?: unknown }): Record<PanelPosition, string | null> {
return {
bottom: typeof raw.bottom === 'string' ? raw.bottom : null,
right: typeof raw.right === 'string' ? raw.right : null,
}
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function optionalNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
function sanitizeTerminalSessionState(raw: Partial<PersistedTerminalSessionState>): PersistedTerminalSessionState {
return {
title: optionalString(raw.title),
shellTitle: optionalString(raw.shellTitle),
customTitle: optionalString(raw.customTitle),
buffer: optionalString(raw.buffer),
scrollY: optionalNumber(raw.scrollY),
cursor: optionalNumber(raw.cursor),
rows: optionalNumber(raw.rows),
cols: optionalNumber(raw.cols),
}
}
function sanitizeTerminalSessions(raw: unknown): Record<string, PersistedTerminalSessionState> {
const sessions: Record<string, PersistedTerminalSessionState> = {}
if (!raw || typeof raw !== 'object') return sessions
for (const [id, session] of Object.entries(raw)) {
if (!id || !session || typeof session !== 'object') continue
sessions[id] = sanitizeTerminalSessionState(session as Partial<PersistedTerminalSessionState>)
}
return sessions
}
function sanitizeTerminalDirectoryLayout(raw: unknown): PersistedTerminalDirectoryLayout | null {
if (!raw || typeof raw !== 'object') return null
const entry = raw as Partial<PersistedTerminalDirectoryLayout>
const { order, activeTabId } = entry
if (!order || typeof order !== 'object' || !activeTabId || typeof activeTabId !== 'object') return null
return {
order: { bottom: sanitizeIdList(order.bottom), right: sanitizeIdList(order.right) },
activeTabId: sanitizeActiveTabId(activeTabId),
sessions: sanitizeTerminalSessions(entry.sessions),
}
}
export function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
const data = raw && typeof raw === 'object' ? (raw as Partial<PersistedTerminalLayoutMap>) : undefined
if (data?.version !== 1 || !data.directories || typeof data.directories !== 'object') {
return { version: 1, directories: {} }
}
const directories: Record<string, PersistedTerminalDirectoryLayout> = {}
for (const [directory, value] of Object.entries(data.directories)) {
if (!directory || !value || typeof value !== 'object') continue
const entry = value as Partial<PersistedTerminalDirectoryLayout>
const rawOrder = entry.order
const rawActiveTabId = entry.activeTabId
if (!rawOrder || typeof rawOrder !== 'object' || !rawActiveTabId || typeof rawActiveTabId !== 'object') continue
const order = {
bottom: Array.isArray(rawOrder.bottom)
? rawOrder.bottom.filter((id): id is string => typeof id === 'string' && id.length > 0)
: [],
right: Array.isArray(rawOrder.right)
? rawOrder.right.filter((id): id is string => typeof id === 'string' && id.length > 0)
: [],
}
const sessions: Record<string, PersistedTerminalSessionState> = {}
const rawSessions = entry.sessions
if (rawSessions && typeof rawSessions === 'object') {
for (const [id, session] of Object.entries(rawSessions)) {
if (!id || !session || typeof session !== 'object') continue
const data = session as Partial<PersistedTerminalSessionState>
sessions[id] = {
title: typeof data.title === 'string' ? data.title : undefined,
shellTitle: typeof data.shellTitle === 'string' ? data.shellTitle : undefined,
customTitle: typeof data.customTitle === 'string' ? data.customTitle : undefined,
buffer: typeof data.buffer === 'string' ? data.buffer : undefined,
scrollY: typeof data.scrollY === 'number' ? data.scrollY : undefined,
cursor: typeof data.cursor === 'number' ? data.cursor : undefined,
rows: typeof data.rows === 'number' ? data.rows : undefined,
cols: typeof data.cols === 'number' ? data.cols : undefined,
}
}
}
directories[directory] = {
order,
activeTabId: {
bottom: typeof rawActiveTabId.bottom === 'string' ? rawActiveTabId.bottom : null,
right: typeof rawActiveTabId.right === 'string' ? rawActiveTabId.right : null,
},
sessions,
}
const layout = directory ? sanitizeTerminalDirectoryLayout(value) : null
if (layout) directories[directory] = layout
}
return { version: 1, directories }
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { computeDiffPairStats, computeDiffStats } from './diffStats'
describe('computeDiffPairStats', () => {
it('counts added and removed lines', () => {
expect(computeDiffPairStats('a\nb\n', 'a\nc\nd\n')).toEqual({ additions: 2, deletions: 1 })
})
it('returns zeros for identical text', () => {
expect(computeDiffPairStats('a\n', 'a\n')).toEqual({ additions: 0, deletions: 0 })
})
})
describe('computeDiffStats', () => {
it('returns undefined without files or a before/after pair', () => {
expect(computeDiffStats({})).toBeUndefined()
expect(computeDiffStats({ files: [] })).toBeUndefined()
expect(computeDiffStats({ diff: '@@ -1 +1 @@' })).toBeUndefined()
})
it('sums the counters reported per file', () => {
expect(
computeDiffStats({
files: [{ additions: 2, deletions: 1 }, { additions: 1 }, { deletions: 3 }],
}),
).toEqual({ additions: 3, deletions: 4 })
})
it('computes missing counters from before/after, only when additions is absent', () => {
expect(
computeDiffStats({
files: [
{ before: 'a\n', after: 'a\nb\n' },
{ before: 'x\n', after: 'y\n', additions: 5 },
{ before: 'only-before\n' },
],
}),
).toEqual({ additions: 6, deletions: 0 })
})
it('returns undefined when files contribute no changes', () => {
expect(computeDiffStats({ files: [{ additions: 0, deletions: 0 }] })).toBeUndefined()
})
it('uses a single before/after diff and ignores it when nothing changed', () => {
expect(computeDiffStats({ diff: { before: 'a\n', after: 'b\n' } })).toEqual({ additions: 1, deletions: 1 })
expect(computeDiffStats({ diff: { before: 'a\n', after: 'a\n' } })).toBeUndefined()
})
it('prefers files over the single diff', () => {
expect(computeDiffStats({ files: [{ additions: 1 }], diff: { before: 'a\n', after: 'b\nc\n' } })).toEqual({
additions: 1,
deletions: 0,
})
})
})
+50
View File
@@ -0,0 +1,50 @@
import { diffLines } from 'diff'
export interface DiffStats {
additions: number
deletions: number
}
interface DiffStatsInput {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}
/** Line additions/deletions between two texts. */
export function computeDiffPairStats(before: string, after: string): DiffStats {
let additions = 0
let deletions = 0
for (const change of diffLines(before, after)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
return { additions, deletions }
}
/**
* Diff stats from extracted tool data, for when the metadata did not provide them.
* Returns undefined when there is nothing to count (no files, no before/after pair, or no changes).
*/
export function computeDiffStats(data: DiffStatsInput): DiffStats | undefined {
if (data.files?.length) {
let additions = 0
let deletions = 0
for (const file of data.files) {
if (file.additions !== undefined) additions += file.additions
if (file.deletions !== undefined) deletions += file.deletions
if (file.additions === undefined && file.before !== undefined && file.after !== undefined) {
const stats = computeDiffPairStats(file.before, file.after)
additions += stats.additions
deletions += stats.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}
if (data.diff && typeof data.diff === 'object') {
const stats = computeDiffPairStats(data.diff.before, data.diff.after)
return stats.additions || stats.deletions ? stats : undefined
}
return undefined
}
+26
View File
@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { saveData } from './downloadUtils'
vi.mock('./tauri', () => ({ isTauri: () => false }))
describe('browser file downloads', () => {
beforeEach(() => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() })
})
it('downloads exactly the requested byte view without surrounding buffer contents', async () => {
const bytes = new Uint8Array([11, 22, 33, 44, 55])
saveData(bytes.subarray(1, 4), 'slice.bin')
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob
const contents = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = reject
reader.readAsArrayBuffer(blob)
})
expect([...new Uint8Array(contents)]).toEqual([22, 33, 44])
expect(blob.type).toBe('application/octet-stream')
})
})
+3 -4
View File
@@ -73,9 +73,7 @@ export function downloadFileContent(content: FileContent, fileName: string): voi
const encoding = content.encoding || ''
const textContent = content.content || ''
// 统一转为 Uint8Array
const data = isBinaryContent(encoding)
? base64ToBytes(textContent)
: new TextEncoder().encode(textContent)
const data = isBinaryContent(encoding) ? base64ToBytes(textContent) : new TextEncoder().encode(textContent)
const mimeType = isBinaryContent(encoding)
? content.mimeType || 'application/octet-stream'
@@ -95,7 +93,8 @@ export function saveData(data: Uint8Array, fileName: string, mimeType = 'applica
console.warn('[downloadUtils] Tauri save failed:', err)
})
} else {
const blob = new Blob([data.buffer as ArrayBuffer], { type: mimeType })
// A view may cover only part of its backing buffer (for example, a file chunk).
const blob = new Blob([new Uint8Array(data)], { type: mimeType })
triggerBrowserDownload(blob, fileName)
}
}