Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1314c5ab3e | ||
|
|
ceca5bf301 | ||
|
|
e8927ebf78 | ||
|
|
3dd3fa5d05 | ||
|
|
adf63ef9f7 | ||
|
|
2d2cdf3b19 |
No files matched your search
@@ -7,3 +7,8 @@ tmp/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.pi-lens/
|
.pi-lens/
|
||||||
|
|
||||||
|
# Local env and test output
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
coverage/
|
||||||
+173
-95
@@ -1,136 +1,214 @@
|
|||||||
<div align="center">
|
<p align="center">
|
||||||
|
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="Logo de PiAgentUI" width="760" />
|
||||||
|
</p>
|
||||||
|
|
||||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: workspace de escritorio y web para sesiones reales de Pi Agent, con chat, tarjetas de tool calls y el panel Files, Changes, MCP y Skills">
|
# PiAgentUI
|
||||||
|
|
||||||

|
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | **Español** | [日本語](./README.ja.md)
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [Specs de diseño](docs/superpowers/specs/)
|
> 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
|
||||||
|
|
||||||
[English](README.md) · [Português (BR)](README.pt-BR.md) · **Español** · [日本語](README.ja.md)
|
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.
|
||||||
|
|
||||||
</div>
|
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.
|
||||||
|
|
||||||

