refactor: reduce cyclomatic complexity of settings, tool data and layout sanitizers
Add characterization tests first, then split the hot spots into small helpers: - STT/TTS settings updates move to extensions/piagentui-speech-settings.ts (rule tables) - defaultExtractData is split into diff/diagnostic/output extractors - getToolSummaryCategory becomes an ordered rule table (toolSummary.ts) - sanitizePersistedTerminalLayoutMap is split per level - duplicated diff stats helpers are merged into utils/diffStats.ts
This commit is contained in:
1 parent
da09846708
commit
2d2cdf3b19
13 files changed
+962
-404
No files matched your search
@@ -21,6 +21,7 @@ import {
|
||||
type StoredSttSettings,
|
||||
type StoredTtsSettings,
|
||||
} from './piagentui-settings-store.js'
|
||||
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
|
||||
import {
|
||||
buildExtensionCatalog,
|
||||
executeExtensionInstallCommand,
|
||||
@@ -774,56 +775,11 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } {
|
||||
const current = getLocalSettings()
|
||||
const existing = current.stt || {}
|
||||
const next: StoredSttSettings = { ...existing }
|
||||
const result = applySttSettingsUpdate(current.stt || {}, update)
|
||||
if (!result.ok) return result
|
||||
|
||||
if (update.enabled !== undefined) next.enabled = update.enabled === true
|
||||
if (update.providerKind !== undefined) {
|
||||
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
|
||||
return { ok: false, error: 'Invalid providerKind' }
|
||||
}
|
||||
next.providerKind = update.providerKind
|
||||
}
|
||||
if (update.mode !== undefined) {
|
||||
if (update.mode !== 'file' && update.mode !== 'realtime') return { ok: false, error: 'Invalid mode' }
|
||||
next.mode = update.mode
|
||||
}
|
||||
if (update.baseUrl !== undefined) {
|
||||
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
|
||||
return { ok: false, error: 'Invalid baseUrl' }
|
||||
}
|
||||
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
|
||||
}
|
||||
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
|
||||
if (update.transcriptionEndpoint !== undefined) {
|
||||
if (typeof update.transcriptionEndpoint !== 'string' || !update.transcriptionEndpoint.startsWith('/')) {
|
||||
return { ok: false, error: 'Invalid transcriptionEndpoint' }
|
||||
}
|
||||
next.transcriptionEndpoint = update.transcriptionEndpoint
|
||||
}
|
||||
if (update.transcriptionModel !== undefined) {
|
||||
if (typeof update.transcriptionModel !== 'string' || !update.transcriptionModel.trim()) {
|
||||
return { ok: false, error: 'Invalid transcriptionModel' }
|
||||
}
|
||||
next.transcriptionModel = update.transcriptionModel.trim()
|
||||
}
|
||||
if (update.language !== undefined) {
|
||||
if (update.language === null || update.language === '') delete next.language
|
||||
else if (typeof update.language === 'string') next.language = update.language.trim()
|
||||
else return { ok: false, error: 'Invalid language' }
|
||||
}
|
||||
if (update.insertMode !== undefined) {
|
||||
if (update.insertMode !== 'append' && update.insertMode !== 'replace')
|
||||
return { ok: false, error: 'Invalid insertMode' }
|
||||
next.insertMode = update.insertMode
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
|
||||
if (update.apiKey === null) delete next.apiKey
|
||||
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
|
||||
}
|
||||
|
||||
saveLocalSettings({ ...current, stt: next })
|
||||
return { ok: true, settings: sanitizeSttSettings(next) }
|
||||
saveLocalSettings({ ...current, stt: result.next })
|
||||
return { ok: true, settings: sanitizeSttSettings(result.next) }
|
||||
}
|
||||
|
||||
function getEffectiveTtsSettings(): StoredTtsSettings {
|
||||
@@ -857,62 +813,13 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
function isTtsResponseFormat(value: unknown): value is NonNullable<StoredTtsSettings['responseFormat']> {
|
||||
return (
|
||||
value === 'mp3' || value === 'opus' || value === 'aac' || value === 'flac' || value === 'wav' || value === 'pcm'
|
||||
)
|
||||
}
|
||||
|
||||
function updateTtsSettings(update: any): { ok: true; settings: SanitizedTtsSettings } | { ok: false; error: string } {
|
||||
const current = getLocalSettings()
|
||||
const existing = current.tts || {}
|
||||
const next: StoredTtsSettings = { ...existing }
|
||||
const result = applyTtsSettingsUpdate(current.tts || {}, update)
|
||||
if (!result.ok) return result
|
||||
|
||||
if (update.enabled !== undefined) next.enabled = update.enabled === true
|
||||
if (update.providerKind !== undefined) {
|
||||
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
|
||||
return { ok: false, error: 'Invalid providerKind' }
|
||||
}
|
||||
next.providerKind = update.providerKind
|
||||
}
|
||||
if (update.baseUrl !== undefined) {
|
||||
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
|
||||
return { ok: false, error: 'Invalid baseUrl' }
|
||||
}
|
||||
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
|
||||
}
|
||||
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
|
||||
if (update.speechEndpoint !== undefined) {
|
||||
if (typeof update.speechEndpoint !== 'string' || !update.speechEndpoint.startsWith('/')) {
|
||||
return { ok: false, error: 'Invalid speechEndpoint' }
|
||||
}
|
||||
next.speechEndpoint = update.speechEndpoint
|
||||
}
|
||||
if (update.model !== undefined) {
|
||||
if (typeof update.model !== 'string' || !update.model.trim()) return { ok: false, error: 'Invalid model' }
|
||||
next.model = update.model.trim()
|
||||
}
|
||||
if (update.voice !== undefined) {
|
||||
if (typeof update.voice !== 'string' || !update.voice.trim()) return { ok: false, error: 'Invalid voice' }
|
||||
next.voice = update.voice.trim()
|
||||
}
|
||||
if (update.responseFormat !== undefined) {
|
||||
if (!isTtsResponseFormat(update.responseFormat)) return { ok: false, error: 'Invalid responseFormat' }
|
||||
next.responseFormat = update.responseFormat
|
||||
}
|
||||
if (update.speed !== undefined) {
|
||||
if (typeof update.speed !== 'number' || update.speed < 0.25 || update.speed > 4) {
|
||||
return { ok: false, error: 'Invalid speed' }
|
||||
}
|
||||
next.speed = update.speed
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
|
||||
if (update.apiKey === null) delete next.apiKey
|
||||
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
|
||||
}
|
||||
|
||||
saveLocalSettings({ ...current, tts: next })
|
||||
return { ok: true, settings: sanitizeTtsSettings(next) }
|
||||
saveLocalSettings({ ...current, tts: result.next })
|
||||
return { ok: true, settings: sanitizeTtsSettings(result.next) }
|
||||
}
|
||||
|
||||
async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { diffLines } from 'diff'
|
||||
import { animate } from 'motion/mini'
|
||||
import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons'
|
||||
import { CopyButton, SmoothHeight } from '../../components/ui'
|
||||
@@ -25,6 +24,8 @@ import {
|
||||
MessageErrorView,
|
||||
} from './parts'
|
||||
import { extractToolData } from './tools'
|
||||
import { getToolSummaryCategory, type ToolSummaryCategory } from './toolSummary'
|
||||
import { computeDiffStats } from '../../utils/diffStats'
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
@@ -597,7 +598,7 @@ const ToolGroup = memo(function ToolGroup({
|
||||
for (const part of parts) {
|
||||
if (part.state?.status === 'error') continue
|
||||
const data = extractToolData(part)
|
||||
const stats = data.diffStats || computePartDiffStats(data)
|
||||
const stats = data.diffStats || computeDiffStats(data)
|
||||
if (stats) {
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
@@ -774,21 +775,6 @@ function formatTokens(
|
||||
return `${total} ${t('tokens')}`
|
||||
}
|
||||
|
||||
type ToolSummaryCategory =
|
||||
| 'execute'
|
||||
| 'write'
|
||||
| 'edit'
|
||||
| 'read'
|
||||
| 'search'
|
||||
| 'list'
|
||||
| 'network'
|
||||
| 'task'
|
||||
| 'todo'
|
||||
| 'question'
|
||||
| 'skill'
|
||||
| 'think'
|
||||
| 'other'
|
||||
|
||||
type ToolSummaryPhase = 'done' | 'active' | 'failed'
|
||||
|
||||
interface SummarySegment {
|
||||
@@ -897,45 +883,6 @@ function formatToolSummarySegment(
|
||||
return t(key, { count })
|
||||
}
|
||||
|
||||
function getToolSummaryCategory(toolName: string): ToolSummaryCategory {
|
||||
const lower = toolName.toLowerCase()
|
||||
|
||||
if (lower.includes('todo')) return 'todo'
|
||||
if (lower === 'task') return 'task'
|
||||
if (lower.includes('question') || lower.includes('ask')) return 'question'
|
||||
if (lower.includes('skill')) return 'skill'
|
||||
if (
|
||||
lower.includes('bash') ||
|
||||
lower === 'sh' ||
|
||||
lower.includes('cmd') ||
|
||||
lower.includes('terminal') ||
|
||||
lower.includes('shell')
|
||||
) {
|
||||
return 'execute'
|
||||
}
|
||||
if (lower.includes('write') || lower.includes('save')) {
|
||||
return 'write'
|
||||
}
|
||||
if (lower.includes('edit') || lower.includes('replace') || lower.includes('patch')) {
|
||||
return 'edit'
|
||||
}
|
||||
if (
|
||||
lower.includes('web') ||
|
||||
lower.includes('fetch') ||
|
||||
lower.includes('http') ||
|
||||
lower.includes('browse') ||
|
||||
lower.includes('network') ||
|
||||
lower.includes('exa')
|
||||
) {
|
||||
return 'network'
|
||||
}
|
||||
if (lower.includes('read') || lower.includes('cat')) return 'read'
|
||||
if (lower.includes('grep') || lower.includes('search')) return 'search'
|
||||
if (lower.includes('glob') || lower.includes('find')) return 'list'
|
||||
if (lower.includes('think') || lower.includes('reason') || lower.includes('plan')) return 'think'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function isToolPartActive(part: ToolPart): boolean {
|
||||
return part.state?.status === 'running' || part.state?.status === 'pending'
|
||||
}
|
||||
@@ -946,43 +893,6 @@ function getTaskChildSessionId(part: ToolPart): string | undefined {
|
||||
return metadata?.sessionId as string | undefined
|
||||
}
|
||||
|
||||
/** 从 extractToolData 的结果计算 diff stats(当 metadata 没给 diffStats 时) */
|
||||
function computePartDiffStats(data: {
|
||||
diff?: { before: string; after: string } | string
|
||||
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
|
||||
}): { additions: number; deletions: number } | undefined {
|
||||
if (data.files?.length) {
|
||||
let a = 0,
|
||||
d = 0
|
||||
for (const f of data.files) {
|
||||
if (f.additions !== undefined) a += f.additions
|
||||
if (f.deletions !== undefined) d += f.deletions
|
||||
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
|
||||
const s = diffPairStats(f.before, f.after)
|
||||
a += s.additions
|
||||
d += s.deletions
|
||||
}
|
||||
}
|
||||
return a || d ? { additions: a, deletions: d } : undefined
|
||||
}
|
||||
if (data.diff && typeof data.diff === 'object') {
|
||||
const s = diffPairStats(data.diff.before, data.diff.after)
|
||||
return s.additions || s.deletions ? s : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function diffPairStats(before: string, after: string): { additions: number; deletions: number } {
|
||||
const changes = diffLines(before, after)
|
||||
let additions = 0,
|
||||
deletions = 0
|
||||
for (const c of changes) {
|
||||
if (c.added) additions += c.count || 0
|
||||
if (c.removed) deletions += c.count || 0
|
||||
}
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper: Group parts for rendering
|
||||
// ============================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { diffLines } from 'diff'
|
||||
import { computeDiffStats } from '../../../utils/diffStats'
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
|
||||
import type { ToolPart } from '../../../types/message'
|
||||
import { useDelayedRender } from '../../../hooks'
|
||||
@@ -203,7 +203,7 @@ export const ToolPartView = memo(function ToolPartView({
|
||||
if (descriptive) {
|
||||
const hasDiffFiles = !!toolData.files?.length
|
||||
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
|
||||
const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData)
|
||||
const diffStats = toolData.diffStats || computeDiffStats(toolData)
|
||||
|
||||
return (
|
||||
<div className="group py-0.5">
|
||||
@@ -451,47 +451,6 @@ function isReadableTool(toolName: string): boolean {
|
||||
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase())
|
||||
}
|
||||
|
||||
/** 从 diff 数据计算 diffStats(当 metadata 没给 diffStats 时用) */
|
||||
function computeDiffStatsFromData(data: {
|
||||
diff?: { before: string; after: string } | string
|
||||
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
|
||||
}): { additions: number; deletions: number } | undefined {
|
||||
// 多文件
|
||||
if (data.files?.length) {
|
||||
let additions = 0,
|
||||
deletions = 0
|
||||
for (const f of data.files) {
|
||||
if (f.additions !== undefined) additions += f.additions
|
||||
if (f.deletions !== undefined) deletions += f.deletions
|
||||
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
|
||||
const s = computeDiffPair(f.before, f.after)
|
||||
additions += s.additions
|
||||
deletions += s.deletions
|
||||
}
|
||||
}
|
||||
return additions || deletions ? { additions, deletions } : undefined
|
||||
}
|
||||
|
||||
// 单个 diff
|
||||
if (data.diff && typeof data.diff === 'object') {
|
||||
const s = computeDiffPair(data.diff.before, data.diff.after)
|
||||
return s.additions || s.deletions ? s : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function computeDiffPair(before: string, after: string): { additions: number; deletions: number } {
|
||||
const changes = diffLines(before, after)
|
||||
let additions = 0,
|
||||
deletions = 0
|
||||
for (const c of changes) {
|
||||
if (c.added) additions += c.count || 0
|
||||
if (c.removed) deletions += c.count || 0
|
||||
}
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ToolBody - 根据工具类型选择渲染器
|
||||
// ============================================
|
||||
|
||||
@@ -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 type { ToolPart } from '../../../types/message'
|
||||
import { defaultExtractData } from './registry'
|
||||
import { defaultExtractData, extractToolData } from './registry'
|
||||
|
||||
describe('defaultExtractData', () => {
|
||||
it('extracts files and diagnostics from metadata', () => {
|
||||
@@ -43,3 +43,140 @@ describe('defaultExtractData', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function makePart(state: Record<string, unknown> | undefined): ToolPart {
|
||||
return {
|
||||
type: 'tool',
|
||||
tool: 'custom',
|
||||
id: 'tool-1',
|
||||
callID: 'call-1',
|
||||
sessionID: 'session-1',
|
||||
messageID: 'message-1',
|
||||
state,
|
||||
} as unknown as ToolPart
|
||||
}
|
||||
|
||||
describe('defaultExtractData branches', () => {
|
||||
it('returns an empty result for a part without state', () => {
|
||||
expect(defaultExtractData(makePart(undefined))).toEqual({})
|
||||
})
|
||||
|
||||
it('serializes input as JSON only when it has keys', () => {
|
||||
expect(defaultExtractData(makePart({ input: { a: 1 } }))).toMatchObject({
|
||||
input: JSON.stringify({ a: 1 }, null, 2),
|
||||
inputLang: 'json',
|
||||
})
|
||||
expect(defaultExtractData(makePart({ input: {} })).input).toBeUndefined()
|
||||
})
|
||||
|
||||
it('extracts the error as a string', () => {
|
||||
expect(defaultExtractData(makePart({ error: 'boom' })).error).toBe('boom')
|
||||
})
|
||||
|
||||
it('prefers metadata.filepath over input.filePath and detects the output language', () => {
|
||||
const fromMetadata = defaultExtractData(
|
||||
makePart({ input: { filePath: 'b.py' }, metadata: { filepath: 'a.ts' }, output: 'x' }),
|
||||
)
|
||||
expect(fromMetadata.filePath).toBe('a.ts')
|
||||
expect(fromMetadata.outputLang).toBeTruthy()
|
||||
expect(defaultExtractData(makePart({ input: { filePath: 'b.py' } })).filePath).toBe('b.py')
|
||||
})
|
||||
|
||||
it('extracts a numeric exit code only', () => {
|
||||
expect(defaultExtractData(makePart({ metadata: { exit: 2 } })).exitCode).toBe(2)
|
||||
expect(defaultExtractData(makePart({ metadata: { exit: '2' } })).exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps metadata.files with the unknown fallback for the name', () => {
|
||||
const data = defaultExtractData(makePart({ metadata: { files: [{ file: 'f.ts' }, {}] } }))
|
||||
expect(data.files?.map(f => f.filePath)).toEqual(['f.ts', 'unknown'])
|
||||
})
|
||||
|
||||
it('uses a unified metadata.diff with filediff stats', () => {
|
||||
const data = defaultExtractData(
|
||||
makePart({ metadata: { diff: '@@', filediff: { additions: 3 } }, output: 'ignored' }),
|
||||
)
|
||||
expect(data.diff).toBe('@@')
|
||||
expect(data.diffStats).toEqual({ additions: 3, deletions: 0 })
|
||||
expect(data.output).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a unified metadata.diff without stats when filediff has no counters', () => {
|
||||
const data = defaultExtractData(makePart({ metadata: { diff: '@@', filediff: {} } }))
|
||||
expect(data.diffStats).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads filediff.patch, falling back to before/after', () => {
|
||||
const patch = defaultExtractData(makePart({ metadata: { filediff: { patch: 'P', additions: 1, deletions: 2 } } }))
|
||||
expect(patch.diff).toBe('P')
|
||||
expect(patch.diffStats).toEqual({ additions: 1, deletions: 2 })
|
||||
|
||||
const pair = defaultExtractData(makePart({ metadata: { filediff: { before: 'a', after: 'b' } } }))
|
||||
expect(pair.diff).toEqual({ before: 'a', after: 'b' })
|
||||
expect(pair.diffStats).toBeUndefined()
|
||||
|
||||
expect(defaultExtractData(makePart({ metadata: { filediff: { before: 'a' } } })).diff).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps only error and warning diagnostics and skips malformed entries', () => {
|
||||
const data = defaultExtractData(
|
||||
makePart({
|
||||
metadata: {
|
||||
diagnostics: {
|
||||
'C:\\proj\\a.ts': [
|
||||
{ severity: 2, message: 'warn' },
|
||||
{ severity: 3, message: 'info' },
|
||||
{ severity: 9, message: 'unknown severity' },
|
||||
{ message: 'no severity' },
|
||||
null,
|
||||
],
|
||||
'b.ts': 'not-an-array',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(data.diagnostics).toEqual([{ file: 'a.ts', severity: 'warning', message: 'warn', line: 0, column: 0 }])
|
||||
})
|
||||
|
||||
it('omits diagnostics when nothing is an error or warning', () => {
|
||||
const data = defaultExtractData(makePart({ metadata: { diagnostics: { 'a.ts': [{ severity: 4 }] } } }))
|
||||
expect(data.diagnostics).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses state.output, stringifying non-string values and inferring json', () => {
|
||||
expect(defaultExtractData(makePart({ output: 'plain' }))).toMatchObject({ output: 'plain' })
|
||||
expect(defaultExtractData(makePart({ output: 'plain' })).outputLang).toBeUndefined()
|
||||
expect(defaultExtractData(makePart({ output: ' {"a":1} ' })).outputLang).toBe('json')
|
||||
expect(defaultExtractData(makePart({ output: '[1]' })).outputLang).toBe('json')
|
||||
expect(defaultExtractData(makePart({ output: { a: 1 } })).output).toBe(JSON.stringify({ a: 1 }, null, 2))
|
||||
})
|
||||
|
||||
it('falls back to the interrupted metadata output only when interrupted', () => {
|
||||
expect(defaultExtractData(makePart({ metadata: { interrupted: true, output: 'partial' } })).output).toBe('partial')
|
||||
expect(defaultExtractData(makePart({ metadata: { output: 'partial' } })).output).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not set output when files are present', () => {
|
||||
const data = defaultExtractData(makePart({ output: 'x', metadata: { files: [{ filePath: 'a' }] } }))
|
||||
expect(data.output).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractToolData for bash', () => {
|
||||
const bash = (state: Record<string, unknown>) => extractToolData({ ...makePart(state), tool: 'bash' } as ToolPart)
|
||||
|
||||
it('shows the command as bash input and trims the working directory', () => {
|
||||
const data = bash({ input: { command: 'ls -la', workdir: ' /tmp ' } })
|
||||
expect(data).toMatchObject({ input: 'ls -la', inputLang: 'bash', cwd: '/tmp' })
|
||||
})
|
||||
|
||||
it('falls back to metadata cwd and ignores blank or non-string values', () => {
|
||||
expect(bash({ input: { command: 'pwd' }, metadata: { cwd: '/srv' } }).cwd).toBe('/srv')
|
||||
expect(bash({ input: { command: 'pwd', workdir: ' ' } }).cwd).toBeUndefined()
|
||||
expect(bash({ input: { command: 'pwd', cwd: 5 } }).cwd).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the JSON input when there is no command', () => {
|
||||
expect(bash({ input: { a: 1 } }).inputLang).toBe('json')
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,156 @@ interface MetadataDiagnosticEntry {
|
||||
// Default Data Extractor
|
||||
// ============================================
|
||||
|
||||
type DiffStats = { additions: number; deletions: number }
|
||||
|
||||
type MetadataFileDiff = {
|
||||
patch?: string
|
||||
before?: string
|
||||
after?: string
|
||||
additions?: number
|
||||
deletions?: number
|
||||
}
|
||||
|
||||
function toDiffStats(fd: { additions?: number; deletions?: number }): DiffStats | undefined {
|
||||
if (fd.additions === undefined && fd.deletions === undefined) return undefined
|
||||
return { additions: fd.additions || 0, deletions: fd.deletions || 0 }
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object'
|
||||
}
|
||||
|
||||
function toFileEntries(files: MetadataFileEntry[]): NonNullable<ExtractedToolData['files']> {
|
||||
return files.map(file => ({
|
||||
filePath: file.filePath || file.file || 'unknown',
|
||||
diff: file.diff,
|
||||
patch: file.patch,
|
||||
before: file.before,
|
||||
after: file.after,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 上游 v1.4.0+ metadata.filediff 用 patch 格式;否则回退到 before/after */
|
||||
function diffFromFileDiff(filediff: MetadataFileDiff): ExtractedToolData['diff'] {
|
||||
if (filediff.patch) return filediff.patch
|
||||
if (filediff.before !== undefined && filediff.after !== undefined) {
|
||||
return { before: filediff.before, after: filediff.after }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Extracts files / diff / diffStats from tool metadata (files > unified diff > filediff). */
|
||||
function extractDiffData(metadata: Record<string, unknown>): Pick<ExtractedToolData, 'files' | 'diff' | 'diffStats'> {
|
||||
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
|
||||
return { files: toFileEntries(metadata.files as MetadataFileEntry[]) }
|
||||
}
|
||||
|
||||
const filediff = isObject(metadata.filediff) ? (metadata.filediff as MetadataFileDiff) : undefined
|
||||
const diffStats = filediff && toDiffStats(filediff)
|
||||
|
||||
// 优先使用 unified diff,统计取自 filediff
|
||||
if (typeof metadata.diff === 'string') return { diff: metadata.diff, diffStats }
|
||||
if (!filediff) return {}
|
||||
return { diff: diffFromFileDiff(filediff), diffStats }
|
||||
}
|
||||
|
||||
// severity: 1=error, 2=warning, 3=info, 4=hint
|
||||
const DIAGNOSTIC_SEVERITIES: Record<number, DiagnosticInfo['severity']> = {
|
||||
1: 'error',
|
||||
2: 'warning',
|
||||
3: 'info',
|
||||
4: 'hint',
|
||||
}
|
||||
|
||||
function toSeverity(value: unknown): DiagnosticInfo['severity'] {
|
||||
return (typeof value === 'number' && DIAGNOSTIC_SEVERITIES[value]) || 'info'
|
||||
}
|
||||
|
||||
function toDiagnostic(file: string, item: MetadataDiagnosticEntry): DiagnosticInfo {
|
||||
const start = item.range?.start
|
||||
return {
|
||||
file: file.split(/[/\\]/).pop() || file,
|
||||
severity: toSeverity(item.severity),
|
||||
message: item.message || '',
|
||||
line: start?.line ?? 0,
|
||||
column: start?.character ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts only the error/warning diagnostics from tool metadata. */
|
||||
function extractDiagnostics(raw: unknown): DiagnosticInfo[] | undefined {
|
||||
if (!isObject(raw)) return undefined
|
||||
|
||||
const diagnostics: DiagnosticInfo[] = []
|
||||
for (const [file, items] of Object.entries(raw as Record<string, MetadataDiagnosticEntry[]>)) {
|
||||
if (!Array.isArray(items)) continue
|
||||
for (const item of items) {
|
||||
if (isObject(item)) diagnostics.push(toDiagnostic(file, item))
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留 error 和 warning
|
||||
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
|
||||
return filtered.length > 0 ? filtered : undefined
|
||||
}
|
||||
|
||||
function looksLikeJson(text: string): boolean {
|
||||
const trimmed = text.trim()
|
||||
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
}
|
||||
|
||||
function extractFilePath(
|
||||
inputObj: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
if (typeof metadata?.filepath === 'string' && metadata.filepath) return metadata.filepath
|
||||
return inputObj?.filePath ? String(inputObj.filePath) : undefined
|
||||
}
|
||||
|
||||
/** Tool output (or the output kept by an interrupted run), stringified. */
|
||||
function extractOutputText(
|
||||
state: ToolPart['state'] | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const stateOutput = state && 'output' in state ? state.output : undefined
|
||||
const interrupted =
|
||||
metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
|
||||
const output = stateOutput ?? interrupted
|
||||
if (!output) return undefined
|
||||
return typeof output === 'string' ? output : JSON.stringify(output, null, 2)
|
||||
}
|
||||
|
||||
/** Fields derived from `metadata`: exit code, files / diff / diffStats and diagnostics. */
|
||||
function extractMetadataFields(metadata: Record<string, unknown> | undefined): ExtractedToolData {
|
||||
if (!metadata) return {}
|
||||
|
||||
const result: ExtractedToolData = {}
|
||||
if (typeof metadata.exit === 'number') result.exitCode = metadata.exit
|
||||
|
||||
const diffData = extractDiffData(metadata)
|
||||
if (diffData.files) result.files = diffData.files
|
||||
if (diffData.diff !== undefined) result.diff = diffData.diff
|
||||
if (diffData.diffStats) result.diffStats = diffData.diffStats
|
||||
|
||||
const diagnostics = extractDiagnostics(metadata.diagnostics)
|
||||
if (diagnostics) result.diagnostics = diagnostics
|
||||
return result
|
||||
}
|
||||
|
||||
/** Output text plus its language (inferred as json when it looks like a JSON document). */
|
||||
function extractOutputFields(
|
||||
result: ExtractedToolData,
|
||||
state: ToolPart['state'] | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): ExtractedToolData {
|
||||
const output = extractOutputText(state, metadata)
|
||||
if (result.files || result.diff || !output) return {}
|
||||
if (!result.outputLang && looksLikeJson(output)) return { output, outputLang: 'json' }
|
||||
return { output }
|
||||
}
|
||||
|
||||
export function defaultExtractData(part: ToolPart): ExtractedToolData {
|
||||
const { state } = part
|
||||
const inputObj = state?.input as Record<string, unknown> | undefined
|
||||
@@ -78,120 +228,15 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
|
||||
result.error = String(state.error)
|
||||
}
|
||||
|
||||
// FilePath
|
||||
if (metadata && typeof metadata.filepath === 'string') {
|
||||
result.filePath = metadata.filepath
|
||||
}
|
||||
if (!result.filePath && inputObj?.filePath) {
|
||||
result.filePath = String(inputObj.filePath)
|
||||
// FilePath (+ output language derived from it)
|
||||
const filePath = extractFilePath(inputObj, metadata)
|
||||
if (filePath) {
|
||||
result.filePath = filePath
|
||||
result.outputLang = detectLanguage(filePath)
|
||||
}
|
||||
|
||||
// Exit code
|
||||
if (metadata && typeof metadata.exit === 'number') {
|
||||
result.exitCode = metadata.exit
|
||||
}
|
||||
|
||||
// Diff / Files (from metadata)
|
||||
if (metadata) {
|
||||
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
|
||||
result.files = (metadata.files as MetadataFileEntry[]).map(file => ({
|
||||
filePath: file.filePath || file.file || 'unknown',
|
||||
diff: file.diff,
|
||||
patch: file.patch,
|
||||
before: file.before,
|
||||
after: file.after,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
}))
|
||||
} else if (typeof metadata.diff === 'string') {
|
||||
// 优先使用 unified diff
|
||||
result.diff = metadata.diff
|
||||
// 从 filediff 获取统计
|
||||
if (metadata.filediff && typeof metadata.filediff === 'object') {
|
||||
const fd = metadata.filediff as { additions?: number; deletions?: number }
|
||||
if (fd.additions !== undefined || fd.deletions !== undefined) {
|
||||
result.diffStats = {
|
||||
additions: fd.additions || 0,
|
||||
deletions: fd.deletions || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (metadata.filediff && typeof metadata.filediff === 'object') {
|
||||
const fd = metadata.filediff as {
|
||||
patch?: string
|
||||
before?: string
|
||||
after?: string
|
||||
additions?: number
|
||||
deletions?: number
|
||||
}
|
||||
// 上游 v1.4.0+ metadata.filediff 用 patch 格式
|
||||
if (fd.patch) {
|
||||
result.diff = fd.patch
|
||||
} else if (fd.before !== undefined && fd.after !== undefined) {
|
||||
result.diff = { before: fd.before, after: fd.after }
|
||||
}
|
||||
if (fd.additions !== undefined || fd.deletions !== undefined) {
|
||||
result.diffStats = {
|
||||
additions: fd.additions || 0,
|
||||
deletions: fd.deletions || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 diagnostics
|
||||
if (metadata.diagnostics && typeof metadata.diagnostics === 'object') {
|
||||
const diagMap = metadata.diagnostics as Record<string, MetadataDiagnosticEntry[]>
|
||||
const diagnostics: DiagnosticInfo[] = []
|
||||
|
||||
for (const [file, items] of Object.entries(diagMap)) {
|
||||
if (!Array.isArray(items)) continue
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
// severity: 1=error, 2=warning, 3=info, 4=hint
|
||||
const severityMap: Record<number, DiagnosticInfo['severity']> = {
|
||||
1: 'error',
|
||||
2: 'warning',
|
||||
3: 'info',
|
||||
4: 'hint',
|
||||
}
|
||||
diagnostics.push({
|
||||
file: file.split(/[/\\]/).pop() || file,
|
||||
severity: typeof item.severity === 'number' ? (severityMap[item.severity] ?? 'info') : 'info',
|
||||
message: item.message || '',
|
||||
line: item.range?.start?.line ?? 0,
|
||||
column: item.range?.start?.character ?? 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留 error 和 warning
|
||||
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
|
||||
if (filtered.length > 0) {
|
||||
result.diagnostics = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output language from filePath
|
||||
if (result.filePath) {
|
||||
result.outputLang = detectLanguage(result.filePath)
|
||||
}
|
||||
|
||||
// Output
|
||||
const stateOutput = state && 'output' in state ? state.output : undefined
|
||||
const interruptedOutput = metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
|
||||
const output = stateOutput ?? interruptedOutput
|
||||
if (!result.files && !result.diff && output) {
|
||||
result.output = typeof output === 'string' ? output : JSON.stringify(output, null, 2)
|
||||
|
||||
// 推断语言
|
||||
if (!result.outputLang && result.output) {
|
||||
const trimmed = result.output.trim()
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
result.outputLang = 'json'
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.assign(result, extractMetadataFields(metadata))
|
||||
Object.assign(result, extractOutputFields(result, state, metadata))
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -200,6 +245,14 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
|
||||
// Tool-Specific Data Extractors
|
||||
// ============================================
|
||||
|
||||
function resolveCwd(
|
||||
inputObj: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
|
||||
return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined
|
||||
}
|
||||
|
||||
function bashExtractData(part: ToolPart): ExtractedToolData {
|
||||
const base = defaultExtractData(part)
|
||||
const inputObj = part.state?.input as Record<string, unknown> | undefined
|
||||
@@ -210,10 +263,8 @@ function bashExtractData(part: ToolPart): ExtractedToolData {
|
||||
base.inputLang = 'bash'
|
||||
}
|
||||
|
||||
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
|
||||
if (typeof cwd === 'string' && cwd.trim()) {
|
||||
base.cwd = cwd.trim()
|
||||
}
|
||||
const cwd = resolveCwd(inputObj, metadata)
|
||||
if (cwd) base.cwd = cwd
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { version: 1, directories: {} }
|
||||
}
|
||||
function sanitizeIdList(raw: unknown): string[] {
|
||||
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
|
||||
}
|
||||
|
||||
const data = raw as Partial<PersistedTerminalLayoutMap>
|
||||
if (data.version !== 1 || !data.directories || typeof data.directories !== 'object') {
|
||||
function sanitizeActiveTabId(raw: { bottom?: unknown; right?: unknown }): Record<PanelPosition, string | null> {
|
||||
return {
|
||||
bottom: typeof raw.bottom === 'string' ? raw.bottom : null,
|
||||
right: typeof raw.right === 'string' ? raw.right : null,
|
||||
}
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
function sanitizeTerminalSessionState(raw: Partial<PersistedTerminalSessionState>): PersistedTerminalSessionState {
|
||||
return {
|
||||
title: optionalString(raw.title),
|
||||
shellTitle: optionalString(raw.shellTitle),
|
||||
customTitle: optionalString(raw.customTitle),
|
||||
buffer: optionalString(raw.buffer),
|
||||
scrollY: optionalNumber(raw.scrollY),
|
||||
cursor: optionalNumber(raw.cursor),
|
||||
rows: optionalNumber(raw.rows),
|
||||
cols: optionalNumber(raw.cols),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeTerminalSessions(raw: unknown): Record<string, PersistedTerminalSessionState> {
|
||||
const sessions: Record<string, PersistedTerminalSessionState> = {}
|
||||
if (!raw || typeof raw !== 'object') return sessions
|
||||
|
||||
for (const [id, session] of Object.entries(raw)) {
|
||||
if (!id || !session || typeof session !== 'object') continue
|
||||
sessions[id] = sanitizeTerminalSessionState(session as Partial<PersistedTerminalSessionState>)
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
function sanitizeTerminalDirectoryLayout(raw: unknown): PersistedTerminalDirectoryLayout | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
|
||||
const entry = raw as Partial<PersistedTerminalDirectoryLayout>
|
||||
const { order, activeTabId } = entry
|
||||
if (!order || typeof order !== 'object' || !activeTabId || typeof activeTabId !== 'object') return null
|
||||
|
||||
return {
|
||||
order: { bottom: sanitizeIdList(order.bottom), right: sanitizeIdList(order.right) },
|
||||
activeTabId: sanitizeActiveTabId(activeTabId),
|
||||
sessions: sanitizeTerminalSessions(entry.sessions),
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
|
||||
const data = raw && typeof raw === 'object' ? (raw as Partial<PersistedTerminalLayoutMap>) : undefined
|
||||
if (data?.version !== 1 || !data.directories || typeof data.directories !== 'object') {
|
||||
return { version: 1, directories: {} }
|
||||
}
|
||||
|
||||
const directories: Record<string, PersistedTerminalDirectoryLayout> = {}
|
||||
|
||||
for (const [directory, value] of Object.entries(data.directories)) {
|
||||
if (!directory || !value || typeof value !== 'object') continue
|
||||
const entry = value as Partial<PersistedTerminalDirectoryLayout>
|
||||
const rawOrder = entry.order
|
||||
const rawActiveTabId = entry.activeTabId
|
||||
if (!rawOrder || typeof rawOrder !== 'object' || !rawActiveTabId || typeof rawActiveTabId !== 'object') continue
|
||||
|
||||
const order = {
|
||||
bottom: Array.isArray(rawOrder.bottom)
|
||||
? rawOrder.bottom.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
: [],
|
||||
right: Array.isArray(rawOrder.right)
|
||||
? rawOrder.right.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
: [],
|
||||
}
|
||||
|
||||
const sessions: Record<string, PersistedTerminalSessionState> = {}
|
||||
const rawSessions = entry.sessions
|
||||
if (rawSessions && typeof rawSessions === 'object') {
|
||||
for (const [id, session] of Object.entries(rawSessions)) {
|
||||
if (!id || !session || typeof session !== 'object') continue
|
||||
const data = session as Partial<PersistedTerminalSessionState>
|
||||
sessions[id] = {
|
||||
title: typeof data.title === 'string' ? data.title : undefined,
|
||||
shellTitle: typeof data.shellTitle === 'string' ? data.shellTitle : undefined,
|
||||
customTitle: typeof data.customTitle === 'string' ? data.customTitle : undefined,
|
||||
buffer: typeof data.buffer === 'string' ? data.buffer : undefined,
|
||||
scrollY: typeof data.scrollY === 'number' ? data.scrollY : undefined,
|
||||
cursor: typeof data.cursor === 'number' ? data.cursor : undefined,
|
||||
rows: typeof data.rows === 'number' ? data.rows : undefined,
|
||||
cols: typeof data.cols === 'number' ? data.cols : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
directories[directory] = {
|
||||
order,
|
||||
activeTabId: {
|
||||
bottom: typeof rawActiveTabId.bottom === 'string' ? rawActiveTabId.bottom : null,
|
||||
right: typeof rawActiveTabId.right === 'string' ? rawActiveTabId.right : null,
|
||||
},
|
||||
sessions,
|
||||
}
|
||||
const layout = directory ? sanitizeTerminalDirectoryLayout(value) : null
|
||||
if (layout) directories[directory] = layout
|
||||
}
|
||||
|
||||
return { version: 1, directories }
|
||||
|
||||
@@ -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