Compare commits
6
Commits
main
...
refine/2026-10
| 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,13 +173,13 @@ Everything below was executed on a clean clone in this repository state:
|
|||||||
| `npm run typecheck:extensions` | exit 0 |
|
| `npm run typecheck:extensions` | exit 0 |
|
||||||
| `npm run lint` | exit 0 (0 errors, 109 warnings) |
|
| `npm run lint` | exit 0 (0 errors, 109 warnings) |
|
||||||
| `npm run build` | exit 0, `dist/` about 14 MB |
|
| `npm run build` | exit 0, `dist/` about 14 MB |
|
||||||
| `npm run test:run` | 89 files, 319 tests, all passing |
|
| `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`) |
|
| `npm run validate` | exit 0 (the same gate CI runs in `build.yml`) |
|
||||||
| `cargo check` in `src-router/` | exit 0 |
|
| `cargo check` in `src-router/` | exit 0 |
|
||||||
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 warning) |
|
| `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` |
|
| 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 67 files that are not Prettier-formatted, so `npm run check` fails on that step even though the CI gate passes.
|
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
|
## Current state and limitations
|
||||||
|
|
||||||
@@ -190,6 +190,7 @@ Two things to know when running the suite: `npm run test:run` needs `dist/` to e
|
|||||||
- `@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`.
|
- `@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.
|
- 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.
|
- 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.
|
- 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.
|
- `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.
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -177,13 +177,13 @@ Tudo abaixo foi executado em um clone limpo neste estado do repositório:
|
|||||||
| `npm run typecheck:extensions` | exit 0 |
|
| `npm run typecheck:extensions` | exit 0 |
|
||||||
| `npm run lint` | exit 0 (0 erros, 109 avisos) |
|
| `npm run lint` | exit 0 (0 erros, 109 avisos) |
|
||||||
| `npm run build` | exit 0, `dist/` com cerca de 14 MB |
|
| `npm run build` | exit 0, `dist/` com cerca de 14 MB |
|
||||||
| `npm run test:run` | 89 arquivos, 319 testes, todos passando |
|
| `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`) |
|
| `npm run validate` | exit 0 (o mesmo gate que o CI roda em `build.yml`) |
|
||||||
| `cargo check` em `src-router/` | exit 0 |
|
| `cargo check` em `src-router/` | exit 0 |
|
||||||
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 aviso) |
|
| `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` |
|
| 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 67 arquivos fora do padrão do Prettier, então `npm run check` falha nesse passo mesmo com o gate do CI passando.
|
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
|
## Estado atual e limitações
|
||||||
|
|
||||||
@@ -194,6 +194,7 @@ Dois detalhes ao rodar a suíte: `npm run test:run` precisa do `dist/` existir,
|
|||||||
- `@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`.
|
- `@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.
|
- 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.
|
- 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.
|
- Sem pacote publicado no npm e sem demo web hospedada; o README e o código assumem um runtime local.
|
||||||
|
|
||||||
## Documentação
|
## Documentação
|
||||||
|
|||||||
@@ -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