|
> **Estado del proyecto:** desarrollo local activo. Algunas áreas ya son cercanas a producto, mientras otras siguen cambiando rápidamente.
|
||||||
|
|
||||||
## Qué es
|
## Demostración del software
|
||||||
|
|
||||||
PiAgentUI es una interfaz de chat y un workspace para el [agente de código Pi](https://www.npmjs.com/package/@mariozechner/pi-coding-agent). Funciona como app web servida por una extensión de Pi o como app de escritorio con Tauri 2, y muestra en una ventana lo que una terminal sola esconde: la lista de sesiones, las tool calls, los servidores MCP, las skills y el uso de contexto.
|
Estas capturas muestran la experiencia actual de PiAgentUI: un workspace oscuro y enfocado para agentes, con sesiones reales de Pi, selección de modelos, metadatos MCP, settings, chat con herramientas y paneles laterales.
|
||||||
|
|
||||||
- Transmite sesiones reales de Pi por SSE (`/global/event`) y renderiza Markdown, código (Shiki) y matemáticas (KaTeX).
|

|
||||||
- Muestra las tool calls como tarjetas y el uso de contexto de la sesión activa.
|
|
||||||
- Cambia de modelo con las capacidades que informa el runtime.
|
|
||||||
- Lista los servidores MCP configurados (transporte, comando, URL, origen) y las skills efectivas agrupadas por origen.
|
|
||||||
- Expone slash commands integrados y dinámicos, además de controles de Git en el composer.
|
|
||||||
- Integra una terminal (xterm.js) y un explorador de archivos junto al chat.
|
|
||||||
- Acepta entrada de voz mediante un endpoint STT compatible con OpenAI configurado en settings.
|
|
||||||
- Idiomas de la interfaz: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar` (`src/locales/`).
|
|
||||||
|
|
||||||
## Por qué usarlo
|

|
||||||
|
|
||||||
PiAgentUI no guarda estado propio del agente. Sesiones, modelos, tools, skills y comandos vienen del runtime de Pi a través de la extensión, así que lo que ves es lo que Pi está ejecutando.
|

|
||||||
|
|
||||||
No lo uses si no ejecutas Pi: sin la extensión, la interfaz solo muestra el diálogo de conexión al servidor. También atiende un runtime por ventana y no tiene demo alojada ni paquete publicado en npm.
|
## Qué hace PiAgentUI hoy
|
||||||
|
|
||||||
## Cómo funciona
|
- **Chat con streaming** — salida en vivo por SSE, reconciliación estable de mensajes, markdown, resaltado de código, partes de razonamiento y tarjetas de herramientas.
|
||||||
|
- **Sesiones reales de Pi** — lista de sesiones, enrutamiento a la sesión activa, historial seguro tras reinicio, títulos derivados y envío a la sesión seleccionada.
|
||||||
|
- **Salida de herramientas en tarjetas** — tool calls/results quedan anexados a los mensajes del asistente, sin filtrarse como texto normal en el chat.
|
||||||
|
- **Uso real de contexto** — el uso de contexto viene del runtime de Pi cuando está disponible; el contexto desconocido/compactado se muestra de forma segura en lugar de `NaN`.
|
||||||
|
- **Metadatos de modelos** — la identidad de provider/modelo viene de Pi; el catálogo local de OpenRouter se usa solo como enriquecimiento de detalles.
|
||||||
|
- **Panel de Skills** — las skills reales se leen del prompt efectivo de Pi y se agrupan por origen: global, project, package y other.
|
||||||
|
- **Panel MCP** — lee servidores MCP configurados en archivos locales/globales y muestra transporte, comando, URL, lifecycle, direct tools y origen.
|
||||||
|
- **Slash commands de Pi** — `/api/commands/list` expone comandos built-in de Pi y comandos dinámicos de extensiones, prompts y skills.
|
||||||
|
- **Adjuntos multimodales** — soporte visual para archivos/imágenes/PDF/audio/video según las capacidades del modelo seleccionado.
|
||||||
|
- **Terminal y archivos** — terminal integrado, explorador de archivos, syntax highlighting, componentes de diff e integración de escritorio con Tauri.
|
||||||
|
- **UI keyboard-first** — command palette, atajos configurables, split panes, selector de modelo, navegación por proyecto/sesión y layouts responsivos.
|
||||||
|
|
||||||
- `extensions/piagentui-server.ts` es la extensión de Pi. Sirve la UI compilada desde `dist/` con `http` + `ws` de Node e inyecta `window.PI_BOOT_DATA` (URL base y token).
|
## Dirección del producto
|
||||||
- Al arrancar escucha en el puerto `58785` (o el siguiente libre) y escribe el puerto y un token aleatorio en `~/.pi/agent/piagentui-port.json`.
|
|
||||||
- `GET /global/health` es abierto; las demás rutas `/api/*` exigen `Authorization: Bearer <token>`. El socket escucha en `0.0.0.0`, pero cada petición se valida contra loopback salvo que el acceso por LAN en `/api/network/access` esté activado (viene desactivado).
|
|
||||||
- La app Tauri lee el mismo archivo de descubrimiento con el comando `get_pi_agent_connection`, así que el build de escritorio se conecta al runtime local sin configuración manual.
|
|
||||||
- En contenedores, el router en Rust (`src-router/`, axum) va detrás de Caddy como gateway delante de los servicios de frontend y backend.
|
|
||||||
|
|
||||||
## Inicio rápido
|
PiAgentUI está evolucionando hacia una aplicación completa de agentes:
|
||||||
|
|
||||||
| Herramienta | Versión | Necesaria para |
|
1. **Acciones visuales para todos los comandos de Pi** — los slash commands siguen disponibles, pero las acciones frecuentes deben convertirse en botones, menús, diálogos o paneles.
|
||||||
|---|---|---|
|
2. **Entrada por voz y transcripción en tiempo real** — grabación de micrófono, deltas parciales, inserción de la transcripción final en el composer y configuración de proveedores STT OpenAI-compatible.
|
||||||
| Node.js | `>=22` (`.tool-versions`: 22.22.0) | todo |
|
3. **Integración MCP más profunda** — más allá de metadatos: conectar, autenticar, inspeccionar herramientas y ejecutar workflows MCP desde la UI.
|
||||||
| npm | la que trae Node | todo (`package-lock.json`) |
|
4. **Control completo de sesiones** — fork, clone, navegación en árbol, import/export/share, branch summaries y gestión de contexto como flujos nativos.
|
||||||
| Rust | `1.85.0` (`.tool-versions`) | app de escritorio y router del gateway |
|
5. **Experiencia de escritorio completa** — app Tauri pulida con descubrimiento local de runtime, settings seguros, notificaciones y controles ricos de workspace.
|
||||||
| Docker Compose | reciente | solo el stack de contenedores |
|
|
||||||
| Pi Agent | instalado localmente | sesiones y modelos reales |
|
|
||||||
|
|
||||||
```bash
|
## Roadmap de audio y transcripción
|
||||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
|
||||||
cd PiAgentUI
|
PiAgentUI ya acepta adjuntos de audio cuando el modelo seleccionado declara soporte de entrada de audio. El sistema de voz planeado añade speech-to-text en vivo directamente en el composer.
|
||||||
npm ci # el postinstall ejecuta scripts/copy-material-icons.mjs
|
|
||||||
npm run build # tsc -b && vite build -> dist/
|
Arquitectura recomendada:
|
||||||
npm run dev # servidor Vite en http://localhost:5173
|
|
||||||
|
- **Proveedor realtime por defecto:** OpenAI Realtime Transcription con `gpt-realtime-whisper` para transcripción parcial de baja latencia.
|
||||||
|
- **Fallback por archivo:** OpenAI Audio Transcriptions con `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` o `whisper-1`.
|
||||||
|
- **Proveedores personalizados:** APIs STT OpenAI-compatible configurables por el usuario, como proveedores que implementan `POST /v1/audio/transcriptions`.
|
||||||
|
|
||||||
|
UX planeada:
|
||||||
|
|
||||||
|
- botón de micrófono en el composer;
|
||||||
|
- estados de grabación y permisos;
|
||||||
|
- overlay de transcripción parcial;
|
||||||
|
- texto final insertado en el input;
|
||||||
|
- comportamiento configurable de append/replace;
|
||||||
|
- auto-send desactivado por defecto;
|
||||||
|
- manejo recuperable de errores de permisos, red y proveedor.
|
||||||
|
|
||||||
|
Ejemplo de configuración de proveedor:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "openai-compatible",
|
||||||
|
"mode": "file",
|
||||||
|
"baseUrl": "https://api.example.com/v1",
|
||||||
|
"transcriptionEndpoint": "/audio/transcriptions",
|
||||||
|
"transcriptionModel": "openai/whisper-large-v3",
|
||||||
|
"language": "es"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run dev` es solo el front end: hace proxy de `/api` a `127.0.0.1:4096` sin el prefijo. Para el flujo con Pi, compila primero y deja que la extensión sirva `dist/` (Pi carga este repositorio como paquete mediante el campo `pi.extensions` de `package.json`):
|
El soporte realtime se trata como una capability separada, porque la compatibilidad HTTP con OpenAI no garantiza compatibilidad WebSocket.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```text
|
||||||
|
PiAgentUI
|
||||||
|
├─ Frontend React/Vite
|
||||||
|
│ ├─ chat, renderizado de mensajes, input, paneles y settings
|
||||||
|
│ ├─ stores locales para preferencias de UI
|
||||||
|
│ └─ clientes SSE/API para endpoints locales de PiAgentUI
|
||||||
|
├─ Backend como extensión Pi
|
||||||
|
│ └─ extensions/piagentui-server.ts
|
||||||
|
│ ├─ expone endpoints locales /api/*
|
||||||
|
│ ├─ conecta con runtime/sesiones/modelos de Pi
|
||||||
|
│ ├─ transmite eventos de Pi al navegador
|
||||||
|
│ └─ lee metadatos locales como MCP config y skills
|
||||||
|
└─ Runtime Pi Agent
|
||||||
|
├─ sesiones
|
||||||
|
├─ modelos/providers
|
||||||
|
├─ herramientas
|
||||||
|
├─ skills
|
||||||
|
├─ MCP
|
||||||
|
└─ slash commands
|
||||||
|
```
|
||||||
|
|
||||||
|
La UI no debe inventar estado de runtime cuando Pi ya conoce la respuesta. PiAgentUI puede cachear y enriquecer datos para UX, pero Pi sigue siendo autoritativo para el comportamiento del agente.
|
||||||
|
|
||||||
|
## Stack técnico
|
||||||
|
|
||||||
|
| Área | Stack |
|
||||||
|
| ------------------- | --------------------------------------------- |
|
||||||
|
| UI | React 19, TypeScript |
|
||||||
|
| Build | Vite 8 |
|
||||||
|
| Estilo | Tailwind CSS v4 y design tokens del proyecto |
|
||||||
|
| Escritorio | Tauri 2 |
|
||||||
|
| Markdown | Streamdown / pipeline de markdown |
|
||||||
|
| Syntax highlighting | Shiki |
|
||||||
|
| Terminal | xterm.js |
|
||||||
|
| Tests | Vitest, Testing Library |
|
||||||
|
| Backend local | Extensión Pi, Node HTTP server, WebSocket/SSE |
|
||||||
|
|
||||||
|
## Desarrollo local
|
||||||
|
|
||||||
|
Instalar dependencias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Iniciar el frontend en modo dev:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
|
||||||
# abre http://127.0.0.1:58785
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Escritorio y Android:
|
Tests:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run tauri dev # ventana de escritorio sobre el servidor Vite
|
npm run test:run
|
||||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
|
||||||
npm run tauri android dev # ver scripts/dev-android.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Antes de un PR, `npm run validate` ejecuta typecheck, typecheck de la extensión, lint, build y tests; es el gate que corre el CI en `build.yml`.
|
Type checks:
|
||||||
|
|
||||||
## Configuración
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run typecheck:extensions
|
||||||
|
```
|
||||||
|
|
||||||
El stack de contenedores viene heredado del despliegue de OpenCodeUI. Copia `.env.example` a `.env` primero.
|
Validación completa:
|
||||||
|
|
||||||
| Archivo | Comando | Qué hace |
|
```bash
|
||||||
|---|---|---|
|
npm run validate
|
||||||
| `docker-compose.yml` | `docker compose up -d` | Stack completo: `gateway` (Caddy + router en Rust, `127.0.0.1:6658` y `6659`), `frontend`, `backend` (`opencode serve`, 4096, límite de 4G de memoria). |
|
```
|
||||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | Solo el front end, para un `opencode serve` existente. Usa `BACKEND_URL` (por defecto `host.docker.internal:4096`) y `PORT` (por defecto `3000`). |
|
|
||||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | Override opcional: el `backend` corre privilegiado, con el socket de Docker y el `/` del host montado en `/host`. |
|
|
||||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | Override opcional: compila gateway, frontend y backend localmente desde `docker/`. |
|
|
||||||
|
|
||||||
<details>
|
## Runtime de la extensión Pi
|
||||||
<summary>Variables de entorno (solo para el stack de contenedores)</summary>
|
|
||||||
|
|
||||||
| Variable | Obligatoria | Descripción |
|
PiAgentUI se registra como extensión Pi en `package.json`:
|
||||||
|---|---|---|
|
|
||||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | Al menos una | Claves de proveedores de LLM que se pasan al contenedor del backend. |
|
|
||||||
| `GATEWAY_PORT` | No (`6658`) | Puerto del host para el punto de entrada unificado. |
|
|
||||||
| `PREVIEW_PORT` | No (`6659`) | Puerto del host para el punto de entrada de previews. |
|
|
||||||
| `PREVIEW_DOMAIN` | No | Dominio aparte para previews; el proxy inverso lo reenvía a `PREVIEW_PORT`. |
|
|
||||||
| `WORKSPACE` | No (`./workspace`) | Carpeta del host montada en `/workspace` en el backend. |
|
|
||||||
| `OPENCODE_SERVER_PASSWORD` | En despliegues públicos | Contraseña del backend y del router. |
|
|
||||||
| `OPENCODE_SERVER_USERNAME` | No (`opencode`) | Usuario que acompaña a la contraseña anterior. |
|
|
||||||
| `PUBLIC_BASE_URL` | No | URL base pública que usa el router integrado en el gateway. |
|
|
||||||
| `ROUTER_SCAN_INTERVAL` | No (`5`) | Intervalo de escaneo del router. |
|
|
||||||
| `ROUTER_TOKEN_LENGTH` | No (`12`) | Longitud de los tokens de ruta generados. |
|
|
||||||
| `ROUTER_PORT_RANGE` | No (`3000-9999`) | Rango de puertos que atiende el router. |
|
|
||||||
| `ROUTER_EXCLUDE_PORTS` | No (`4096`) | Puertos que el router omite. |
|
|
||||||
| `DOCKER_API_VERSION` | No (`1.40`) | Versión de la API de Docker para el backend; se define en `docker-compose.yml`, no en `.env.example`. |
|
|
||||||
| `USE_CHINA_MIRROR` | No (`1`) | Opción de build de la imagen del backend: usa los mirrors de abajo. |
|
|
||||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | No | Mirrors para el toolchain del backend. |
|
|
||||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | No | URLs de instalación usadas al compilar la imagen del backend. |
|
|
||||||
|
|
||||||
</details>
|
```json
|
||||||
|
{
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./extensions/piagentui-server.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Estado del proyecto
|
Cuando la extensión arranca, escribe metadatos de descubrimiento local en:
|
||||||
|
|
||||||
Beta, antes de la 1.0 (`0.6.10`), mantenido por un desarrollador. Las releases salen de `release.yml` en tags `v*` (artefactos para Windows, macOS, Linux y Android); `deploy.yml` publica el build web con `VITE_BASE_PATH=/PiAgentUI/`.
|
```text
|
||||||
|
~/.pi/agent/piagentui-port.json
|
||||||
|
```
|
||||||
|
|
||||||
- El nombre OpenCodeUI no ha desaparecido del todo: el crate del router en Rust es `opencodeui-router`, las imágenes son `opencodeui-*`, los APK de Android salen como `OpenCodeUI-*`, los comentarios de `.env.example` están en chino y el backend Docker ejecuta `opencode serve`, que pertenece al despliegue heredado, no al flujo con la extensión de Pi.
|
La app web usa el puerto/token de ese archivo para hablar con el servidor local de la extensión. Algunos cambios de backend requieren reiniciar o recargar el proceso de la extensión Pi antes de verse en el navegador.
|
||||||
- `docker-compose.yml` y `docker-compose.standalone.yml` todavía descargan la imagen upstream `ghcr.io/lehhair/opencodeui-frontend`.
|
|
||||||
- Los tests son de componente y de servidor (Vitest). No hay tests del shell Tauri ni del router en Rust más allá de la compilación, ni test end-to-end en navegador, ni umbral de cobertura.
|
|
||||||
- La entrada de voz necesita un endpoint STT compatible con OpenAI y no tiene test automatizado en navegador.
|
|
||||||
|
|
||||||
Issues y pull requests van al [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues); ejecuta `npm run validate` antes de abrir un PR.
|
## Endpoints locales importantes
|
||||||
|
|
||||||
|
| Endpoint | Propósito |
|
||||||
|
| -------------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `GET /api/models` | Lista real de modelos Pi con capacidades normalizadas |
|
||||||
|
| `GET /api/sessions` | Lista de sesiones leída desde los archivos de sesión de Pi |
|
||||||
|
| `GET /api/sessions/:id/messages` | Historial normalizado para renderizado en la UI |
|
||||||
|
| `GET /api/sessions/:id/context` | Uso de contexto runtime para la sesión activa |
|
||||||
|
| `POST /api/messages/send` | Envía un mensaje a la sesión Pi solicitada |
|
||||||
|
| `GET /api/skills` | Skills efectivas de Pi agrupadas por origen |
|
||||||
|
| `GET /api/mcp/status` | Metadatos de servidores MCP configurados |
|
||||||
|
| `GET /api/commands/list` | Slash commands built-in y dinámicos de Pi |
|
||||||
|
| `GET /global/event` | Stream SSE de eventos del backend PiAgentUI |
|
||||||
|
|
||||||
|
## Principios de desarrollo
|
||||||
|
|
||||||
|
- Preferir datos reales del runtime Pi en vez de estado falso solo de UI.
|
||||||
|
- Mantener OpenRouter como enriquecimiento de catálogo, nunca como sustituto de la identidad provider/modelo de Pi.
|
||||||
|
- Tratar valores runtime desconocidos explícitamente.
|
||||||
|
- Escribir tests antes de cambios de comportamiento siempre que sea posible.
|
||||||
|
- Mantener tool results dentro de tarjetas de herramienta.
|
||||||
|
- Seguir el sistema visual existente: tema, movimiento, espaciado y patrones de paneles.
|
||||||
|
- Separar claramente lo implementado del roadmap.
|
||||||
|
|
||||||
|
## Notas del repositorio
|
||||||
|
|
||||||
|
El proyecto todavía contiene nombres heredados de la base original OpenCodeUI en algunos puntos, incluyendo metadatos del paquete y documentación antigua. La dirección actual es PiAgentUI-first, y una limpieza futura debe migrar esos nombres sin romper los workflows locales.
|
||||||
|
|
||||||
## Licencia
|
## Licencia
|
||||||
|
|
||||||
GPL-3.0-only, ver [`LICENSE`](LICENSE). PiAgentUI es un fork reelaborado de OpenCodeUI; el autor original figura en `src-tauri/Cargo.toml`.
|
Este repositorio sigue la licencia declarada en `package.json`: `GPL-3.0-only`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<sub>Hecho por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
|
||||||
</div>
|
|
||||||
+173
-95
@@ -1,136 +1,214 @@
|
|||||||
<div align="center">
|
<p align="center">
|
||||||
|
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="PiAgentUI ロゴ" width="760" />
|
||||||
|
</p>
|
||||||
|
|
||||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: 実際の Pi Agent セッションを扱うデスクトップ/Web ワークスペース。チャット、tool call カード、Files・Changes・MCP・Skills パネル">
|
# PiAgentUI
|
||||||
|
|
||||||

|
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | **日本語**
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [設計 spec](docs/superpowers/specs/)
|
> この翻訳は基準となる [メイン README](./README.md) より古い可能性があります。リポジトリ: https://github.com/evandrodevbr/PiAgentUI
|
||||||
|
|
||||||
[English](README.md) · [Português (BR)](README.pt-BR.md) · [Español](README.es.md) · **日本語**
|
PiAgentUI は **Pi Agent** のためのモダンな Web / デスクトップ UI です。単なるチャット画面ではありません。最終的には Codex や Claude Desktop のような、エージェント中心の本格的な開発アプリになることを目指しています。
|
||||||
|
|
||||||
</div>
|
PiAgentUI は、実際の Pi runtime を信頼できる唯一の情報源として扱います。セッション、モデルメタデータ、コンテキスト使用量、tool call、skills、MCP 設定、slash command は、可能な限り Pi から取得し、UI 側だけの仮の状態として再実装しません。
|
||||||
|
|
||||||

|
> **プロジェクト状態:** アクティブなローカル開発中です。一部は製品に近い品質ですが、まだ高速に変化している領域もあります。
|
||||||
|
|
||||||
## 概要
|
## ソフトウェアプレビュー
|
||||||
|
|
||||||
PiAgentUI は [Pi coding agent](https://www.npmjs.com/package/@mariozechner/pi-coding-agent) 用のチャット UI 兼 workspace です。Pi 拡張機能が配信する Web アプリとして、または Tauri 2 のデスクトップアプリとして動き、素のターミナルでは見えないもの(セッション一覧、tool call、MCP サーバー、skills、コンテキスト使用量)を 1 つのウィンドウにまとめます。
|
以下のスクリーンショットは、現在の PiAgentUI の体験を示しています。実際の Pi セッション、モデル選択、MCP メタデータ、settings、ツール対応チャット、サイドパネルを備えた、暗色で集中しやすいエージェント workspace です。
|
||||||
|
|
||||||
- 実際の Pi セッションを SSE(`/global/event`)でストリーミングし、Markdown、コード(Shiki)、数式(KaTeX)を描画します。
|

|
||||||
- tool call をカードで表示し、アクティブなセッションのコンテキスト使用量を表示します。
|
|
||||||
- ランタイムが返す capability を使ってモデルを切り替えます。
|
|
||||||
- 設定済みの MCP サーバー(transport、command、URL、source)と、source ごとにまとめた有効な skills を一覧表示します。
|
|
||||||
- 組み込みと動的な slash command、composer の Git 操作を提供します。
|
|
||||||
- チャットの横にターミナル(xterm.js)とファイルエクスプローラーを組み込んでいます。
|
|
||||||
- settings で指定した OpenAI 互換の STT エンドポイント経由で音声入力を受け付けます。
|
|
||||||
- UI の言語: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar`(`src/locales/`)。日本語の UI はまだありません。
|
|
||||||
|
|
||||||
## 使う理由
|

|
||||||
|
|
||||||
PiAgentUI はエージェントの状態を独自に持ちません。セッション、モデル、tools、skills、コマンドは拡張機能を通じて Pi ランタイムから取得するので、画面に出るものは Pi が実際に動かしているものです。
|

|
||||||
|
|
||||||
Pi を使っていないなら向きません。拡張機能がないと、UI はサーバー接続ダイアログしか表示しません。また 1 ウィンドウにつき 1 ランタイムで、ホストされたデモや npm 公開パッケージもありません。
|
## 現在できること
|
||||||
|
|
||||||
## 仕組み
|
- **ストリーミング対応チャット** — SSE によるライブ出力、安定したメッセージ同期、Markdown、コードハイライト、reasoning part、ツールカード。
|
||||||
|
- **実際の Pi セッション** — セッション一覧、アクティブセッションへのルーティング、再起動後も安全な履歴読み込み、セッションタイトル生成、選択中セッションへの送信。
|
||||||
|
- **ツール出力のカード表示** — tool calls/results は通常のチャット本文として漏れず、アシスタントメッセージに紐づいたカードとして表示されます。
|
||||||
|
- **実 runtime のコンテキスト使用量** — 利用可能な場合は Pi runtime から取得し、不明または compacted な値は `NaN` ではなく安全に表示します。
|
||||||
|
- **モデルメタデータ** — provider/model の実体は Pi が authoritative であり、ローカル OpenRouter カタログは context、output、modality、pricing、tokenizer などの補助情報としてのみ使います。
|
||||||
|
- **Skills パネル** — 有効な Pi prompt から実際の skills を読み取り、global / project / package / other に分類します。
|
||||||
|
- **MCP パネル** — ローカル/グローバル設定ファイルから MCP サーバーを読み取り、transport、command、URL、lifecycle、direct tools、source を表示します。
|
||||||
|
- **Pi slash commands** — `/api/commands/list` が Pi の built-in commands と、extension / prompt / skill 由来の動的コマンドを公開します。
|
||||||
|
- **マルチモーダル添付** — 選択中モデルの能力に応じて、ファイル、画像、PDF、音声、動画の添付 UI を表示します。
|
||||||
|
- **ターミナルとファイル操作** — 統合ターミナル、ファイルエクスプローラー、syntax highlighting、diff 関連コンポーネント、Tauri デスクトップ連携。
|
||||||
|
- **キーボード中心の UI** — command palette、カスタム keybindings、split panes、モデルセレクター、プロジェクト/セッションナビゲーション、レスポンシブレイアウト。
|
||||||
|
|
||||||
- `extensions/piagentui-server.ts` が Pi 拡張機能です。Node の `http` + `ws` で `dist/` のビルド済み UI を配信し、`window.PI_BOOT_DATA`(ベース URL とトークン)を注入します。
|
## プロダクトの方向性
|
||||||
- 起動時にポート `58785`(使用中なら次の空きポート)で待ち受け、ポートとランダムなトークンを `~/.pi/agent/piagentui-port.json` に書き込みます。
|
|
||||||
- `GET /global/health` は認証なしで、ほかの `/api/*` ルートは `Authorization: Bearer <token>` が必要です。ソケットは `0.0.0.0` にバインドしますが、`/api/network/access` の LAN アクセスが有効(デフォルトは無効)でない限り、各リクエストは loopback かどうか検査されます。
|
|
||||||
- Tauri アプリは `get_pi_agent_connection` コマンドで同じ discovery ファイルを読むため、デスクトップ版は手動設定なしでローカルのランタイムに接続します。
|
|
||||||
- コンテナでは、Rust の router(`src-router/`、axum)が Caddy の背後で gateway として frontend と backend の前に立ちます。
|
|
||||||
|
|
||||||
## クイックスタート
|
PiAgentUI は、完全なエージェントアプリへ進化しています。
|
||||||
|
|
||||||
| ツール | バージョン | 用途 |
|
1. **すべての Pi コマンドに視覚的な操作を提供** — slash commands は残しつつ、よく使う操作はボタン、メニュー、ダイアログ、パネルにします。
|
||||||
|---|---|---|
|
2. **音声入力とリアルタイム文字起こし** — マイク録音、部分 transcript delta、最終 transcript の composer 挿入、OpenAI-compatible STT provider 設定。
|
||||||
| Node.js | `>=22`(`.tool-versions`: 22.22.0) | すべて |
|
3. **より深い MCP 統合** — メタデータ表示だけでなく、接続、認証、ツール確認、MCP workflow 実行を UI から行えるようにします。
|
||||||
| npm | Node に同梱のもの | すべて(`package-lock.json`) |
|
4. **完全なセッション制御** — fork、clone、tree navigation、import/export/share、branch summary、context management をネイティブ UI として提供します。
|
||||||
| Rust | `1.85.0`(`.tool-versions`) | デスクトップアプリと gateway の router |
|
5. **デスクトップグレードの体験** — Tauri アプリとして、ローカル runtime discovery、安全な settings、通知、より豊かな workspace control を提供します。
|
||||||
| Docker Compose | 最近のもの | コンテナ構成のみ |
|
|
||||||
| Pi Agent | ローカルにインストール済み | 実際のセッションとモデル |
|
|
||||||
|
|
||||||
```bash
|
## 音声と文字起こしのロードマップ
|
||||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
|
||||||
cd PiAgentUI
|
PiAgentUI は、選択中モデルが audio input をサポートしている場合、すでに音声ファイル添付を扱えます。計画中の voice system では、composer にリアルタイム speech-to-text を追加します。
|
||||||
npm ci # postinstall で scripts/copy-material-icons.mjs が動きます
|
|
||||||
npm run build # tsc -b && vite build -> dist/
|
推奨アーキテクチャ:
|
||||||
npm run dev # Vite 開発サーバー http://localhost:5173
|
|
||||||
|
- **デフォルト realtime provider:** 低レイテンシの部分 transcript 用に OpenAI Realtime Transcription と `gpt-realtime-whisper` を使用。
|
||||||
|
- **ファイル fallback:** OpenAI Audio Transcriptions の `gpt-4o-transcribe`、`gpt-4o-mini-transcribe`、または `whisper-1` を使用。
|
||||||
|
- **カスタム provider:** `POST /v1/audio/transcriptions` を実装する OpenAI-compatible STT API をユーザーが設定可能。
|
||||||
|
|
||||||
|
計画中の UX:
|
||||||
|
|
||||||
|
- composer のマイクボタン;
|
||||||
|
- 録音状態と権限状態;
|
||||||
|
- 部分 transcript overlay;
|
||||||
|
- 最終 transcript を input に挿入;
|
||||||
|
- append / replace 動作の設定;
|
||||||
|
- auto-send はデフォルト無効;
|
||||||
|
- 権限、ネットワーク、provider エラーを復旧可能な形で表示。
|
||||||
|
|
||||||
|
provider 設定例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "openai-compatible",
|
||||||
|
"mode": "file",
|
||||||
|
"baseUrl": "https://api.example.com/v1",
|
||||||
|
"transcriptionEndpoint": "/audio/transcriptions",
|
||||||
|
"transcriptionModel": "openai/whisper-large-v3",
|
||||||
|
"language": "ja"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run dev` はフロントエンドのみで、`/api` をプレフィックスを外して `127.0.0.1:4096` にプロキシします。Pi で使う場合は先にビルドし、拡張機能に `dist/` を配信させます(Pi は `package.json` の `pi.extensions` フィールドでこのリポジトリをパッケージとして読み込みます)。
|
OpenAI-compatible な HTTP transcription は WebSocket 互換性を保証しないため、realtime support は別 capability として扱います。
|
||||||
|
|
||||||
|
## アーキテクチャ
|
||||||
|
|
||||||
|
```text
|
||||||
|
PiAgentUI
|
||||||
|
├─ React/Vite frontend
|
||||||
|
│ ├─ chat, message rendering, input, panels, settings
|
||||||
|
│ ├─ UI preferences 用の local stores
|
||||||
|
│ └─ PiAgentUI local endpoints 用の SSE/API clients
|
||||||
|
├─ Pi extension backend
|
||||||
|
│ └─ extensions/piagentui-server.ts
|
||||||
|
│ ├─ local /api/* endpoints を公開
|
||||||
|
│ ├─ Pi runtime/session/model APIs への bridge
|
||||||
|
│ ├─ Pi events を browser に stream
|
||||||
|
│ └─ MCP config や skills などの local metadata を読み取り
|
||||||
|
└─ Pi Agent runtime
|
||||||
|
├─ sessions
|
||||||
|
├─ models/providers
|
||||||
|
├─ tools
|
||||||
|
├─ skills
|
||||||
|
├─ MCP
|
||||||
|
└─ slash commands
|
||||||
|
```
|
||||||
|
|
||||||
|
Pi がすでに知っている runtime state を UI が勝手に作るべきではありません。PiAgentUI は UX のために cache や enrichment を行えますが、agent behavior については Pi が authoritative です。
|
||||||
|
|
||||||
|
## 技術スタック
|
||||||
|
|
||||||
|
| 領域 | Stack |
|
||||||
|
| ------------------- | --------------------------------------------- |
|
||||||
|
| UI | React 19, TypeScript |
|
||||||
|
| Build | Vite 8 |
|
||||||
|
| Styling | Tailwind CSS v4 と project design tokens |
|
||||||
|
| Desktop | Tauri 2 |
|
||||||
|
| Markdown | Streamdown / markdown rendering pipeline |
|
||||||
|
| Syntax highlighting | Shiki |
|
||||||
|
| Terminal | xterm.js |
|
||||||
|
| Tests | Vitest, Testing Library |
|
||||||
|
| Local backend | Pi extension, Node HTTP server, WebSocket/SSE |
|
||||||
|
|
||||||
|
## ローカル開発
|
||||||
|
|
||||||
|
依存関係をインストール:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
frontend dev server を起動:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
ビルド:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
|
||||||
# http://127.0.0.1:58785 を開く
|
|
||||||
```
|
```
|
||||||
|
|
||||||
デスクトップと Android:
|
テスト:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run tauri dev # Vite 開発サーバー上のデスクトップウィンドウ
|
npm run test:run
|
||||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
|
||||||
npm run tauri android dev # scripts/dev-android.sh を参照
|
|
||||||
```
|
```
|
||||||
|
|
||||||
PR の前に `npm run validate` を実行してください。typecheck、拡張機能の typecheck、lint、build、テストを順に実行し、CI の `build.yml` もこれを使います。
|
型チェック:
|
||||||
|
|
||||||
## 設定
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run typecheck:extensions
|
||||||
|
```
|
||||||
|
|
||||||
コンテナ構成は OpenCodeUI のデプロイから引き継いだものです。最初に `.env.example` を `.env` にコピーしてください。
|
完全な検証:
|
||||||
|
|
||||||
| ファイル | コマンド | 内容 |
|
```bash
|
||||||
|---|---|---|
|
npm run validate
|
||||||
| `docker-compose.yml` | `docker compose up -d` | フル構成: `gateway`(Caddy + Rust router、`127.0.0.1:6658` と `6659`)、`frontend`、`backend`(`opencode serve`、4096、メモリ上限 4G)。 |
|
```
|
||||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | 既存の `opencode serve` 向けのフロントエンドのみ。`BACKEND_URL`(デフォルト `host.docker.internal:4096`)と `PORT`(デフォルト `3000`)を使います。 |
|
|
||||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | 任意の override: `backend` を privileged で動かし、Docker ソケットとホストの `/` を `/host` にマウントします。 |
|
|
||||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | 任意の override: `docker/` から gateway、frontend、backend をローカルでビルドします。 |
|
|
||||||
|
|
||||||
<details>
|
## Pi extension runtime
|
||||||
<summary>環境変数(コンテナ構成のみ)</summary>
|
|
||||||
|
|
||||||
| 変数 | 必須 | 説明 |
|
PiAgentUI は `package.json` で Pi extension として登録されています。
|
||||||
|---|---|---|
|
|
||||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | 最低 1 つ | backend コンテナに渡す LLM プロバイダーのキー。 |
|
|
||||||
| `GATEWAY_PORT` | いいえ(`6658`) | 統合エントリーポイントのホスト側ポート。 |
|
|
||||||
| `PREVIEW_PORT` | いいえ(`6659`) | プレビュー用エントリーポイントのホスト側ポート。 |
|
|
||||||
| `PREVIEW_DOMAIN` | いいえ | プレビュー用の別ドメイン。リバースプロキシが `PREVIEW_PORT` に転送します。 |
|
|
||||||
| `WORKSPACE` | いいえ(`./workspace`) | backend の `/workspace` にマウントするホストのディレクトリ。 |
|
|
||||||
| `OPENCODE_SERVER_PASSWORD` | 公開デプロイ時 | backend と router のパスワード。 |
|
|
||||||
| `OPENCODE_SERVER_USERNAME` | いいえ(`opencode`) | 上記パスワードと組になるユーザー名。 |
|
|
||||||
| `PUBLIC_BASE_URL` | いいえ | gateway 内蔵の router が使う公開ベース URL。 |
|
|
||||||
| `ROUTER_SCAN_INTERVAL` | いいえ(`5`) | router のスキャン間隔。 |
|
|
||||||
| `ROUTER_TOKEN_LENGTH` | いいえ(`12`) | 生成するルートトークンの長さ。 |
|
|
||||||
| `ROUTER_PORT_RANGE` | いいえ(`3000-9999`) | router が扱うポート範囲。 |
|
|
||||||
| `ROUTER_EXCLUDE_PORTS` | いいえ(`4096`) | router が除外するポート。 |
|
|
||||||
| `DOCKER_API_VERSION` | いいえ(`1.40`) | backend が使う Docker API のバージョン。`.env.example` ではなく `docker-compose.yml` で設定されています。 |
|
|
||||||
| `USE_CHINA_MIRROR` | いいえ(`1`) | backend イメージのビルドオプション。下のミラーを使います。 |
|
|
||||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | いいえ | backend のツールチェーン用ミラー。 |
|
|
||||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | いいえ | backend イメージのビルドで使うインストーラーの URL。 |
|
|
||||||
|
|
||||||
</details>
|
```json
|
||||||
|
{
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./extensions/piagentui-server.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## プロジェクトの状態
|
extension 起動時、ローカル discovery metadata が以下に書き込まれます。
|
||||||
|
|
||||||
1.0 前のベータ版(`0.6.10`)で、開発者 1 人がメンテナンスしています。リリースは `v*` タグで `release.yml` がビルドし(Windows、macOS、Linux、Android の成果物)、`deploy.yml` が `VITE_BASE_PATH=/PiAgentUI/` で Web 版を公開します。
|
```text
|
||||||
|
~/.pi/agent/piagentui-port.json
|
||||||
|
```
|
||||||
|
|
||||||
- OpenCodeUI の名前はまだ一部に残っています。Rust router の crate は `opencodeui-router`、イメージは `opencodeui-*`、Android の APK は `OpenCodeUI-*`、`.env.example` のコメントは中国語で、Docker の backend が動かす `opencode serve` は Pi 拡張機能のフローではなく引き継いだデプロイのものです。
|
Web アプリはこのファイルの port/token を使ってローカル extension server と通信します。一部の backend 変更は、ブラウザに反映される前に Pi extension process の再起動または reload が必要です。
|
||||||
- `docker-compose.yml` と `docker-compose.standalone.yml` は、まだ upstream の `ghcr.io/lehhair/opencodeui-frontend` イメージを取得します。
|
|
||||||
- テストはコンポーネントとサーバーのレベル(Vitest)です。Tauri シェルと Rust router はコンパイル以外のテストがなく、ブラウザの end-to-end テストもカバレッジの閾値もありません。
|
|
||||||
- 音声入力には OpenAI 互換の STT エンドポイントが必要で、ブラウザでの自動テストはありません。
|
|
||||||
|
|
||||||
Issue と pull request は [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues) へ。PR の前に `npm run validate` を実行してください。
|
## 重要なローカル endpoints
|
||||||
|
|
||||||
|
| Endpoint | 目的 |
|
||||||
|
| -------------------------------- | --------------------------------------------------- |
|
||||||
|
| `GET /api/models` | Pi の実モデル一覧と正規化された capability metadata |
|
||||||
|
| `GET /api/sessions` | Pi session files から読み取ったセッション一覧 |
|
||||||
|
| `GET /api/sessions/:id/messages` | UI rendering 用に正規化されたセッション履歴 |
|
||||||
|
| `GET /api/sessions/:id/context` | active session の runtime context usage |
|
||||||
|
| `POST /api/messages/send` | 指定された Pi session にユーザーメッセージを送信 |
|
||||||
|
| `GET /api/skills` | 有効な Pi skills を source ごとに分類 |
|
||||||
|
| `GET /api/mcp/status` | 設定済み MCP servers の metadata |
|
||||||
|
| `GET /api/commands/list` | Pi built-in / dynamic slash commands |
|
||||||
|
| `GET /global/event` | PiAgentUI backend からの SSE event stream |
|
||||||
|
|
||||||
|
## 開発原則
|
||||||
|
|
||||||
|
- UI-only の仮状態よりも、Pi runtime の実データを優先する。
|
||||||
|
- OpenRouter は catalog enrichment としてのみ使い、Pi の provider/model identity を置き換えない。
|
||||||
|
- 不明な runtime 値は明示的に扱う。
|
||||||
|
- 振る舞いを変える場合は、可能な限り先にテストを書く。
|
||||||
|
- tool results は tool cards 内に保持する。
|
||||||
|
- 既存の theme、motion、spacing、panel patterns に合わせる。
|
||||||
|
- 実装済み機能と roadmap を明確に分ける。
|
||||||
|
|
||||||
|
## リポジトリノート
|
||||||
|
|
||||||
|
このプロジェクトには、元の OpenCodeUI ベースから継承した名前が一部残っています。package metadata や古い documentation などです。現在の方向性は PiAgentUI-first であり、将来的な cleanup ではローカル workflow を壊さずに残りの名称を移行する必要があります。
|
||||||
|
|
||||||
## ライセンス
|
## ライセンス
|
||||||
|
|
||||||
GPL-3.0-only。[`LICENSE`](LICENSE) を参照してください。PiAgentUI は OpenCodeUI をフォークして作り直したもので、元の作者は `src-tauri/Cargo.toml` に記載されています。
|
このリポジトリは `package.json` に記載されているライセンス `GPL-3.0-only` に従います。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<sub>作成: <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
|
||||||
</div>
|
|
||||||
@@ -1,136 +1,214 @@
|
|||||||
<div align="center">
|
# PiAgentUI
|
||||||
|
|
||||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: a desktop and web workspace for live Pi Agent sessions, with chat, tool call cards and the Files, Changes, MCP and Skills panel">
|
**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.**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
[](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml)
|
||||||
|
|
||||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [Design specs](docs/superpowers/specs/)
|
**English (US)** | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | [日本語](./README.ja.md)
|
||||||
|
|
||||||
**English** · [Português (BR)](README.pt-BR.md) · [Español](README.es.md) · [日本語](README.ja.md)
|
## About
|
||||||
|
|
||||||
</div>
|
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.
|
||||||
|
|
||||||

|
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)).
|
||||||
|
|
||||||
## What it is
|
|
||||||
|
|
||||||
PiAgentUI is a chat UI and workspace for the [Pi coding agent](https://www.npmjs.com/package/@mariozechner/pi-coding-agent). It runs as a web app served by a Pi extension or as a Tauri 2 desktop app, and shows in one window what a bare terminal hides: the session list, tool calls, MCP servers, skills and context usage.
|
|
||||||
|
|
||||||
- Streams real Pi sessions over SSE (`/global/event`) and renders Markdown, code (Shiki) and math (KaTeX).
|
|
||||||
- Shows tool calls as cards and the context usage of the active session.
|
|
||||||
- Switches models using the capabilities the runtime reports.
|
|
||||||
- Lists configured MCP servers (transport, command, URL, source) and effective skills grouped by source.
|
|
||||||
- Exposes built-in and dynamic slash commands, plus Git controls in the composer.
|
|
||||||
- Embeds a terminal (xterm.js) and a file explorer next to the chat.
|
|
||||||
- Accepts voice input through an OpenAI-compatible STT endpoint set in settings.
|
|
||||||
- UI locales: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar` (`src/locales/`).
|
|
||||||
|
|
||||||
## Why use it
|
|
||||||
|
|
||||||
PiAgentUI does not keep its own agent state. Sessions, models, tools, skills and commands come from the Pi runtime through the extension, so what you see is what Pi is running.
|
|
||||||
|
|
||||||
Do not use it if you do not run Pi: without the extension the UI only shows the server connection dialog. It also handles one runtime per window and has no hosted demo or published npm package.
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- `extensions/piagentui-server.ts` is the Pi extension. It serves the built UI from `dist/` over Node `http` + `ws` and injects `window.PI_BOOT_DATA` (base URL and token).
|
```text
|
||||||
- On start it listens on port `58785` (or the next free one) and writes the port and a random token to `~/.pi/agent/piagentui-port.json`.
|
browser window / Tauri webview
|
||||||
- `GET /global/health` is open; other `/api/*` routes require `Authorization: Bearer <token>`. The socket binds `0.0.0.0`, but each request is checked against loopback unless the LAN toggle at `/api/network/access` is on (off by default).
|
|
|
||||||
- The Tauri app reads the same discovery file through `get_pi_agent_connection`, so the desktop build connects to the local runtime without manual setup.
|
| HTTP /api/* + SSE /global/event + WebSocket
|
||||||
- In containers, the Rust router (`src-router/`, axum) sits behind Caddy as the gateway in front of the frontend and backend services.
|
v
|
||||||
|
Pi extension server (extensions/piagentui-server.ts, Node http + ws)
|
||||||
## Quickstart
|
|-- 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}
|
||||||
| Tool | Version | Needed for |
|
|-- enforces bearer-token auth on /api/* and loopback-only access by default
|
||||||
|---|---|---|
|
|-- bridges sessions, models, tools, MCP, skills and commands
|
||||||
| Node.js | `>=22` (`.tool-versions`: 22.22.0) | everything |
|
^
|
||||||
| npm | bundled with Node | everything (`package-lock.json`) |
|
| loaded by the runtime
|
||||||
| Rust | `1.85.0` (`.tool-versions`) | desktop app and gateway router |
|
Pi Agent process
|
||||||
| Docker Compose | any recent | container stack only |
|
|
||||||
| Pi Agent | installed locally | real sessions and models |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
|
||||||
cd PiAgentUI
|
|
||||||
npm ci # postinstall runs scripts/copy-material-icons.mjs
|
|
||||||
npm run build # tsc -b && vite build -> dist/
|
|
||||||
npm run dev # Vite dev server on http://localhost:5173
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run dev` is front end only: it proxies `/api` to `127.0.0.1:4096` with the prefix stripped. For the Pi flow, build first and let the extension serve `dist/` (Pi loads this repository as a package through the `pi.extensions` field of `package.json`):
|
- 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.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 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
|
```bash
|
||||||
npm run build
|
git clone https://github.com/evandrodevbr/PiAgentUI.git
|
||||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Web UI served by the Pi extension (this is the path that talks to a real Pi runtime):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
# open http://127.0.0.1:58785
|
||||||
```
|
```
|
||||||
|
|
||||||
Desktop and Android:
|
Desktop app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run tauri dev # desktop window on the Vite dev server
|
npm run tauri dev # development window, uses the Vite dev server
|
||||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
npm run tauri build # bundles per src-tauri/tauri.conf.json (deb on Linux, dmg, nsis)
|
||||||
npm run tauri android dev # see scripts/dev-android.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Before a PR, `npm run validate` runs typecheck, extension typecheck, lint, build and tests; it is the gate CI runs in `build.yml`.
|
Android (see `scripts/dev-android.sh` and the Android job in the release workflow):
|
||||||
|
|
||||||
## Configuration
|
```bash
|
||||||
|
npm run tauri android dev
|
||||||
|
```
|
||||||
|
|
||||||
The container stack is inherited from the OpenCodeUI deployment. Copy `.env.example` to `.env` first.
|
## Usage
|
||||||
|
|
||||||
| File | Command | What it does |
|
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.
|
||||||
|---|---|---|
|
|
||||||
| `docker-compose.yml` | `docker compose up -d` | Full stack: `gateway` (Caddy + Rust router, `127.0.0.1:6658` and `6659`), `frontend`, `backend` (`opencode serve`, 4096, 4G memory limit). |
|
|
||||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | Front end only, for an existing `opencode serve`. Uses `BACKEND_URL` (default `host.docker.internal:4096`) and `PORT` (default `3000`). |
|
|
||||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | Optional override: `backend` runs privileged with the Docker socket and host `/` mounted at `/host`. |
|
|
||||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | Optional override: builds gateway, frontend and backend locally from `docker/`. |
|
|
||||||
|
|
||||||
<details>
|
| Endpoint | Purpose |
|
||||||
<summary>Environment variables (container stack only)</summary>
|
|---|---|
|
||||||
|
| `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) |
|
||||||
|
|
||||||
| Variable | Required | Description |
|
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.
|
||||||
|---|---|---|
|
|
||||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | At least one | LLM provider keys passed to the backend container. |
|
|
||||||
| `GATEWAY_PORT` | No (`6658`) | Host port of the unified entry point. |
|
|
||||||
| `PREVIEW_PORT` | No (`6659`) | Host port of the preview entry point. |
|
|
||||||
| `PREVIEW_DOMAIN` | No | Separate domain for previews; the reverse proxy forwards it to `PREVIEW_PORT`. |
|
|
||||||
| `WORKSPACE` | No (`./workspace`) | Host directory mounted at `/workspace` in the backend. |
|
|
||||||
| `OPENCODE_SERVER_PASSWORD` | Public deployments | Password for the backend and the router. |
|
|
||||||
| `OPENCODE_SERVER_USERNAME` | No (`opencode`) | Username paired with the password above. |
|
|
||||||
| `PUBLIC_BASE_URL` | No | Public base URL used by the router built into the gateway. |
|
|
||||||
| `ROUTER_SCAN_INTERVAL` | No (`5`) | Router scan interval. |
|
|
||||||
| `ROUTER_TOKEN_LENGTH` | No (`12`) | Length of the generated route tokens. |
|
|
||||||
| `ROUTER_PORT_RANGE` | No (`3000-9999`) | Port range the router handles. |
|
|
||||||
| `ROUTER_EXCLUDE_PORTS` | No (`4096`) | Ports the router skips. |
|
|
||||||
| `DOCKER_API_VERSION` | No (`1.40`) | Docker API version for the backend; set in `docker-compose.yml`, not in `.env.example`. |
|
|
||||||
| `USE_CHINA_MIRROR` | No (`1`) | Backend image build option: use the mirror sources below. |
|
|
||||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | No | Mirrors for the backend toolchain. |
|
|
||||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | No | Installer URLs used when building the backend image. |
|
|
||||||
|
|
||||||
</details>
|
Auditing the agent from a shell works too, for example:
|
||||||
|
|
||||||
## Project status
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
Beta, pre-1.0 (`0.6.10`), maintained by one developer. Releases are built by `release.yml` on `v*` tags (Windows, macOS, Linux and Android artifacts); `deploy.yml` publishes the web build with `VITE_BASE_PATH=/PiAgentUI/`.
|
## Production and deploy
|
||||||
|
|
||||||
- OpenCodeUI naming is only partly cleaned: the Rust router crate is `opencodeui-router`, images are `opencodeui-*`, Android APKs are named `OpenCodeUI-*`, `.env.example` comments are in Chinese, and the Docker backend runs `opencode serve`, which belongs to the inherited deployment, not to the Pi extension flow.
|
```bash
|
||||||
- `docker-compose.yml` and `docker-compose.standalone.yml` still pull the upstream `ghcr.io/lehhair/opencodeui-frontend` image.
|
npm run build # tsc -b && vite build; output in dist/ (about 14 MB, static)
|
||||||
- Tests are component- and server-level (Vitest). There are no tests for the Tauri shell or the Rust router beyond compilation, no end-to-end browser test and no coverage threshold.
|
npm run preview # serves dist/ locally for a final check
|
||||||
- Voice input needs an OpenAI-compatible STT endpoint and has no automated browser test.
|
```
|
||||||
|
|
||||||
Issues and pull requests go to the [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues); run `npm run validate` before a PR.
|
- 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.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Everything below was executed on a clean clone in this repository state:
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Current state and limitations
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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
|
## License
|
||||||
|
|
||||||
GPL-3.0-only, see [`LICENSE`](LICENSE). PiAgentUI is a fork and rework of OpenCodeUI; the original author is credited in `src-tauri/Cargo.toml`.
|
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)
|
||||||
|
|
||||||
<div align="center">
|
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.
|
||||||
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
|
||||||
</div>
|
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.
|
||||||
+172
-97
@@ -1,136 +1,211 @@
|
|||||||
<div align="center">
|
<p align="center">
|
||||||
|
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="Logo do PiAgentUI" width="760" />
|
||||||
|
</p>
|
||||||
|
|
||||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: workspace desktop e web para sessões reais do Pi Agent, com chat, cards de tool calls e o painel Files, Changes, MCP e Skills">
|
# 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.**
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [Specs de design](docs/superpowers/specs/)
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
[](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml)
|
||||||
|
|
||||||
[English](README.md) · **Português (BR)** · [Español](README.es.md) · [日本語](README.ja.md)
|
[English (US)](./README.md) | **Português (BR)** | [Español](./README.es.md) | [日本語](./README.ja.md)
|
||||||
|
|
||||||
</div>
|
## Sobre
|
||||||
|
|
||||||

|
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.
|
||||||
|
|
||||||
## O que é
|
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)).
|
||||||
|
|
||||||
PiAgentUI é uma interface de chat e um workspace para o [agente de código Pi](https://www.npmjs.com/package/@mariozechner/pi-coding-agent). Roda como app web servido por uma extensão do Pi ou como app desktop em Tauri 2, e mostra numa janela o que o terminal cru esconde: a lista de sessões, as tool calls, os servidores MCP, as skills e o uso de contexto.
|
|
||||||
|
|
||||||
- Faz streaming das sessões reais do Pi por SSE (`/global/event`) e renderiza Markdown, código (Shiki) e matemática (KaTeX).
|
|
||||||
- Mostra tool calls como cards e o uso de contexto da sessão ativa.
|
|
||||||
- Troca de modelo usando as capacidades que o runtime informa.
|
|
||||||
- Lista os servidores MCP configurados (transporte, comando, URL, origem) e as skills efetivas agrupadas por origem.
|
|
||||||
- Expõe slash commands nativos e dinâmicos, além de controles de Git no composer.
|
|
||||||
- Embute um terminal (xterm.js) e um explorador de arquivos ao lado do chat.
|
|
||||||
- Aceita entrada de voz por um endpoint de STT compatível com OpenAI configurado nas settings.
|
|
||||||
- Idiomas da interface: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar` (`src/locales/`).
|
|
||||||
|
|
||||||
## Por que usar
|
|
||||||
|
|
||||||
O PiAgentUI não guarda estado próprio do agente. Sessões, modelos, tools, skills e comandos vêm do runtime do Pi pela extensão, então o que aparece na tela é o que o Pi está rodando.
|
|
||||||
|
|
||||||
Não use se você não roda o Pi: sem a extensão, a interface mostra só o diálogo de conexão com o servidor. Ele também atende um runtime por janela e não tem demo hospedada nem pacote publicado no npm.
|
|
||||||
|
|
||||||
## Como funciona
|
## Como funciona
|
||||||
|
|
||||||
- `extensions/piagentui-server.ts` é a extensão do Pi. Ela serve a UI compilada de `dist/` com `http` + `ws` do Node e injeta `window.PI_BOOT_DATA` (URL base e token).
|
```text
|
||||||
- Ao iniciar, escuta na porta `58785` (ou na próxima livre) e grava a porta e um token aleatório em `~/.pi/agent/piagentui-port.json`.
|
janela do navegador / webview Tauri
|
||||||
- `GET /global/health` é aberto; as outras rotas `/api/*` exigem `Authorization: Bearer <token>`. O socket escuta em `0.0.0.0`, mas cada requisição é checada contra loopback, a menos que o acesso pela LAN em `/api/network/access` esteja ligado (vem desligado).
|
|
|
||||||
- O app Tauri lê o mesmo arquivo de descoberta pelo comando `get_pi_agent_connection`, então o build desktop conecta no runtime local sem configuração manual.
|
| HTTP /api/* + SSE /global/event + WebSocket
|
||||||
- Em containers, o router em Rust (`src-router/`, axum) fica atrás do Caddy como gateway na frente dos serviços de frontend e backend.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
| Á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` |
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- 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
|
## Início rápido
|
||||||
|
|
||||||
| Ferramenta | Versão | Usada para |
|
|
||||||
|---|---|---|
|
|
||||||
| Node.js | `>=22` (`.tool-versions`: 22.22.0) | tudo |
|
|
||||||
| npm | a que vem com o Node | tudo (`package-lock.json`) |
|
|
||||||
| Rust | `1.85.0` (`.tool-versions`) | app desktop e router do gateway |
|
|
||||||
| Docker Compose | recente | só a stack em containers |
|
|
||||||
| Pi Agent | instalado localmente | sessões e modelos reais |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
git clone https://github.com/evandrodevbr/PiAgentUI.git
|
||||||
cd PiAgentUI
|
cd PiAgentUI
|
||||||
npm ci # o postinstall roda scripts/copy-material-icons.mjs
|
npm ci # 615 pacotes; roda scripts/copy-material-icons.mjs
|
||||||
npm run build # tsc -b && vite build -> dist/
|
npm run build # tsc -b && vite build -> dist/
|
||||||
npm run dev # servidor Vite em http://localhost:5173
|
npm run dev # servidor de desenvolvimento Vite em http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run dev` é só o front end: ele faz proxy de `/api` para `127.0.0.1:4096` sem o prefixo. Para o fluxo com o Pi, compile antes e deixe a extensão servir `dist/` (o Pi carrega este repositório como pacote pelo campo `pi.extensions` do `package.json`):
|
UI web servida pela extensão Pi (é o caminho que conversa com o runtime Pi de verdade):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build # a extensão serve o dist/
|
||||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
# 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
|
# abra http://127.0.0.1:58785
|
||||||
```
|
```
|
||||||
|
|
||||||
Desktop e Android:
|
App desktop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run tauri dev # janela desktop sobre o servidor Vite
|
npm run tauri dev # janela de desenvolvimento, usa o servidor Vite
|
||||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
npm run tauri build # empacota conforme src-tauri/tauri.conf.json (deb no Linux, dmg, nsis)
|
||||||
npm run tauri android dev # veja scripts/dev-android.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Antes de um PR, `npm run validate` roda typecheck, typecheck da extensão, lint, build e testes; é o gate que o CI roda em `build.yml`.
|
Android (veja `scripts/dev-android.sh` e o job de Android no workflow de release):
|
||||||
|
|
||||||
## Configuração
|
```bash
|
||||||
|
npm run tauri android dev
|
||||||
|
```
|
||||||
|
|
||||||
A stack em containers foi herdada do deploy do OpenCodeUI. Copie `.env.example` para `.env` antes.
|
## Uso
|
||||||
|
|
||||||
| Arquivo | Comando | O que faz |
|
Rotas locais expostas pelo servidor da extensão. Todas, exceto `/global/health` e a rota SSE, exigem o bearer token do arquivo de descoberta.
|
||||||
|---|---|---|
|
|
||||||
| `docker-compose.yml` | `docker compose up -d` | Stack completa: `gateway` (Caddy + router em Rust, `127.0.0.1:6658` e `6659`), `frontend`, `backend` (`opencode serve`, 4096, limite de 4G de memória). |
|
|
||||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | Só o front end, para um `opencode serve` já existente. Usa `BACKEND_URL` (padrão `host.docker.internal:4096`) e `PORT` (padrão `3000`). |
|
|
||||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | Override opcional: o `backend` roda privilegiado, com o socket do Docker e o `/` do host montado em `/host`. |
|
|
||||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | Override opcional: compila gateway, frontend e backend localmente a partir de `docker/`. |
|
|
||||||
|
|
||||||
<details>
|
| Rota | Para que serve |
|
||||||
<summary>Variáveis de ambiente (só para a stack em containers)</summary>
|
|---|---|
|
||||||
|
| `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) |
|
||||||
|
|
||||||
| Variável | Obrigatória | Descrição |
|
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.
|
||||||
|---|---|---|
|
|
||||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | Ao menos uma | Chaves de provedores de LLM repassadas ao container do backend. |
|
|
||||||
| `GATEWAY_PORT` | Não (`6658`) | Porta do host para o ponto de entrada unificado. |
|
|
||||||
| `PREVIEW_PORT` | Não (`6659`) | Porta do host para o ponto de entrada de previews. |
|
|
||||||
| `PREVIEW_DOMAIN` | Não | Domínio separado para previews; o proxy reverso encaminha para `PREVIEW_PORT`. |
|
|
||||||
| `WORKSPACE` | Não (`./workspace`) | Pasta do host montada em `/workspace` no backend. |
|
|
||||||
| `OPENCODE_SERVER_PASSWORD` | Em deploys públicos | Senha do backend e do router. |
|
|
||||||
| `OPENCODE_SERVER_USERNAME` | Não (`opencode`) | Usuário que acompanha a senha acima. |
|
|
||||||
| `PUBLIC_BASE_URL` | Não | URL base pública usada pelo router embutido no gateway. |
|
|
||||||
| `ROUTER_SCAN_INTERVAL` | Não (`5`) | Intervalo de varredura do router. |
|
|
||||||
| `ROUTER_TOKEN_LENGTH` | Não (`12`) | Tamanho dos tokens de rota gerados. |
|
|
||||||
| `ROUTER_PORT_RANGE` | Não (`3000-9999`) | Faixa de portas que o router atende. |
|
|
||||||
| `ROUTER_EXCLUDE_PORTS` | Não (`4096`) | Portas que o router ignora. |
|
|
||||||
| `DOCKER_API_VERSION` | Não (`1.40`) | Versão da API do Docker para o backend; definida no `docker-compose.yml`, não no `.env.example`. |
|
|
||||||
| `USE_CHINA_MIRROR` | Não (`1`) | Opção de build da imagem do backend: usa os mirrors abaixo. |
|
|
||||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | Não | Mirrors para o toolchain do backend. |
|
|
||||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | Não | URLs de instalação usadas no build da imagem do backend. |
|
|
||||||
|
|
||||||
</details>
|
Auditar o agente pelo shell também funciona, por exemplo:
|
||||||
|
|
||||||
## Estado do projeto
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
Beta, antes da 1.0 (`0.6.10`), mantido por um desenvolvedor. As releases saem do `release.yml` em tags `v*` (artefatos para Windows, macOS, Linux e Android); o `deploy.yml` publica o build web com `VITE_BASE_PATH=/PiAgentUI/`.
|
## Produção e deploy
|
||||||
|
|
||||||
- O nome OpenCodeUI ainda não saiu de tudo: o crate do router em Rust é `opencodeui-router`, as imagens são `opencodeui-*`, os APKs Android saem como `OpenCodeUI-*`, os comentários do `.env.example` estão em chinês e o backend Docker roda `opencode serve`, que é do deploy herdado, não do fluxo com a extensão do Pi.
|
```bash
|
||||||
- `docker-compose.yml` e `docker-compose.standalone.yml` ainda puxam a imagem upstream `ghcr.io/lehhair/opencodeui-frontend`.
|
npm run build # tsc -b && vite build; saída em dist/ (cerca de 14 MB, estático)
|
||||||
- Os testes são de componente e de servidor (Vitest). Não há testes do shell Tauri nem do router em Rust além da compilação, nem teste end-to-end no navegador, nem meta de cobertura.
|
npm run preview # serve o dist/ localmente para conferência final
|
||||||
- A entrada de voz depende de um endpoint de STT compatível com OpenAI e não tem teste automatizado no navegador.
|
```
|
||||||
|
|
||||||
Issues e pull requests vão para o [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues); rode `npm run validate` antes de abrir um PR.
|
- 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.
|
||||||
|
|
||||||
|
## Estrutura do projeto
|
||||||
|
|
||||||
|
```text
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificação
|
||||||
|
|
||||||
|
Tudo abaixo foi executado em um clone limpo neste estado do repositório:
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Estado atual e limitações
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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
|
## Licença
|
||||||
|
|
||||||
GPL-3.0-only, veja [`LICENSE`](LICENSE). O PiAgentUI é um fork retrabalhado do OpenCodeUI; o autor original está creditado em `src-tauri/Cargo.toml`.
|
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`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
|
||||||
</div>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
|
|
||||||
<title id="title">PiAgentUI</title>
|
|
||||||
<desc id="desc">PiAgentUI: a desktop and web workspace for live Pi Agent sessions. On the right, a simplified app window shows a chat with a tool call card, the composer prompt and the Files, Changes, MCP and Skills side panel.</desc>
|
|
||||||
|
|
||||||
<rect width="1200" height="420" rx="24" fill="#1e1d1c"/>
|
|
||||||
|
|
||||||
<g id="title-block" font-family="system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif">
|
|
||||||
<text x="64" y="156" font-size="68" font-weight="700" fill="#f4f1ea">PiAgentUI</text>
|
|
||||||
<text x="64" y="214" font-size="28" fill="#f4f1ea">A desktop and web workspace for</text>
|
|
||||||
<text x="64" y="252" font-size="28" fill="#f4f1ea">live Pi Agent sessions, tools and MCP.</text>
|
|
||||||
<text x="64" y="326" font-size="20" fill="#a8a29e" font-family="ui-monospace, "SF Mono", Consolas, monospace">React 19 · Vite 8 · Tauri 2 · Node ≥22</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g id="workspace" transform="translate(640 60)" font-family="system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif">
|
|
||||||
<rect width="496" height="300" rx="16" fill="#262422" stroke="#3a3633" stroke-width="2"/>
|
|
||||||
<text x="24" y="31" font-size="18" fill="#a8a29e">Session</text>
|
|
||||||
<line x1="0" y1="48" x2="496" y2="48" stroke="#3a3633" stroke-width="2"/>
|
|
||||||
<line x1="316" y1="48" x2="316" y2="300" stroke="#3a3633" stroke-width="2"/>
|
|
||||||
|
|
||||||
<g id="chat">
|
|
||||||
<rect x="124" y="64" width="176" height="40" rx="12" fill="#3a3633"/>
|
|
||||||
<text x="140" y="91" font-size="20" fill="#f4f1ea">fix the build</text>
|
|
||||||
|
|
||||||
<rect x="24" y="120" width="276" height="76" rx="12" fill="none" stroke="#d97706" stroke-width="2"/>
|
|
||||||
<g font-family="ui-monospace, "SF Mono", Consolas, monospace" font-size="18">
|
|
||||||
<text x="40" y="150" fill="#d97706">bash</text>
|
|
||||||
<text x="96" y="150" fill="#f4f1ea">npm run build</text>
|
|
||||||
<path d="M42 172 L48 178 L60 165" fill="none" stroke="#a8a29e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<text x="70" y="180" fill="#a8a29e">exit 0</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<text x="24" y="232" font-size="20" fill="#f4f1ea">Build passes.</text>
|
|
||||||
|
|
||||||
<rect x="24" y="248" width="276" height="36" rx="10" fill="#1e1d1c" stroke="#3a3633" stroke-width="2"/>
|
|
||||||
<path d="M38 259 L46 266 L38 273" fill="none" stroke="#d97706" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<line x1="50" y1="274" x2="56" y2="274" stroke="#d97706" stroke-width="3" stroke-linecap="round"/>
|
|
||||||
<text x="68" y="272" font-size="18" fill="#a8a29e">Reply to agent</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g id="side-panel" font-size="20">
|
|
||||||
<text x="344" y="96" fill="#a8a29e">Files</text>
|
|
||||||
<text x="344" y="140" fill="#a8a29e">Changes</text>
|
|
||||||
<rect x="328" y="164" width="4" height="28" rx="2" fill="#d97706"/>
|
|
||||||
<text x="344" y="184" fill="#d97706" font-weight="700">MCP</text>
|
|
||||||
<text x="344" y="228" fill="#a8a29e">Skills</text>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1025,8 +1025,13 @@ describe('PiAgentUi Extension Server', () => {
|
|||||||
|
|
||||||
// Trigger agent_start - should broadcast session.status and session.updated!
|
// Trigger agent_start - should broadcast session.status and session.updated!
|
||||||
await listeners['agent_start']()
|
await listeners['agent_start']()
|
||||||
const chunk3 = await reader?.read()
|
// The two events are separate writes, so TCP may deliver them in different chunks.
|
||||||
const text3 = new TextDecoder().decode(chunk3?.value)
|
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('session.status')
|
||||||
expect(text3).toContain('working')
|
expect(text3).toContain('working')
|
||||||
expect(text3).toContain('session.updated')
|
expect(text3).toContain('session.updated')
|
||||||
|
|||||||
+79
-137
@@ -3,7 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws'
|
|||||||
import * as http from 'node:http'
|
import * as http from 'node:http'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomBytes, randomUUID } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
validateBearerToken,
|
validateBearerToken,
|
||||||
preventPathTraversal,
|
preventPathTraversal,
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
type StoredSttSettings,
|
type StoredSttSettings,
|
||||||
type StoredTtsSettings,
|
type StoredTtsSettings,
|
||||||
} from './piagentui-settings-store.js'
|
} from './piagentui-settings-store.js'
|
||||||
|
import { createStatCache } from './piagentui-stat-cache.js'
|
||||||
|
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
|
||||||
import {
|
import {
|
||||||
buildExtensionCatalog,
|
buildExtensionCatalog,
|
||||||
executeExtensionInstallCommand,
|
executeExtensionInstallCommand,
|
||||||
@@ -517,48 +519,76 @@ export default function (pi: ExtensionAPI) {
|
|||||||
.slice(0, 120)
|
.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 {
|
function readSessionMetadata(filePath: string): any | null {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath, 'utf8')
|
const stats = fs.statSync(filePath)
|
||||||
const lines = content.trim().split('\n').filter(Boolean)
|
let cached = sessionFileCache.get(filePath, stats)
|
||||||
if (lines.length === 0) return null
|
if (!cached) {
|
||||||
|
cached = { value: parseSessionFile(filePath) }
|
||||||
const header = JSON.parse(lines[0])
|
sessionFileCache.set(filePath, stats, cached.value)
|
||||||
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)
|
const parsed = cached.value
|
||||||
const createdTime = header.timestamp ? new Date(header.timestamp).getTime() : stats.birthtimeMs
|
if (!parsed) return null
|
||||||
const title = sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id)
|
|
||||||
|
|
||||||
|
const createdTime = parsed.headerTimestamp ? new Date(parsed.headerTimestamp).getTime() : stats.birthtimeMs
|
||||||
return {
|
return {
|
||||||
id: header.id,
|
id: parsed.id,
|
||||||
title,
|
title: parsed.title,
|
||||||
directory: header.cwd || latestCtx?.cwd || process.cwd(),
|
directory: parsed.cwd || latestCtx?.cwd || process.cwd(),
|
||||||
time: {
|
time: {
|
||||||
created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime,
|
created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime,
|
||||||
updated: stats.mtimeMs,
|
updated: stats.mtimeMs,
|
||||||
},
|
},
|
||||||
summary: { deletions: 0, files: 0, additions: messageCount },
|
summary: { deletions: 0, files: 0, additions: parsed.messageCount },
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -691,7 +721,8 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateBearerToken(): string {
|
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())
|
const settingsStore = createPiAgentUiSettingsStore(resolvePiAgentUiSettingsPaths())
|
||||||
@@ -774,56 +805,11 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } {
|
function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } {
|
||||||
const current = getLocalSettings()
|
const current = getLocalSettings()
|
||||||
const existing = current.stt || {}
|
const result = applySttSettingsUpdate(current.stt || {}, update)
|
||||||
const next: StoredSttSettings = { ...existing }
|
if (!result.ok) return result
|
||||||
|
|
||||||
if (update.enabled !== undefined) next.enabled = update.enabled === true
|
saveLocalSettings({ ...current, stt: result.next })
|
||||||
if (update.providerKind !== undefined) {
|
return { ok: true, settings: sanitizeSttSettings(result.next) }
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEffectiveTtsSettings(): StoredTtsSettings {
|
function getEffectiveTtsSettings(): StoredTtsSettings {
|
||||||
@@ -857,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 } {
|
function updateTtsSettings(update: any): { ok: true; settings: SanitizedTtsSettings } | { ok: false; error: string } {
|
||||||
const current = getLocalSettings()
|
const current = getLocalSettings()
|
||||||
const existing = current.tts || {}
|
const result = applyTtsSettingsUpdate(current.tts || {}, update)
|
||||||
const next: StoredTtsSettings = { ...existing }
|
if (!result.ok) return result
|
||||||
|
|
||||||
if (update.enabled !== undefined) next.enabled = update.enabled === true
|
saveLocalSettings({ ...current, tts: result.next })
|
||||||
if (update.providerKind !== undefined) {
|
return { ok: true, settings: sanitizeTtsSettings(result.next) }
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> {
|
async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> {
|
||||||
@@ -2129,7 +2066,12 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (!fs.existsSync(piAgentDir)) {
|
if (!fs.existsSync(piAgentDir)) {
|
||||||
fs.mkdirSync(piAgentDir, { recursive: true })
|
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}`)
|
console.log(`[PiAgentUi] Wrote connection info to: ${discoveryPath}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[PiAgentUi] Failed to write discovery file:', 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'
|
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { diffLines } from 'diff'
|
|
||||||
import { animate } from 'motion/mini'
|
import { animate } from 'motion/mini'
|
||||||
import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons'
|
import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons'
|
||||||
import { CopyButton, SmoothHeight } from '../../components/ui'
|
import { CopyButton, SmoothHeight } from '../../components/ui'
|
||||||
@@ -25,6 +24,8 @@ import {
|
|||||||
MessageErrorView,
|
MessageErrorView,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
import { extractToolData } from './tools'
|
import { extractToolData } from './tools'
|
||||||
|
import { getToolSummaryCategory, type ToolSummaryCategory } from './toolSummary'
|
||||||
|
import { computeDiffStats } from '../../utils/diffStats'
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
@@ -597,7 +598,7 @@ const ToolGroup = memo(function ToolGroup({
|
|||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.state?.status === 'error') continue
|
if (part.state?.status === 'error') continue
|
||||||
const data = extractToolData(part)
|
const data = extractToolData(part)
|
||||||
const stats = data.diffStats || computePartDiffStats(data)
|
const stats = data.diffStats || computeDiffStats(data)
|
||||||
if (stats) {
|
if (stats) {
|
||||||
additions += stats.additions
|
additions += stats.additions
|
||||||
deletions += stats.deletions
|
deletions += stats.deletions
|
||||||
@@ -774,21 +775,6 @@ function formatTokens(
|
|||||||
return `${total} ${t('tokens')}`
|
return `${total} ${t('tokens')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolSummaryCategory =
|
|
||||||
| 'execute'
|
|
||||||
| 'write'
|
|
||||||
| 'edit'
|
|
||||||
| 'read'
|
|
||||||
| 'search'
|
|
||||||
| 'list'
|
|
||||||
| 'network'
|
|
||||||
| 'task'
|
|
||||||
| 'todo'
|
|
||||||
| 'question'
|
|
||||||
| 'skill'
|
|
||||||
| 'think'
|
|
||||||
| 'other'
|
|
||||||
|
|
||||||
type ToolSummaryPhase = 'done' | 'active' | 'failed'
|
type ToolSummaryPhase = 'done' | 'active' | 'failed'
|
||||||
|
|
||||||
interface SummarySegment {
|
interface SummarySegment {
|
||||||
@@ -897,45 +883,6 @@ function formatToolSummarySegment(
|
|||||||
return t(key, { count })
|
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 {
|
function isToolPartActive(part: ToolPart): boolean {
|
||||||
return part.state?.status === 'running' || part.state?.status === 'pending'
|
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
|
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
|
// Helper: Group parts for rendering
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { diffLines } from 'diff'
|
import { computeDiffStats } from '../../../utils/diffStats'
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
|
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
|
||||||
import type { ToolPart } from '../../../types/message'
|
import type { ToolPart } from '../../../types/message'
|
||||||
import { useDelayedRender } from '../../../hooks'
|
import { useDelayedRender } from '../../../hooks'
|
||||||
@@ -203,7 +203,7 @@ export const ToolPartView = memo(function ToolPartView({
|
|||||||
if (descriptive) {
|
if (descriptive) {
|
||||||
const hasDiffFiles = !!toolData.files?.length
|
const hasDiffFiles = !!toolData.files?.length
|
||||||
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
|
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
|
||||||
const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData)
|
const diffStats = toolData.diffStats || computeDiffStats(toolData)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group py-0.5">
|
<div className="group py-0.5">
|
||||||
@@ -451,47 +451,6 @@ function isReadableTool(toolName: string): boolean {
|
|||||||
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase())
|
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 - 根据工具类型选择渲染器
|
// ToolBody - 根据工具类型选择渲染器
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { ToolPart } from '../../../types/message'
|
import type { ToolPart } from '../../../types/message'
|
||||||
import { defaultExtractData } from './registry'
|
import { defaultExtractData, extractToolData } from './registry'
|
||||||
|
|
||||||
describe('defaultExtractData', () => {
|
describe('defaultExtractData', () => {
|
||||||
it('extracts files and diagnostics from metadata', () => {
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -60,6 +60,156 @@ interface MetadataDiagnosticEntry {
|
|||||||
// Default Data Extractor
|
// 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 {
|
export function defaultExtractData(part: ToolPart): ExtractedToolData {
|
||||||
const { state } = part
|
const { state } = part
|
||||||
const inputObj = state?.input as Record<string, unknown> | undefined
|
const inputObj = state?.input as Record<string, unknown> | undefined
|
||||||
@@ -78,120 +228,15 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
|
|||||||
result.error = String(state.error)
|
result.error = String(state.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilePath
|
// FilePath (+ output language derived from it)
|
||||||
if (metadata && typeof metadata.filepath === 'string') {
|
const filePath = extractFilePath(inputObj, metadata)
|
||||||
result.filePath = metadata.filepath
|
if (filePath) {
|
||||||
}
|
result.filePath = filePath
|
||||||
if (!result.filePath && inputObj?.filePath) {
|
result.outputLang = detectLanguage(filePath)
|
||||||
result.filePath = String(inputObj.filePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit code
|
Object.assign(result, extractMetadataFields(metadata))
|
||||||
if (metadata && typeof metadata.exit === 'number') {
|
Object.assign(result, extractOutputFields(result, state, metadata))
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -200,6 +245,14 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
|
|||||||
// Tool-Specific Data Extractors
|
// 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 {
|
function bashExtractData(part: ToolPart): ExtractedToolData {
|
||||||
const base = defaultExtractData(part)
|
const base = defaultExtractData(part)
|
||||||
const inputObj = part.state?.input as Record<string, unknown> | undefined
|
const inputObj = part.state?.input as Record<string, unknown> | undefined
|
||||||
@@ -210,10 +263,8 @@ function bashExtractData(part: ToolPart): ExtractedToolData {
|
|||||||
base.inputLang = 'bash'
|
base.inputLang = 'bash'
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
|
const cwd = resolveCwd(inputObj, metadata)
|
||||||
if (typeof cwd === 'string' && cwd.trim()) {
|
if (cwd) base.cwd = cwd
|
||||||
base.cwd = cwd.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -230,61 +230,73 @@ function sanitizePersistedPanelLayout(raw: unknown): PersistedPanelLayout | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
|
function sanitizeIdList(raw: unknown): string[] {
|
||||||
if (!raw || typeof raw !== 'object') {
|
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
|
||||||
return { version: 1, directories: {} }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const data = raw as Partial<PersistedTerminalLayoutMap>
|
function sanitizeActiveTabId(raw: { bottom?: unknown; right?: unknown }): Record<PanelPosition, string | null> {
|
||||||
if (data.version !== 1 || !data.directories || typeof data.directories !== 'object') {
|
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: {} }
|
return { version: 1, directories: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
const directories: Record<string, PersistedTerminalDirectoryLayout> = {}
|
const directories: Record<string, PersistedTerminalDirectoryLayout> = {}
|
||||||
|
|
||||||
for (const [directory, value] of Object.entries(data.directories)) {
|
for (const [directory, value] of Object.entries(data.directories)) {
|
||||||
if (!directory || !value || typeof value !== 'object') continue
|
const layout = directory ? sanitizeTerminalDirectoryLayout(value) : null
|
||||||
const entry = value as Partial<PersistedTerminalDirectoryLayout>
|
if (layout) directories[directory] = layout
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { version: 1, directories }
|
return { version: 1, directories }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user