import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { Message, User, TraceLog } from '@/types/agent'; export interface PlanItem { id: string; content: string; status: 'pending' | 'in_progress' | 'completed'; } interface PanelTab { id: string; title: string; content: string; language?: string; parameters?: Record; } export interface LLMHealthError { error: string; errorType: 'auth' | 'credits' | 'rate_limit' | 'network' | 'unknown'; model: string; } interface AgentStore { // State per session (keyed by session ID) messagesBySession: Record; isProcessing: boolean; isConnected: boolean; user: User | null; error: string | null; llmHealthError: LLMHealthError | null; traceLogs: TraceLog[]; panelContent: { title: string; content: string; language?: string; parameters?: Record } | null; panelTabs: PanelTab[]; activePanelTab: string | null; plan: PlanItem[]; currentTurnMessageId: string | null; // Track the current turn's assistant message editedScripts: Record; // tool_call_id -> edited content // Actions addMessage: (sessionId: string, message: Message) => void; updateMessage: (sessionId: string, messageId: string, updates: Partial) => void; clearMessages: (sessionId: string) => void; setProcessing: (isProcessing: boolean) => void; setConnected: (isConnected: boolean) => void; setUser: (user: User | null) => void; setError: (error: string | null) => void; getMessages: (sessionId: string) => Message[]; addTraceLog: (log: TraceLog) => void; updateTraceLog: (toolCallId: string, toolName: string, updates: Partial) => void; clearTraceLogs: () => void; setPanelContent: (content: { title: string; content: string; language?: string; parameters?: Record } | null) => void; setPanelTab: (tab: PanelTab) => void; updatePanelTabContent: (tabId: string, content: string) => void; setActivePanelTab: (tabId: string) => void; clearPanelTabs: () => void; removePanelTab: (tabId: string) => void; setPlan: (plan: PlanItem[]) => void; setCurrentTurnMessageId: (id: string | null) => void; updateCurrentTurnTrace: (sessionId: string) => void; showToolOutput: (log: TraceLog) => void; setEditedScript: (toolCallId: string, content: string) => void; getEditedScript: (toolCallId: string) => string | undefined; clearEditedScripts: () => void; /** Append a streaming delta to an existing message. */ appendToMessage: (sessionId: string, messageId: string, delta: string) => void; /** Remove all messages for a session (also clears from localStorage). */ deleteSessionMessages: (sessionId: string) => void; /** Remove the last turn (last user msg + all following assistant/tool msgs). */ removeLastTurn: (sessionId: string) => void; setLlmHealthError: (error: LLMHealthError | null) => void; } export const useAgentStore = create()( persist( (set, get) => ({ messagesBySession: {}, isProcessing: false, isConnected: false, user: null, error: null, llmHealthError: null, traceLogs: [], panelContent: null, panelTabs: [], activePanelTab: null, plan: [], currentTurnMessageId: null, editedScripts: {}, addMessage: (sessionId: string, message: Message) => { set((state) => { const currentMessages = state.messagesBySession[sessionId] || []; return { messagesBySession: { ...state.messagesBySession, [sessionId]: [...currentMessages, message], }, }; }); }, updateMessage: (sessionId: string, messageId: string, updates: Partial) => { set((state) => { const currentMessages = state.messagesBySession[sessionId] || []; const updatedMessages = currentMessages.map((msg) => msg.id === messageId ? { ...msg, ...updates } : msg ); return { messagesBySession: { ...state.messagesBySession, [sessionId]: updatedMessages, }, }; }); }, clearMessages: (sessionId: string) => { set((state) => ({ messagesBySession: { ...state.messagesBySession, [sessionId]: [], }, })); }, setProcessing: (isProcessing: boolean) => { set({ isProcessing }); }, setConnected: (isConnected: boolean) => { set({ isConnected }); }, setUser: (user: User | null) => { set({ user }); }, setError: (error: string | null) => { set({ error }); }, getMessages: (sessionId: string) => { return get().messagesBySession[sessionId] || []; }, addTraceLog: (log: TraceLog) => { set((state) => ({ traceLogs: [...state.traceLogs, log], })); }, updateTraceLog: (toolCallId: string, toolName: string, updates: Partial) => { set((state) => { const traceLogs = [...state.traceLogs]; // Prefer matching by tool_call_id (reliable), fall back to tool name (legacy) let matched = false; if (toolCallId) { for (let i = traceLogs.length - 1; i >= 0; i--) { if (traceLogs[i].toolCallId === toolCallId) { traceLogs[i] = { ...traceLogs[i], ...updates }; matched = true; break; } } } if (!matched) { // Fallback: match by tool name (last uncompleted call) for (let i = traceLogs.length - 1; i >= 0; i--) { if (traceLogs[i].tool === toolName && traceLogs[i].type === 'call' && !traceLogs[i].completed) { traceLogs[i] = { ...traceLogs[i], ...updates }; break; } } } return { traceLogs }; }); }, clearTraceLogs: () => { set({ traceLogs: [] }); }, setPanelContent: (content) => { set({ panelContent: content }); }, setPanelTab: (tab: PanelTab) => { set((state) => { const existingIndex = state.panelTabs.findIndex(t => t.id === tab.id); let newTabs: PanelTab[]; if (existingIndex >= 0) { // Update existing tab newTabs = [...state.panelTabs]; newTabs[existingIndex] = tab; } else { // Add new tab newTabs = [...state.panelTabs, tab]; } return { panelTabs: newTabs, activePanelTab: state.activePanelTab || tab.id, // Auto-select first tab }; }); }, updatePanelTabContent: (tabId: string, content: string) => { set((state) => { const newTabs = state.panelTabs.map(tab => tab.id === tabId ? { ...tab, content } : tab ); return { panelTabs: newTabs }; }); }, setActivePanelTab: (tabId: string) => { set({ activePanelTab: tabId }); }, clearPanelTabs: () => { set({ panelTabs: [], activePanelTab: null }); }, removePanelTab: (tabId: string) => { set((state) => { const newTabs = state.panelTabs.filter(t => t.id !== tabId); // If we removed the active tab, switch to another tab or null let newActiveTab = state.activePanelTab; if (state.activePanelTab === tabId) { newActiveTab = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null; } return { panelTabs: newTabs, activePanelTab: newActiveTab, }; }); }, setPlan: (plan: PlanItem[]) => { set({ plan }); }, setCurrentTurnMessageId: (id: string | null) => { set({ currentTurnMessageId: id }); }, updateCurrentTurnTrace: (sessionId: string) => { const state = get(); if (!state.currentTurnMessageId) return; const currentMessages = state.messagesBySession[sessionId] || []; const latestTools = state.traceLogs.length > 0 ? [...state.traceLogs] : undefined; if (!latestTools) return; // Build a lookup of the latest state for each tool by id const toolById = new Map(latestTools.map(t => [t.id, t])); const updatedMessages = currentMessages.map((msg) => { if (msg.id !== state.currentTurnMessageId) return msg; const segments = msg.segments ? [...msg.segments] : []; // First pass: update existing tools in their original segments const placedToolIds = new Set(); for (let i = 0; i < segments.length; i++) { if (segments[i].type === 'tools' && segments[i].tools) { segments[i] = { ...segments[i], tools: segments[i].tools!.map(t => { placedToolIds.add(t.id); return toolById.get(t.id) || t; }), }; } } // Collect only genuinely new tools (not yet in any segment) const newTools = latestTools.filter(t => !placedToolIds.has(t.id)); if (newTools.length > 0) { const lastToolsIdx = segments.map((s) => s.type).lastIndexOf('tools'); if (lastToolsIdx >= 0 && lastToolsIdx === segments.length - 1) { // Last segment is tools — append new tools to it segments[lastToolsIdx] = { ...segments[lastToolsIdx], tools: [...(segments[lastToolsIdx].tools || []), ...newTools], }; } else { // Text came after previous tools — create a new segment with only new tools segments.push({ type: 'tools', tools: newTools }); } } return { ...msg, segments }; }); set({ messagesBySession: { ...state.messagesBySession, [sessionId]: updatedMessages, }, }); }, showToolOutput: (log: TraceLog) => { // Show tool output in the right panel - only ONE tool output tab at a time const state = get(); // Determine language based on content let language = 'text'; const content = log.output || ''; // Check if content looks like JSON if (content.trim().startsWith('{') || content.trim().startsWith('[') || content.includes('```json')) { language = 'json'; } // Check if content has markdown tables or formatting else if (content.includes('|') && content.includes('---') || content.includes('```')) { language = 'markdown'; } // Remove any existing tool output tab (only keep one) const otherTabs = state.panelTabs.filter(t => t.id !== 'tool_output'); // Create/replace the single tool output tab const newTab = { id: 'tool_output', title: log.tool, content: content || 'No output available', language, }; set({ panelTabs: [...otherTabs, newTab], activePanelTab: 'tool_output', }); }, setEditedScript: (toolCallId: string, content: string) => { set((state) => ({ editedScripts: { ...state.editedScripts, [toolCallId]: content }, })); }, getEditedScript: (toolCallId: string) => { return get().editedScripts[toolCallId]; }, clearEditedScripts: () => { set({ editedScripts: {} }); }, appendToMessage: (sessionId: string, messageId: string, delta: string) => { set((state) => { const messages = state.messagesBySession[sessionId] || []; return { messagesBySession: { ...state.messagesBySession, [sessionId]: messages.map((msg) => { if (msg.id !== messageId) return msg; const newContent = msg.content + delta; const segments = msg.segments ? [...msg.segments] : []; const lastSeg = segments[segments.length - 1]; if (lastSeg && lastSeg.type === 'text') { // Append to the existing text segment segments[segments.length - 1] = { ...lastSeg, content: (lastSeg.content || '') + delta, }; } else { // Last segment is 'tools' (or empty) — start a NEW text segment // so that tools and text remain visually separated. segments.push({ type: 'text', content: delta }); } return { ...msg, content: newContent, segments }; }), }, }; }); }, deleteSessionMessages: (sessionId: string) => { set((state) => { const { [sessionId]: _, ...rest } = state.messagesBySession; return { messagesBySession: rest }; }); }, removeLastTurn: (sessionId: string) => { set((state) => { const msgs = state.messagesBySession[sessionId]; if (!msgs || msgs.length === 0) return state; // Find the index of the last user message let lastUserIdx = -1; for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === 'user') { lastUserIdx = i; break; } } if (lastUserIdx === -1) return state; // no user message to remove // Remove everything from that user message onward return { messagesBySession: { ...state.messagesBySession, [sessionId]: msgs.slice(0, lastUserIdx), }, }; }); }, setLlmHealthError: (error: LLMHealthError | null) => { set({ llmHealthError: error }); }, }), { name: 'hf-agent-messages', // Only persist messages — all transient UI state stays in-memory partialize: (state) => ({ messagesBySession: state.messagesBySession, }), } ) );