diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index b255793..ed202ec 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useRef } from 'react' +import { getErrorMessage } from '../../utils/errors' import { useTranslation } from 'react-i18next' import { useAppStore, useAgentStore, useWebSearchStore, usePetStore } from '../../stores' import { useSkillStore } from '../../stores/skillStore' @@ -99,7 +100,7 @@ export function ChatView() { }) await Pi.sendMcpToolResponse(piSessionId, request.requestId, result) } catch (err) { - await Pi.sendMcpToolResponse(piSessionId, request.requestId, null, err instanceof Error ? err.message : String(err)) + await Pi.sendMcpToolResponse(piSessionId, request.requestId, null, getErrorMessage(err)) } }, onToolApproval: (request) => { diff --git a/src/components/chat/shared/CodeRunBlock.tsx b/src/components/chat/shared/CodeRunBlock.tsx index 8fd1b25..97c1e5a 100644 --- a/src/components/chat/shared/CodeRunBlock.tsx +++ b/src/components/chat/shared/CodeRunBlock.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react' +import { getErrorMessage } from '../../../utils/errors' import { useTranslation } from 'react-i18next' import { Play, Square, Terminal, Copy, Check, AlertCircle } from 'lucide-react' @@ -47,7 +48,7 @@ export function CodeRunBlock({ code, language }: CodeRunBlockProps) { setOutput(outputLines.join('\n') || '(no output)') } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + setError(getErrorMessage(err)) } finally { setRunning(false) } diff --git a/src/components/chat/shared/MermaidBlock.tsx b/src/components/chat/shared/MermaidBlock.tsx index 43903a6..527a2bf 100644 --- a/src/components/chat/shared/MermaidBlock.tsx +++ b/src/components/chat/shared/MermaidBlock.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { getErrorMessage } from '../../../utils/errors' interface MermaidBlockProps { code: string @@ -29,7 +30,7 @@ export function MermaidBlock({ code }: MermaidBlockProps) { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to render mermaid diagram') + setError(getErrorMessage(err, 'Failed to render mermaid diagram')) setRendered(false) } } diff --git a/src/components/mcp/MCPSettings.tsx b/src/components/mcp/MCPSettings.tsx index 687be47..3f94352 100644 --- a/src/components/mcp/MCPSettings.tsx +++ b/src/components/mcp/MCPSettings.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { getErrorMessage } from '../../utils/errors' import { useTranslation } from 'react-i18next'; import { Server, @@ -374,7 +375,7 @@ function AddMCPServerModal({ onClose }: AddMCPServerModalProps) { } onClose(); } catch (err) { - setJsonError(err instanceof Error ? err.message : t('mcp.invalidJson')); + setJsonError(getErrorMessage(err, t('mcp.invalidJson'))); } return; } diff --git a/src/components/platforms/PlatformCard.tsx b/src/components/platforms/PlatformCard.tsx index d4c0728..a08125c 100644 --- a/src/components/platforms/PlatformCard.tsx +++ b/src/components/platforms/PlatformCard.tsx @@ -5,6 +5,7 @@ import { Send, Check, Loader2, Link, Link2Off, ChevronDown } from 'lucide-react' import { MagicButton } from '../ui' import type { PlatformConfig } from '../../types/platform' import { getBaseUrl } from '../../utils/piClient' +import { jsonRequest } from '../../utils/http' interface ContactEntry { key: string @@ -345,15 +346,11 @@ function DetectChatIdButton({ platform, onDetected }: { platform: PlatformConfig const username = msg.from?.username || msg.from?.first_name || '' // Reply to the user with their chat ID - await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: chatId, - text: `Your Chat ID: \`${chatId}\`\nUsername: @${username}\n\nCopy this Chat ID into S-Loop platform settings.`, - parse_mode: 'Markdown', - }), - }) + await fetch(`https://api.telegram.org/bot${token}/sendMessage`, jsonRequest({ + chat_id: chatId, + text: `Your Chat ID: \`${chatId}\`\nUsername: @${username}\n\nCopy this Chat ID into S-Loop platform settings.`, + parse_mode: 'Markdown', + })) onDetected(chatId) setMsg(`Found: ${chatId} (@${username}). Sent confirmation to Telegram.`) diff --git a/src/components/preview/DocxPreview.tsx b/src/components/preview/DocxPreview.tsx index 4968f9f..9182807 100644 --- a/src/components/preview/DocxPreview.tsx +++ b/src/components/preview/DocxPreview.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { getErrorMessage } from '../../utils/errors' interface DocxPreviewProps { filePath: string @@ -31,7 +32,7 @@ export function DocxPreview({ filePath, onLoaded, onError }: DocxPreviewProps) { } } catch (err) { if (!cancelled) { - onError(err instanceof Error ? err.message : String(err)) + onError(getErrorMessage(err)) } } } diff --git a/src/components/preview/ExcelPreview.tsx b/src/components/preview/ExcelPreview.tsx index e612c2d..fe92d14 100644 --- a/src/components/preview/ExcelPreview.tsx +++ b/src/components/preview/ExcelPreview.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { getErrorMessage } from '../../utils/errors' import * as XLSX from 'xlsx' interface ExcelPreviewProps { @@ -46,7 +47,7 @@ export function ExcelPreview({ filePath, onLoaded, onError }: ExcelPreviewProps) } } catch (err) { if (!cancelled) { - onError(err instanceof Error ? err.message : String(err)) + onError(getErrorMessage(err)) } } } diff --git a/src/components/preview/MarkdownPreview.tsx b/src/components/preview/MarkdownPreview.tsx index 4c7c422..0d4971d 100644 --- a/src/components/preview/MarkdownPreview.tsx +++ b/src/components/preview/MarkdownPreview.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { getErrorMessage } from '../../utils/errors' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' @@ -28,7 +29,7 @@ export function MarkdownPreview({ filePath, onLoaded, onError }: MarkdownPreview } } catch (err) { if (!cancelled) { - onError(err instanceof Error ? err.message : String(err)) + onError(getErrorMessage(err)) } } } diff --git a/src/components/preview/PdfPreview.tsx b/src/components/preview/PdfPreview.tsx index f397e6f..33d5f49 100644 --- a/src/components/preview/PdfPreview.tsx +++ b/src/components/preview/PdfPreview.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef } from 'react' +import { getErrorMessage } from '../../utils/errors' interface PdfPreviewProps { filePath: string @@ -40,7 +41,7 @@ export function PdfPreview({ filePath, onLoaded, onError }: PdfPreviewProps) { if (!cancelled) onLoaded() } catch (err) { if (!cancelled) { - onError(err instanceof Error ? err.message : String(err)) + onError(getErrorMessage(err)) } } } diff --git a/src/components/preview/PptxPreview.tsx b/src/components/preview/PptxPreview.tsx index 42c0111..d77e81d 100644 --- a/src/components/preview/PptxPreview.tsx +++ b/src/components/preview/PptxPreview.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { getErrorMessage } from '../../utils/errors' interface PptxPreviewProps { filePath: string @@ -241,7 +242,7 @@ export function PptxPreview({ filePath, onLoaded, onError }: PptxPreviewProps) { } catch (err) { if (!cancelled) { setLoading(false) - onError(err instanceof Error ? err.message : String(err)) + onError(getErrorMessage(err)) } } } diff --git a/src/components/preview/TextPreview.tsx b/src/components/preview/TextPreview.tsx index 404eb09..123e48c 100644 --- a/src/components/preview/TextPreview.tsx +++ b/src/components/preview/TextPreview.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { getErrorMessage } from '../../utils/errors' import CodeMirror from '@uiw/react-codemirror' import { oneDark } from '@codemirror/theme-one-dark' import type { Extension } from '@codemirror/state' @@ -58,7 +59,7 @@ export function TextPreview({ filePath, onLoaded, onError }: TextPreviewProps) { } } catch (err) { if (!cancelled) { - onError(err instanceof Error ? err.message : String(err)) + onError(getErrorMessage(err)) } } } diff --git a/src/components/skills/SkillDropZone.tsx b/src/components/skills/SkillDropZone.tsx index f9d28db..f32f348 100644 --- a/src/components/skills/SkillDropZone.tsx +++ b/src/components/skills/SkillDropZone.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react' +import { getErrorMessage } from '../../utils/errors' import { useTranslation } from 'react-i18next' import { useSkillStore } from '../../stores/skillStore' import { FileArchive, Loader2, Check, AlertCircle } from 'lucide-react' @@ -61,7 +62,7 @@ export function SkillDropZone() { setTimeout(reset, 2000) } catch (err) { setStatus('error') - setStatusText(err instanceof Error ? err.message : t('skills.zipParseError')) + setStatusText(getErrorMessage(err, t('skills.zipParseError'))) setTimeout(reset, 3000) } }, [t, installSkillZip, reset]) diff --git a/src/components/skills/SkillSettings.tsx b/src/components/skills/SkillSettings.tsx index fd9791e..4ecb209 100644 --- a/src/components/skills/SkillSettings.tsx +++ b/src/components/skills/SkillSettings.tsx @@ -1,4 +1,5 @@ import { useState, useRef } from 'react'; +import { getErrorMessage } from '../../utils/errors' import { useTranslation } from 'react-i18next'; import { open } from '@tauri-apps/plugin-dialog'; import { @@ -128,7 +129,7 @@ export function SkillSettings() { setDropInstalled(true); setTimeout(() => setDropInstalled(false), 2000); } catch (err) { - setDropError(err instanceof Error ? err.message : t('skills.zipParseError')); + setDropError(getErrorMessage(err, t('skills.zipParseError'))); } finally { setDropInstalling(false); } diff --git a/src/components/tasks/CreateTaskModal.tsx b/src/components/tasks/CreateTaskModal.tsx index 0a5159a..fc7bf97 100644 --- a/src/components/tasks/CreateTaskModal.tsx +++ b/src/components/tasks/CreateTaskModal.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { getErrorMessage } from '../../utils/errors' import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useTaskStore, useAppStore } from '../../stores'; @@ -95,7 +96,7 @@ export function CreateTaskModal({ onClose }: CreateTaskModalProps) { await createTask({ name: name.trim(), prompt: prompt.trim(), schedule, provider: activeProvider, model: providerConfigs[activeProvider]?.model || '', apiKey: providerConfigs[activeProvider]?.apiKey || '', workspaceDir: workspaceDir || undefined, deliver, enabled: true }); onClose(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(getErrorMessage(err)); } finally { setSaving(false); } }; diff --git a/src/components/workspace/WorkspacePanel.tsx b/src/components/workspace/WorkspacePanel.tsx index 4f14cc2..549505c 100644 --- a/src/components/workspace/WorkspacePanel.tsx +++ b/src/components/workspace/WorkspacePanel.tsx @@ -1,4 +1,5 @@ import { useState, type ReactElement, type ReactNode } from 'react' +import { getErrorMessage } from '../../utils/errors' import { useTranslation } from 'react-i18next' import { ChevronRight, @@ -196,7 +197,7 @@ export function WorkspacePanel() { setFeedback(result.message) } } catch (error) { - setFeedback(error instanceof Error ? error.message : t('agentStudio.feedback.remoteComingSoon')) + setFeedback(getErrorMessage(error, t('agentStudio.feedback.remoteComingSoon'))) } finally { setBusyInstallKey(null) } @@ -219,7 +220,7 @@ export function WorkspacePanel() { }))) } catch (error) { setRemoteSkills([]) - setRemoteSkillsError(error instanceof Error ? error.message : t('agentStudio.library.remoteSearchError')) + setRemoteSkillsError(getErrorMessage(error, t('agentStudio.library.remoteSearchError'))) } finally { setRemoteSkillsLoading(false) } @@ -245,7 +246,7 @@ export function WorkspacePanel() { setFeedback(t('agentStudio.feedback.remoteInstalled', { name: item.name })) setShowMcpPicker(false) } catch (error) { - setFeedback(error instanceof Error ? error.message : t('agentStudio.feedback.remoteComingSoon')) + setFeedback(getErrorMessage(error, t('agentStudio.feedback.remoteComingSoon'))) } finally { setBusyInstallKey(null) } diff --git a/src/stores/goalStore.ts b/src/stores/goalStore.ts index fe0bac6..7cd91da 100644 --- a/src/stores/goalStore.ts +++ b/src/stores/goalStore.ts @@ -1,5 +1,8 @@ import { create } from 'zustand' import { getBaseUrl } from '../utils/piClient' +import { getErrorMessage } from '../utils/errors' +import { jsonRequest } from '../utils/http' +import { readSSEStream } from '../utils/sse' import type { GoalState, GoalSSEEvent, GoalStep } from '../types/goal' interface GoalStoreState { @@ -38,23 +41,19 @@ export const useGoalStore = create((set, get) => ({ const goals = await res.json() set({ goals, loading: false }) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err), loading: false }) + set({ error: getErrorMessage(err), loading: false }) } }, createGoal: async (goal) => { try { - const res = await fetch(`${BASE()}/goals/create`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ goal }), - }) + const res = await fetch(`${BASE()}/goals/create`, jsonRequest({ goal })) if (!res.ok) throw new Error(`HTTP ${res.status}`) const created = await res.json() set((s) => ({ goals: [created, ...s.goals] })) return created } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) return null } }, @@ -64,7 +63,7 @@ export const useGoalStore = create((set, get) => ({ await fetch(`${BASE()}/goals/${encodeURIComponent(id)}`, { method: 'DELETE' }) set((s) => ({ goals: s.goals.filter((g) => g.id !== id) })) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) } }, @@ -99,95 +98,63 @@ export const useGoalStore = create((set, get) => ({ return } - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - - let lineEnd = buffer.indexOf('\n') - while (lineEnd !== -1) { - const line = buffer.slice(0, lineEnd) - buffer = buffer.slice(lineEnd + 1) - lineEnd = buffer.indexOf('\n') - - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith(':')) continue - - if (trimmed.startsWith('event: ')) { - const eventType = trimmed.slice(7) - const nextEnd = buffer.indexOf('\n') - const dataLine = nextEnd === -1 ? buffer.trim() : buffer.slice(0, nextEnd).trim() - if (dataLine.startsWith('data: ')) { - try { - const data = JSON.parse(dataLine.slice(6)) - if (eventType === 'goal_event') { - set((s) => ({ liveEvents: [...s.liveEvents, data] })) - - if (data.type === 'goal_step_start') { - set((s) => { - if (!s.activeGoal) return s - const newStep: GoalStep = { - agent: data.agent, - task: data.task, - status: 'running', - } - return { - activeGoal: { - ...s.activeGoal, - steps: [...s.activeGoal.steps, newStep], - }, - } - }) - } else if (data.type === 'goal_step_end') { - set((s) => { - if (!s.activeGoal) return s - const steps = s.activeGoal.steps.map((step, i) => - i === data.stepIndex - ? { ...step, status: data.result?.exitCode === 0 ? 'completed' as const : 'failed' as const, result: data.result } - : step - ) as GoalStep[] - return { activeGoal: { ...s.activeGoal, steps } } - }) - } else if (data.type === 'goal_done') { - if (data.goalState) { - set(() => ({ - activeGoal: data.goalState, - isRunning: false, - })) - } else { - set((s) => ({ - activeGoal: s.activeGoal ? { ...s.activeGoal, status: 'completed' as const } : null, - isRunning: false, - })) - } - get().fetchGoals() - } else if (data.type === 'goal_error') { - set((s) => ({ - error: data.message, - isRunning: false, - activeGoal: s.activeGoal ? { ...s.activeGoal, status: 'failed' as const, finalResult: data.message } : null, - })) - } - } else if (eventType === 'done') { - set({ isRunning: false, abortFn: null }) - return - } - } catch { /* skip */ } - } - if (nextEnd !== -1) { - buffer = buffer.slice(nextEnd + 1) - lineEnd = buffer.indexOf('\n') + await readSSEStream(reader, (eventType, data) => { + if (eventType === 'goal_event') { + set((s) => ({ liveEvents: [...s.liveEvents, data] })) + + if (data.type === 'goal_step_start') { + set((s) => { + if (!s.activeGoal) return s + const newStep: GoalStep = { + agent: data.agent, + task: data.task, + status: 'running', + } + return { + activeGoal: { + ...s.activeGoal, + steps: [...s.activeGoal.steps, newStep], + }, + } + }) + } else if (data.type === 'goal_step_end') { + set((s) => { + if (!s.activeGoal) return s + const steps = s.activeGoal.steps.map((step, i) => + i === data.stepIndex + ? { ...step, status: data.result?.exitCode === 0 ? 'completed' as const : 'failed' as const, result: data.result } + : step + ) as GoalStep[] + return { activeGoal: { ...s.activeGoal, steps } } + }) + } else if (data.type === 'goal_done') { + if (data.goalState) { + set(() => ({ + activeGoal: data.goalState, + isRunning: false, + })) + } else { + set((s) => ({ + activeGoal: s.activeGoal ? { ...s.activeGoal, status: 'completed' as const } : null, + isRunning: false, + })) } + get().fetchGoals() + } else if (data.type === 'goal_error') { + set((s) => ({ + error: data.message, + isRunning: false, + activeGoal: s.activeGoal ? { ...s.activeGoal, status: 'failed' as const, finalResult: data.message } : null, + })) } + } else if (eventType === 'done') { + set({ isRunning: false, abortFn: null }) + return true } - } + }) } catch (err) { if ((err as any)?.name !== 'AbortError') { - set({ error: err instanceof Error ? err.message : String(err), isRunning: false }) + set({ error: getErrorMessage(err), isRunning: false }) } } }, diff --git a/src/stores/mcpStore.ts b/src/stores/mcpStore.ts index bd6e015..d6c3e5e 100644 --- a/src/stores/mcpStore.ts +++ b/src/stores/mcpStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { getErrorMessage } from '../utils/errors' import { persist } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import type { MCPServerConfig, MCPServerStatus, MCPTool } from '../types/mcp'; @@ -160,7 +161,7 @@ export const useMCPStore = create()( } catch (error) { get().setServerStatus(name, { status: 'error', - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), tools: [], resources: [], }); diff --git a/src/stores/platformStore.ts b/src/stores/platformStore.ts index ad35b23..d14a888 100644 --- a/src/stores/platformStore.ts +++ b/src/stores/platformStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { getErrorMessage } from '../utils/errors' import type { PlatformConfig, PlatformId, PlatformMessage } from '../types/platform' import { PLATFORM_PRESETS } from '../types/platform' import { @@ -41,7 +42,7 @@ export const usePlatformStore = create()((set, get) => ({ error: null, }) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) } }, @@ -70,7 +71,7 @@ export const usePlatformStore = create()((set, get) => ({ } catch (err) { set((state) => ({ isConnecting: { ...state.isConnecting, [id]: false }, - error: err instanceof Error ? err.message : String(err), + error: getErrorMessage(err), })) } }, @@ -84,7 +85,7 @@ export const usePlatformStore = create()((set, get) => ({ error: null, }) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) } }, @@ -97,7 +98,7 @@ export const usePlatformStore = create()((set, get) => ({ error: null, }) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) throw err } }, @@ -111,7 +112,7 @@ export const usePlatformStore = create()((set, get) => ({ error: null, }) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) } }, @@ -127,7 +128,7 @@ export const usePlatformStore = create()((set, get) => ({ }) return null } catch (err) { - const message = err instanceof Error ? err.message : 'Test failed' + const message = getErrorMessage(err, 'Test failed') set({ error: message }) return message } diff --git a/src/stores/skillStore.ts b/src/stores/skillStore.ts index 3a119a2..421ab2a 100644 --- a/src/stores/skillStore.ts +++ b/src/stores/skillStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { getErrorMessage } from '../utils/errors' import { persist } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import type { SkillInfo } from '../types/skill'; @@ -227,7 +228,7 @@ export const useSkillStore = create()( }); } catch (error) { set({ - scanError: error instanceof Error ? error.message : 'Unknown scanning error', + scanError: getErrorMessage(error, 'Unknown scanning error'), }); } finally { get().setScanning(false); diff --git a/src/stores/taskStore.ts b/src/stores/taskStore.ts index 532a1e6..5a31032 100644 --- a/src/stores/taskStore.ts +++ b/src/stores/taskStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand' import { getBaseUrl } from '../utils/piClient' import { sendPlatformMessage } from '../utils/platformClient' +import { getErrorMessage } from '../utils/errors' +import { jsonRequest } from '../utils/http' import type { ScheduledTask, TaskDelivery, TaskSchedule } from '../types/task' import type { PlatformId } from '../types/platform' import type { KiloMessage } from '../types' @@ -88,34 +90,22 @@ async function deliverTaskResults(tasks: ScheduledTask[]) { } useAppStore.getState().addMessage(sessionId, message) - await fetch(`${BASE()}/tasks/${task.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deliverSessionId: sessionId, - deliveredRunId: task.lastRunId, - deliveryError: undefined, - }), - }) + await fetch(`${BASE()}/tasks/${task.id}`, jsonRequest({ + deliverSessionId: sessionId, + deliveredRunId: task.lastRunId, + deliveryError: undefined, + }, { method: 'PUT' })) } else { await sendPlatformMessage(task.deliver as PlatformId, latest) - await fetch(`${BASE()}/tasks/${task.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deliveredRunId: task.lastRunId, - deliveryError: undefined, - }), - }) + await fetch(`${BASE()}/tasks/${task.id}`, jsonRequest({ + deliveredRunId: task.lastRunId, + deliveryError: undefined, + }, { method: 'PUT' })) } } catch (err) { - await fetch(`${BASE()}/tasks/${task.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deliveryError: err instanceof Error ? err.message : String(err), - }), - }).catch(() => {}) + await fetch(`${BASE()}/tasks/${task.id}`, jsonRequest({ + deliveryError: getErrorMessage(err), + }, { method: 'PUT' })).catch(() => {}) } } } @@ -136,23 +126,19 @@ export const useTaskStore = create()((set, get) => ({ const finalTasks = refreshed.ok ? await refreshed.json() : tasks set({ tasks: finalTasks, loading: false }) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err), loading: false }) + set({ error: getErrorMessage(err), loading: false }) } }, createTask: async (data) => { try { - const res = await fetch(`${BASE()}/tasks/create`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) + const res = await fetch(`${BASE()}/tasks/create`, jsonRequest(data)) if (!res.ok) throw new Error(`HTTP ${res.status}`) const task = await res.json() set((s) => ({ tasks: [...s.tasks, task] })) return task } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) return null } }, @@ -162,7 +148,7 @@ export const useTaskStore = create()((set, get) => ({ await fetch(`${BASE()}/tasks/${id}`, { method: 'DELETE' }) set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) } }, @@ -170,14 +156,10 @@ export const useTaskStore = create()((set, get) => ({ const task = get().tasks.find((t) => t.id === id) if (!task) return try { - await fetch(`${BASE()}/tasks/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: !task.enabled }), - }) + await fetch(`${BASE()}/tasks/${id}`, jsonRequest({ enabled: !task.enabled }, { method: 'PUT' })) set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, enabled: !t.enabled } : t)) })) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) } }, @@ -187,19 +169,15 @@ export const useTaskStore = create()((set, get) => ({ const state = appStore.useAppStore.getState() const apiKey = state.providerConfigs[state.activeProvider]?.apiKey || '' const model = state.providerConfigs[state.activeProvider]?.model || '' - await fetch(`${BASE()}/tasks/run/${id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - apiKey, - defaultProvider: state.activeProvider, - defaultModel: model, - projectDir: state.workspaceDir || undefined, - }), - }) + await fetch(`${BASE()}/tasks/run/${id}`, jsonRequest({ + apiKey, + defaultProvider: state.activeProvider, + defaultModel: model, + projectDir: state.workspaceDir || undefined, + })) setTimeout(() => get().refresh(), 2000) } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + set({ error: getErrorMessage(err) }) } }, diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..ad7de48 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,10 @@ +/** + * Normalize an unknown thrown value into a human-readable message. + * + * Returns the `Error.message` when `err` is an `Error`, otherwise falls back to + * `fallback` (or `String(err)` when no fallback is provided). + */ +export function getErrorMessage(err: unknown, fallback?: string): string { + if (err instanceof Error) return err.message + return fallback ?? String(err) +} diff --git a/src/utils/http.ts b/src/utils/http.ts new file mode 100644 index 0000000..e81b928 --- /dev/null +++ b/src/utils/http.ts @@ -0,0 +1,15 @@ +/** + * Build a `fetch` init for a JSON request body. + * + * Defaults to `POST` and sets the JSON `Content-Type` header. Extra init + * fields (e.g. a different `method` or an `AbortSignal`) can be supplied via + * `init` and are merged; any headers in `init` override the defaults. + */ +export function jsonRequest(body: unknown, init?: RequestInit): RequestInit { + return { + method: 'POST', + ...init, + headers: { 'Content-Type': 'application/json', ...init?.headers }, + body: JSON.stringify(body), + } +} diff --git a/src/utils/piClient.ts b/src/utils/piClient.ts index 172b676..7b9be52 100644 --- a/src/utils/piClient.ts +++ b/src/utils/piClient.ts @@ -1,4 +1,7 @@ import type { PermissionAction, PermissionRule } from '../types/agent' +import { getErrorMessage } from './errors' +import { jsonRequest } from './http' +import { readSSEStream } from './sse' const DEFAULT_BASE = 'http://127.0.0.1:4096' let _base = DEFAULT_BASE @@ -107,11 +110,7 @@ export async function syncRuntimeConfig(config: { permissionMode?: string permissionRules?: Record }): Promise { - await fetch(`${_base}/runtime/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }) + await fetch(`${_base}/runtime/config`, jsonRequest(config)) } export function subscribeStream( @@ -174,12 +173,7 @@ export async function prompt( if (options?.providerAPI) body.providerAPI = options.providerAPI if (options?.providerConfig) body.providerConfig = options.providerConfig - const res = await fetch(`${_base}/session/${sessionId}/message`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: controller.signal, - }) + const res = await fetch(`${_base}/session/${sessionId}/message`, jsonRequest(body, { signal: controller.signal })) if (!res.ok) { const text = await res.text() @@ -189,87 +183,53 @@ export async function prompt( const reader = res.body?.getReader() if (!reader) return { text: '', error: 'No response body' } - const decoder = new TextDecoder() - let buffer = '' let resultText = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - - // Parse SSE events - let lineEnd = buffer.indexOf('\n') - while (lineEnd !== -1) { - const line = buffer.slice(0, lineEnd) - buffer = buffer.slice(lineEnd + 1) - lineEnd = buffer.indexOf('\n') - - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith(':')) continue - - if (trimmed.startsWith('event: ')) { - const eventType = trimmed.slice(7) - // Read next line for data - const nextEnd = buffer.indexOf('\n') - const dataLine = nextEnd === -1 ? buffer.trim() : buffer.slice(0, nextEnd).trim() - if (dataLine.startsWith('data: ')) { - try { - const data = JSON.parse(dataLine.slice(6)) - const cb = _streams.get(sessionId)?.callbacks - switch (eventType) { - case 'text_delta': - cb?.onText(data.pid || '', data.delta) - break - case 'thinking_delta': - cb?.onThinking(data.delta) - break - case 'tool_call': - case 'tool_execution_start': - cb?.onToolCall(data.id, data.name, data.args) - break - case 'tool_result': - case 'tool_execution_end': - cb?.onToolResult(data.id, data.name, data.result) - break - case 'tool_execution_update': - cb?.onToolUpdate?.(data.id, data.name, data.partialResult) - break - case 'mcp_tool_request': - cb?.onMcpToolRequest?.(data) - break - case 'tool_approval_request': - cb?.onToolApproval?.(data) - break - case 'result': - resultText = data.text || '' - cb?.onResult?.(resultText) - break - case 'error': - resultText = `Error: ${data.message}` - cb?.onError?.(data.message) - break - } - if (eventType === 'done') { - cb?.onDone() - return { text: resultText } - } - } catch { /* skip invalid JSON */ } - } - // Consume the data line - if (nextEnd !== -1) { - buffer = buffer.slice(nextEnd + 1) - lineEnd = buffer.indexOf('\n') - } - } + await readSSEStream(reader, (eventType, data) => { + const cb = _streams.get(sessionId)?.callbacks + switch (eventType) { + case 'text_delta': + cb?.onText(data.pid || '', data.delta) + break + case 'thinking_delta': + cb?.onThinking(data.delta) + break + case 'tool_call': + case 'tool_execution_start': + cb?.onToolCall(data.id, data.name, data.args) + break + case 'tool_result': + case 'tool_execution_end': + cb?.onToolResult(data.id, data.name, data.result) + break + case 'tool_execution_update': + cb?.onToolUpdate?.(data.id, data.name, data.partialResult) + break + case 'mcp_tool_request': + cb?.onMcpToolRequest?.(data) + break + case 'tool_approval_request': + cb?.onToolApproval?.(data) + break + case 'result': + resultText = data.text || '' + cb?.onResult?.(resultText) + break + case 'error': + resultText = `Error: ${data.message}` + cb?.onError?.(data.message) + break } - } + if (eventType === 'done') { + cb?.onDone() + return true + } + }) return { text: resultText } } catch (err) { if ((err as any)?.name === 'AbortError') return { text: '' } - return { text: '', error: err instanceof Error ? err.message : String(err) } + return { text: '', error: getErrorMessage(err) } } finally { const current = _streams.get(sessionId) if (current?.abortController === controller) { @@ -305,11 +265,7 @@ export async function sendMcpToolResponse( result?: unknown, error?: string, ): Promise { - await fetch(`${_base}/session/${sessionId}/mcp-response`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ requestId, result, error }), - }) + await fetch(`${_base}/session/${sessionId}/mcp-response`, jsonRequest({ requestId, result, error })) } export async function sendToolApproval( @@ -317,11 +273,7 @@ export async function sendToolApproval( requestId: string, approved: boolean, ): Promise { - await fetch(`${_base}/session/${sessionId}/tool-approval`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ requestId, approved }), - }) + await fetch(`${_base}/session/${sessionId}/tool-approval`, jsonRequest({ requestId, approved })) } export interface SubagentInfo { @@ -362,14 +314,10 @@ export async function saveSubagent( }, ): Promise<{ ok: boolean; path?: string; error?: string }> { try { - const res = await fetch(`${_base}/subagents/${encodeURIComponent(name)}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) + const res = await fetch(`${_base}/subagents/${encodeURIComponent(name)}`, jsonRequest(data)) return await res.json() } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } + return { ok: false, error: getErrorMessage(err) } } } @@ -383,7 +331,7 @@ export async function deleteSubagent( const res = await fetch(url, { method: 'DELETE' }) return await res.json() } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } + return { ok: false, error: getErrorMessage(err) } } } @@ -444,11 +392,7 @@ export async function createGoal(data: { maxIterations?: number }): Promise { try { - const res = await fetch(`${_base}/goals/create`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) + const res = await fetch(`${_base}/goals/create`, jsonRequest(data)) if (!res.ok) return null return await res.json() } catch { @@ -496,49 +440,17 @@ export async function runGoal( return } - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - - let lineEnd = buffer.indexOf('\n') - while (lineEnd !== -1) { - const line = buffer.slice(0, lineEnd) - buffer = buffer.slice(lineEnd + 1) - lineEnd = buffer.indexOf('\n') - - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith(':')) continue - - if (trimmed.startsWith('event: ')) { - const eventType = trimmed.slice(7) - const nextEnd = buffer.indexOf('\n') - const dataLine = nextEnd === -1 ? buffer.trim() : buffer.slice(0, nextEnd).trim() - if (dataLine.startsWith('data: ')) { - try { - const data = JSON.parse(dataLine.slice(6)) - if (eventType === 'goal_event') { - callbacks.onEvent(data) - } else if (eventType === 'done') { - callbacks.onDone() - return - } - } catch { /* skip */ } - } - if (nextEnd !== -1) { - buffer = buffer.slice(nextEnd + 1) - lineEnd = buffer.indexOf('\n') - } - } + await readSSEStream(reader, (eventType, data) => { + if (eventType === 'goal_event') { + callbacks.onEvent(data) + } else if (eventType === 'done') { + callbacks.onDone() + return true } - } + }) } catch (err) { if ((err as any)?.name !== 'AbortError') { - callbacks.onError?.(err instanceof Error ? err.message : String(err)) + callbacks.onError?.(getErrorMessage(err)) } } } diff --git a/src/utils/platformClient.ts b/src/utils/platformClient.ts index 6676da0..afc8251 100644 --- a/src/utils/platformClient.ts +++ b/src/utils/platformClient.ts @@ -1,4 +1,5 @@ import { getBaseUrl } from './piClient' +import { jsonRequest } from './http' import type { PlatformId, PlatformSnapshot } from '../types/platform' const BASE = () => getBaseUrl() @@ -17,19 +18,11 @@ export function loadPlatformSnapshot() { } export function savePlatformConfig(id: PlatformId, values: Record) { - return request(`/platforms/${id}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ values }), - }) + return request(`/platforms/${id}/config`, jsonRequest({ values })) } export function connectPlatform(id: PlatformId, values: Record) { - return request(`/platforms/${id}/connect`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ values }), - }) + return request(`/platforms/${id}/connect`, jsonRequest({ values })) } export function disconnectPlatform(id: PlatformId) { @@ -39,19 +32,11 @@ export function disconnectPlatform(id: PlatformId) { } export function sendPlatformMessage(id: PlatformId, text: string) { - return request(`/platforms/${id}/send`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }) + return request(`/platforms/${id}/send`, jsonRequest({ text })) } export function testPlatformMessage(id: PlatformId, text?: string) { - return request(`/platforms/${id}/test`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }) + return request(`/platforms/${id}/test`, jsonRequest({ text })) } export function clearPlatformMessages() { diff --git a/src/utils/sse.ts b/src/utils/sse.ts new file mode 100644 index 0000000..8eb23b5 --- /dev/null +++ b/src/utils/sse.ts @@ -0,0 +1,54 @@ +/** + * Read a Server-Sent Events stream from a fetch reader and dispatch each + * `event:`/`data:` pair to `onEvent`. + * + * The pi-server SSE framing sends an `event: ` line immediately followed + * by a `data: ` line. This helper handles the low-level buffering, + * line-splitting and JSON parsing that was previously duplicated across every + * streaming consumer. Return `true` from `onEvent` to stop consuming early + * (e.g. on a terminal `done` event). + */ +export async function readSSEStream( + reader: ReadableStreamDefaultReader, + onEvent: (eventType: string, data: any) => boolean | void, +): Promise { + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + let lineEnd = buffer.indexOf('\n') + while (lineEnd !== -1) { + const line = buffer.slice(0, lineEnd) + buffer = buffer.slice(lineEnd + 1) + lineEnd = buffer.indexOf('\n') + + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith(':')) continue + + if (trimmed.startsWith('event: ')) { + const eventType = trimmed.slice(7) + // The data line follows immediately on the next line. + const nextEnd = buffer.indexOf('\n') + const dataLine = nextEnd === -1 ? buffer.trim() : buffer.slice(0, nextEnd).trim() + let stop = false + if (dataLine.startsWith('data: ')) { + try { + const data = JSON.parse(dataLine.slice(6)) + stop = onEvent(eventType, data) === true + } catch { /* skip invalid JSON */ } + } + // Consume the data line. + if (nextEnd !== -1) { + buffer = buffer.slice(nextEnd + 1) + lineEnd = buffer.indexOf('\n') + } + if (stop) return + } + } + } +} diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 0000000..95e29e9 --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { getErrorMessage } from '../src/utils/errors' + +describe('getErrorMessage', () => { + it('returns the message of an Error instance', () => { + expect(getErrorMessage(new Error('boom'))).toBe('boom') + }) + + it('returns the message of an Error subclass', () => { + expect(getErrorMessage(new TypeError('bad type'))).toBe('bad type') + }) + + it('stringifies non-Error values by default', () => { + expect(getErrorMessage('plain string')).toBe('plain string') + expect(getErrorMessage(42)).toBe('42') + expect(getErrorMessage(null)).toBe('null') + expect(getErrorMessage(undefined)).toBe('undefined') + }) + + it('uses the fallback for non-Error values', () => { + expect(getErrorMessage('ignored', 'fallback')).toBe('fallback') + expect(getErrorMessage({}, 'fallback')).toBe('fallback') + }) + + it('prefers the Error message over the fallback', () => { + expect(getErrorMessage(new Error('real'), 'fallback')).toBe('real') + }) +}) diff --git a/tests/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..c7fb2ab --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { jsonRequest } from '../src/utils/http' + +describe('jsonRequest', () => { + it('defaults to a POST with JSON headers and a serialized body', () => { + const init = jsonRequest({ a: 1 }) + expect(init.method).toBe('POST') + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(init.body).toBe(JSON.stringify({ a: 1 })) + }) + + it('allows overriding the method', () => { + const init = jsonRequest({ enabled: true }, { method: 'PUT' }) + expect(init.method).toBe('PUT') + expect(init.body).toBe(JSON.stringify({ enabled: true })) + }) + + it('merges extra init fields such as an abort signal', () => { + const controller = new AbortController() + const init = jsonRequest({}, { signal: controller.signal }) + expect(init.signal).toBe(controller.signal) + expect(init.method).toBe('POST') + }) + + it('keeps the JSON content-type while merging extra headers', () => { + const init = jsonRequest({}, { headers: { 'X-Custom': 'yes' } }) + expect(init.headers).toEqual({ + 'Content-Type': 'application/json', + 'X-Custom': 'yes', + }) + }) +}) diff --git a/tests/sse.test.ts b/tests/sse.test.ts new file mode 100644 index 0000000..2ce3ca2 --- /dev/null +++ b/tests/sse.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { readSSEStream } from '../src/utils/sse' + +function readerFromChunks(chunks: string[]): ReadableStreamDefaultReader { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return stream.getReader() +} + +describe('readSSEStream', () => { + it('dispatches parsed event/data pairs in order', async () => { + const reader = readerFromChunks([ + 'event: text_delta\ndata: {"delta":"Hello"}\n', + 'event: text_delta\ndata: {"delta":" world"}\n', + 'event: done\ndata: {}\n', + ]) + const events: Array<[string, any]> = [] + await readSSEStream(reader, (type, data) => { + events.push([type, data]) + }) + expect(events).toEqual([ + ['text_delta', { delta: 'Hello' }], + ['text_delta', { delta: ' world' }], + ['done', {}], + ]) + }) + + it('stops consuming when the callback returns true', async () => { + const reader = readerFromChunks([ + 'event: a\ndata: {"n":1}\n', + 'event: done\ndata: {}\n', + 'event: b\ndata: {"n":2}\n', + ]) + const seen: string[] = [] + await readSSEStream(reader, (type) => { + seen.push(type) + if (type === 'done') return true + }) + expect(seen).toEqual(['a', 'done']) + }) + + it('reassembles a frame split across chunk boundaries', async () => { + // The event line and its data line arrive together (as pi-server writes + // them), but the frame is split mid-line across chunks. + const reader = readerFromChunks(['event: text_de', 'lta\ndata: {"delta":"hi"}\n']) + const events: Array<[string, any]> = [] + await readSSEStream(reader, (type, data) => { + events.push([type, data]) + }) + expect(events).toEqual([['text_delta', { delta: 'hi' }]]) + }) + + it('skips comments, blank lines and invalid JSON', async () => { + const reader = readerFromChunks([ + ': keep-alive comment\n', + '\n', + 'event: bad\ndata: {not json}\n', + 'event: good\ndata: {"ok":true}\n', + ]) + const events: Array<[string, any]> = [] + await readSSEStream(reader, (type, data) => { + events.push([type, data]) + }) + expect(events).toEqual([['good', { ok: true }]]) + }) +})