| 1 | import type {AgentEvent, ChatState, Message} from './types'; |
| 2 | |
| 3 | export function createChatState(messages: Message[] = []): ChatState { |
| 4 | return {messages, busy: false, turnId: '', promptTokens: 0}; |
| 5 | } |
| 6 | |
| 7 | export function appendLocalUser(state: ChatState, text: string): ChatState { |
| 8 | return { |
| 9 | ...state, |
| 10 | busy: true, |
| 11 | messages: [...state.messages, {id: `local-user-${Date.now()}`, role: 'user', text}], |
| 12 | }; |
| 13 | } |
| 14 | |
| 15 | export function composeAgentPrompt(text: string, workspaceUploads: string[] = []) { |
| 16 | const message = text.replace(/\s+/g, ' ').trim(); |
| 17 | const paths = workspaceUploads.map((path) => path.trim()).filter(Boolean); |
| 18 | if (paths.length === 0) return message; |
| 19 | return `${message} <workspace_uploads>${JSON.stringify(paths)}</workspace_uploads>`; |
| 20 | } |
| 21 | |
| 22 | export function applyAgentEvent(state: ChatState, event: AgentEvent): ChatState { |
| 23 | const turnId = event.turn_id || state.turnId || `turn-${Date.now()}`; |
| 24 | switch (event.type) { |
| 25 | case 'turn': |
| 26 | return {...state, busy: true, turnId}; |
| 27 | case 'prompt_trace': { |
| 28 | const tokens = event.prompt_trace?.totals?.total_tokens |
| 29 | ?? event.prompt_trace?.totals?.total_estimated_tokens |
| 30 | ?? event.prompt_trace?.total_estimated_tokens |
| 31 | ?? state.promptTokens; |
| 32 | return {...state, promptTokens: Math.max(0, Math.round(tokens))}; |
| 33 | } |
| 34 | case 'token': |
| 35 | return appendAssistantDelta(state, turnId, event.delta || ''); |
| 36 | case 'tool_start': { |
| 37 | const name = event.tool?.name || event.tool?.requested_name || 'tool'; |
| 38 | return upsertActivity(state, { |
| 39 | id: activityId(turnId, event.tool?.id, name), |
| 40 | role: 'activity', |
| 41 | tool: name, |
| 42 | status: 'running', |
| 43 | stage: 'starting', |
| 44 | text: 'Starting', |
| 45 | }); |
| 46 | } |
| 47 | case 'tool_progress': { |
| 48 | const name = event.tool?.name || event.tool?.requested_name || 'tool'; |
| 49 | return upsertLatestToolActivity(state, name, { |
| 50 | role: 'activity', |
| 51 | tool: name, |
| 52 | status: 'running', |
| 53 | stage: event.progress?.stage || 'running', |
| 54 | text: event.progress?.message || humanize(event.progress?.stage || 'Running'), |
| 55 | }); |
| 56 | } |
| 57 | case 'tool_result': { |
| 58 | const name = event.tool_result?.name || 'tool'; |
| 59 | const ok = event.tool_result?.ok !== false; |
| 60 | return upsertLatestToolActivity(state, name, { |
| 61 | role: 'activity', |
| 62 | tool: name, |
| 63 | status: ok ? 'done' : 'error', |
| 64 | stage: ok ? 'completed' : 'failed', |
| 65 | text: ok ? 'Completed' : cleanError(event.tool_result?.content || 'Tool failed'), |
| 66 | }); |
| 67 | } |
| 68 | case 'terminal': |
| 69 | if (event.stream !== 'stderr') return state; |
| 70 | return { |
| 71 | ...state, |
| 72 | messages: [...state.messages, { |
| 73 | id: `terminal-${Date.now()}`, |
| 74 | role: 'activity', |
| 75 | status: 'error', |
| 76 | tool: 'runtime', |
| 77 | text: cleanError(event.line || 'Runtime error'), |
| 78 | }], |
| 79 | }; |
| 80 | case 'error': |
| 81 | return { |
| 82 | ...state, |
| 83 | busy: false, |
| 84 | messages: [...state.messages, {id: `error-${turnId}-${Date.now()}`, role: 'error', text: event.message || 'Unknown agent error'}], |
| 85 | }; |
| 86 | case 'done': { |
| 87 | const hasAssistant = state.messages.some((message) => message.id === `assistant-${turnId}`); |
| 88 | const messages = !hasAssistant && event.assistant |
| 89 | ? [...state.messages, {id: `assistant-${turnId}`, role: 'assistant' as const, text: event.assistant}] |
| 90 | : state.messages; |
| 91 | return {...state, messages, busy: false}; |
| 92 | } |
| 93 | case 'bridge_status': |
| 94 | if (event.status !== 'error' && event.status !== 'stopped') return state; |
| 95 | return { |
| 96 | ...state, |
| 97 | busy: false, |
| 98 | messages: state.messages.map((message) => message.role === 'activity' && message.status === 'running' |
| 99 | ? {...message, status: 'error', stage: 'interrupted', text: event.message || 'Generation stopped'} |
| 100 | : message), |
| 101 | }; |
| 102 | default: |
| 103 | return state; |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | function appendAssistantDelta(state: ChatState, turnId: string, delta: string): ChatState { |
| 108 | if (!delta) return state; |
| 109 | const id = `assistant-${turnId}`; |
| 110 | const index = state.messages.findIndex((message) => message.id === id); |
| 111 | if (index < 0) { |
| 112 | return {...state, messages: [...state.messages, {id, role: 'assistant', text: delta}]}; |
| 113 | } |
| 114 | const messages = [...state.messages]; |
| 115 | messages[index] = {...messages[index], text: `${messages[index].text}${delta}`}; |
| 116 | return {...state, messages}; |
| 117 | } |
| 118 | |
| 119 | function upsertActivity(state: ChatState, message: Message): ChatState { |
| 120 | const index = state.messages.findIndex((item) => item.id === message.id); |
| 121 | if (index < 0) return {...state, messages: [...state.messages, message]}; |
| 122 | const messages = [...state.messages]; |
| 123 | messages[index] = {...messages[index], ...message}; |
| 124 | return {...state, messages}; |
| 125 | } |
| 126 | |
| 127 | function upsertLatestToolActivity(state: ChatState, tool: string, update: Omit<Message, 'id'>): ChatState { |
| 128 | const messages = [...state.messages]; |
| 129 | for (let index = messages.length - 1; index >= 0; index -= 1) { |
| 130 | if (messages[index].role === 'activity' && messages[index].tool === tool && messages[index].status === 'running') { |
| 131 | messages[index] = {...messages[index], ...update}; |
| 132 | return {...state, messages}; |
| 133 | } |
| 134 | } |
| 135 | return {...state, messages: [...messages, {id: `activity-${tool}-${Date.now()}`, ...update}]}; |
| 136 | } |
| 137 | |
| 138 | function activityId(turnId: string, toolId: string | undefined, name: string) { |
| 139 | return `activity-${turnId}-${toolId || name}-${Date.now()}`; |
| 140 | } |
| 141 | |
| 142 | export function humanize(value: string) { |
| 143 | return value |
| 144 | .replace(/[_-]+/g, ' ') |
| 145 | .replace(/\b\w/g, (letter) => letter.toUpperCase()) |
| 146 | .replace(/\bVimax\b/g, 'ViMax'); |
| 147 | } |
| 148 | |
| 149 | function cleanError(value: string) { |
| 150 | return value.replace(/\s+/g, ' ').trim(); |
| 151 | } |
| 152 |