diff --git a/examples/kitchen-sink/src/app/(console)/support-agent/page.tsx b/examples/kitchen-sink/src/app/(console)/support-agent/page.tsx index d818de3c..560c27d8 100644 --- a/examples/kitchen-sink/src/app/(console)/support-agent/page.tsx +++ b/examples/kitchen-sink/src/app/(console)/support-agent/page.tsx @@ -1,7 +1,9 @@ 'use client'; import { Button } from '@/components/button'; +import { ConversationList } from '@/components/conversation-list'; import { Text } from '@/components/text'; +import { useConversations } from '@/lib/conversations'; import { Field } from '@base-ui-components/react/field'; import { Form } from '@base-ui-components/react/form'; import { createFeedbackClient, Feedback } from 'axiom/ai/feedback'; @@ -17,70 +19,122 @@ const { sendFeedback } = createFeedbackClient({ }); export default function SupportAgent() { - const { input, setInput, messages, result, error, isLoading, handleSubmit } = useSupportChat(); + const { + conversations, + currentId, + isLoaded, + createConversation, + selectConversation, + deleteConversation, + getMessages, + setMessages, + updateConversationTitle, + } = useConversations(); + + const { input, setInput, messages, result, error, isLoading, handleSubmit } = useSupportChat({ + conversationId: currentId, + getMessages, + setMessages, + updateTitle: updateConversationTitle, + }); + const [feedbackGiven, setFeedbackGiven] = useState>({}); - const handleFeedback = async (messageIndex: number, value: 'up' | 'down', message: string) => { - if (!result?.links) { - console.warn('Cannot send feedback: no links available', { result }); + const handleFeedback = async (messageIndex: number, value: 'up' | 'down', feedbackMessage: string) => { + const msg = messages[messageIndex]; + if (!msg?.links) { + console.warn('Cannot send feedback: no links available for message', { messageIndex, msg }); return; } setFeedbackGiven((prev) => ({ ...prev, [messageIndex]: value })); await sendFeedback( - result.links, - Feedback.thumb({ name: 'response-quality', value, message: message || undefined }), + msg.links, + Feedback.thumb({ name: 'response-quality', value, message: feedbackMessage || undefined }), ); }; + if (!isLoaded) { + return ( +
+
Loading...
+
+ ); + } + return ( - <> - - Pets.ai support agent - - Respond to Pets.ai customer support requests. +
+
+

Conversations

+ createConversation()} + onDelete={deleteConversation} + /> +
- {/* Chat History */} -
- {messages.length === 0 && ( -
No messages yet.
+
+ + Pets.ai support agent + + Respond to Pets.ai customer support requests. + + {currentId && ( +
Conversation: {currentId}
)} - {messages.map((msg, idx) => ( - handleFeedback(idx, value, message)} - /> - ))} - {isLoading && ( -
-
Thinking...
+ + {!currentId ? ( +
+

No conversation selected

+
- )} -
+ ) : ( + <> +
+ {messages.length === 0 && ( +
No messages yet.
+ )} + {messages.map((msg, idx) => ( + handleFeedback(idx, value, message)} + /> + ))} + {isLoading && ( +
+
Thinking...
+
+ )} +
-
- - setInput(event.target.value)} - disabled={isLoading} - className={`w-full rounded-md border border-gray-200 p-3 text-base text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-blue-800 ${ - isLoading ? 'bg-gray-100 cursor-not-allowed' : '' - }`} - /> - - -
+
+ + setInput(event.target.value)} + disabled={isLoading} + className={`w-full rounded-md border border-gray-200 p-3 text-base text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-blue-800 ${ + isLoading ? 'bg-gray-100 cursor-not-allowed' : '' + }`} + /> + + +
- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} - {result && } - + {result && } + + )} +
+
); } diff --git a/examples/kitchen-sink/src/app/(console)/support-agent/use-support-chat.ts b/examples/kitchen-sink/src/app/(console)/support-agent/use-support-chat.ts index 11440f9a..1cd0337b 100644 --- a/examples/kitchen-sink/src/app/(console)/support-agent/use-support-chat.ts +++ b/examples/kitchen-sink/src/app/(console)/support-agent/use-support-chat.ts @@ -1,54 +1,97 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { apiClient } from '@/lib/api/api-client'; import type { SupportAgentResult } from '@/lib/capabilities/support-agent/support-agent'; +import type { StoredMessage } from '@/lib/conversations'; -export type ModelMessage = { role: 'user' | 'assistant' | 'system'; content: string }; +export type ModelMessage = StoredMessage; -export function useSupportChat() { +type UseSupportChatOptions = { + conversationId: string | null; + getMessages: (id: string) => ModelMessage[]; + setMessages: (id: string, messages: ModelMessage[]) => void; + updateTitle?: (id: string, title: string) => void; +}; + +export function useSupportChat({ + conversationId, + getMessages, + setMessages, + updateTitle, +}: UseSupportChatOptions) { const [input, setInput] = useState(''); - const [messages, setMessages] = useState([]); + const [messages, setLocalMessages] = useState([]); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!input.trim() || isLoading) return; + useEffect(() => { + if (conversationId) { + const storedMessages = getMessages(conversationId); + setLocalMessages(storedMessages); + setResult(null); + setError(null); + } else { + setLocalMessages([]); + setResult(null); + setError(null); + } + }, [conversationId, getMessages]); - setIsLoading(true); - setError(null); + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); - const newMessages = [...messages, { role: 'user', content: input } as ModelMessage]; - setMessages(newMessages); - setInput(''); + if (!input.trim() || isLoading || !conversationId) return; - try { - const clientResponse = await apiClient.api['support-response'].$post({ - json: { messages: newMessages }, - }); + setIsLoading(true); + setError(null); - const json = await clientResponse.json(); + const userMessage: ModelMessage = { role: 'user', content: input }; + const newMessages = [...messages, userMessage]; + setLocalMessages(newMessages); + setMessages(conversationId, newMessages); + setInput(''); - if ('error' in json) { - throw new Error(json.error); + if (messages.length === 0 && updateTitle) { + const title = input.slice(0, 50) + (input.length > 50 ? '...' : ''); + updateTitle(conversationId, title); } - const agentResult = json.data as unknown as SupportAgentResult; - setResult(agentResult); + try { + const clientResponse = await apiClient.api['support-response'].$post({ + json: { messages: newMessages, conversationId }, + }); + + const json = await clientResponse.json(); - if (agentResult.answer) { - setMessages((prev) => [...prev, agentResult.answer as ModelMessage]); + if ('error' in json) { + throw new Error(json.error); + } + + const agentResult = json.data as unknown as SupportAgentResult; + setResult(agentResult); + + if (agentResult.answer) { + const assistantMessage: ModelMessage = { + role: 'assistant', + content: agentResult.answer.content as string, + links: agentResult.links, + }; + const updatedMessages = [...newMessages, assistantMessage]; + setLocalMessages(updatedMessages); + setMessages(conversationId, updatedMessages); + } + } catch (err) { + console.error(err); + setError('❌ Error generating response. Please try again.'); + } finally { + setIsLoading(false); } - } catch (err) { - console.error(err); - setError('❌ Error generating response. Please try again.'); - } finally { - setIsLoading(false); - } - }; + }, + [input, isLoading, conversationId, messages, setMessages, updateTitle] + ); return { input, diff --git a/examples/kitchen-sink/src/components/conversation-list.tsx b/examples/kitchen-sink/src/components/conversation-list.tsx new file mode 100644 index 00000000..4ba6a05c --- /dev/null +++ b/examples/kitchen-sink/src/components/conversation-list.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Conversation } from '@/lib/conversations'; + +type ConversationListProps = { + conversations: Conversation[]; + currentId: string | null; + onSelect: (id: string) => void; + onCreate: () => void; + onDelete: (id: string) => void; +}; + +export function ConversationList({ + conversations, + currentId, + onSelect, + onCreate, + onDelete, +}: ConversationListProps) { + return ( +
+ + {conversations.length === 0 && ( +
No conversations yet
+ )} + {conversations.map((conv) => ( +
+ + +
+ ))} +
+ ); +} diff --git a/examples/kitchen-sink/src/lib/api/ai/support-response.ts b/examples/kitchen-sink/src/lib/api/ai/support-response.ts index 736e570c..9ea2d0ad 100644 --- a/examples/kitchen-sink/src/lib/api/ai/support-response.ts +++ b/examples/kitchen-sink/src/lib/api/ai/support-response.ts @@ -4,6 +4,7 @@ import { ModelMessage } from 'ai'; interface SupportResponseBody { messages: ModelMessage[]; + conversationId?: string; context?: Record; } @@ -29,7 +30,7 @@ const createSupportResponseHandler = () => async (c: Context) => { ); } - const result = await runSupportAgent(body.messages); + const result = await runSupportAgent(body.messages, body.conversationId); return c.json({ success: true, diff --git a/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.eval.ts b/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.eval.ts index e207bfb1..74ba42fb 100644 --- a/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.eval.ts +++ b/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.eval.ts @@ -1,5 +1,6 @@ import { pickFlags } from '@/lib/app-scope'; import { categorizeMessage } from '@/lib/capabilities/support-agent/categorize-messages'; +import { SUPPORT_AGENT_CAPABILITY_NAME } from '@/lib/capabilities/support-agent/support-agent'; import { Eval } from 'axiom/ai/evals'; import { Scorer } from 'axiom/ai/evals/scorers'; @@ -7,8 +8,8 @@ const exactMatch = Scorer('exact-match', (args: { expected: string; output: stri return args.expected === args.output ? true : false; }); -Eval('support-agent-categorize-messages', { - capability: 'support-agent', +Eval('categorize-messages', { + capability: SUPPORT_AGENT_CAPABILITY_NAME, configFlags: pickFlags('supportAgent.categorizeMessage'), data: [ // Basic Happy Path diff --git a/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.ts b/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.ts index 5e643970..24dbb0c6 100644 --- a/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.ts +++ b/examples/kitchen-sink/src/lib/capabilities/support-agent/categorize-messages.ts @@ -4,6 +4,7 @@ import { generateText, ModelMessage } from 'ai'; import { withSpan, wrapAISDKModel } from 'axiom/ai'; import { onlineEval } from 'axiom/ai/evals/online'; import z from 'zod'; +import { SUPPORT_AGENT_CAPABILITY_NAME } from './support-agent'; import { validCategoryScorer, formatConfidenceScorer } from './online-scorers'; export const messageCategories = [ @@ -16,7 +17,10 @@ export const messageCategories = [ const messageCategoriesSchema = z.union(messageCategories.map((type) => z.literal(type))); export type MessageCategory = z.infer; -export const categorizeMessage = async (messages: ModelMessage[]): Promise => { +export const categorizeMessage = async ( + messages: ModelMessage[], + conversationId?: string, +): Promise => { const modelName = flag('supportAgent.categorizeMessage.model'); const model = wrapAISDKModel(openai(modelName)); @@ -25,7 +29,7 @@ export const categorizeMessage = async (messages: ModelMessage[]): Promise { const text = ` Please analyze the following series of messages. For the final user message, classify it as one of the following categories: ${messageCategories.join(', ')}. @@ -50,7 +54,7 @@ ${messages.map((msg) => `${msg.role}: ${msg.content}`).join('\n')} // Online evaluation: monitor classification quality in production // Active span is auto-linked. Fire-and-forget — doesn't block response. void onlineEval( - { capability: 'support-agent', step: 'categorize-message' }, + { capability: SUPPORT_AGENT_CAPABILITY_NAME, step: 'categorize-message' }, { input: evalInput, output: result, diff --git a/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.eval.ts b/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.eval.ts index 4fa00795..08de5f3e 100644 --- a/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.eval.ts +++ b/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.eval.ts @@ -2,6 +2,7 @@ import { Eval } from 'axiom/ai/evals'; import { Scorer } from 'axiom/ai/evals/scorers'; import { pickFlags } from '@/lib/app-scope'; import { extractTicketInfo } from '@/lib/capabilities/support-agent/extract-ticket-info'; +import { SUPPORT_AGENT_CAPABILITY_NAME } from '@/lib/capabilities/support-agent/support-agent'; type ExtractTicketInfoResult = Awaited>; @@ -52,7 +53,7 @@ type TestCase = { }; Eval('support-agent-extract-ticket-info-trials', { - capability: 'support-agent', + capability: SUPPORT_AGENT_CAPABILITY_NAME, configFlags: pickFlags('supportAgent.extractTicketInfo'), data: [ // 1. Complete Information (Happy Path) diff --git a/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.ts b/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.ts index 2bbdb237..7176e28b 100644 --- a/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.ts +++ b/examples/kitchen-sink/src/lib/capabilities/support-agent/extract-ticket-info.ts @@ -3,6 +3,7 @@ import { openai } from '@/lib/openai'; import { generateObject, ModelMessage } from 'ai'; import { withSpan, wrapAISDKModel } from 'axiom/ai'; import z from 'zod'; +import { SUPPORT_AGENT_CAPABILITY_NAME } from './support-agent'; const intents = [ 'billing_dispute', @@ -34,12 +35,13 @@ export type ExtractTicketInfoResult = { export const extractTicketInfo = async ( messages: ModelMessage[], + conversationId?: string, ): Promise => { const modelName = flag('supportAgent.extractTicketInfo.model'); const model = wrapAISDKModel(openai(modelName)); const llmRes = await withSpan( - { capability: 'support-agent', step: 'extract-ticket-info' }, + { capability: SUPPORT_AGENT_CAPABILITY_NAME, step: 'extract-ticket-info', conversationId }, async () => { return await generateObject({ model, diff --git a/examples/kitchen-sink/src/lib/capabilities/support-agent/retrieve-from-knowledge-base.eval.ts b/examples/kitchen-sink/src/lib/capabilities/support-agent/retrieve-from-knowledge-base.eval.ts index e4743f35..3ee3fd2f 100644 --- a/examples/kitchen-sink/src/lib/capabilities/support-agent/retrieve-from-knowledge-base.eval.ts +++ b/examples/kitchen-sink/src/lib/capabilities/support-agent/retrieve-from-knowledge-base.eval.ts @@ -2,6 +2,7 @@ import { Eval } from 'axiom/ai/evals'; import { Scorer } from 'axiom/ai/evals/scorers'; import { pickFlags } from '@/lib/app-scope'; import { veryBadRAG } from '@/lib/capabilities/support-agent/retrieve-from-knowledge-base'; +import { SUPPORT_AGENT_CAPABILITY_NAME } from '@/lib/capabilities/support-agent/support-agent'; const strictRetrievalMatch = Scorer( 'strict-retrieval-match', @@ -24,8 +25,8 @@ const strictRetrievalMatch = Scorer( }, ); -Eval('support-agent-retrieve-from-knowledge-base', { - capability: 'support-agent', +Eval('retrieve-from-knowledge-base', { + capability: SUPPORT_AGENT_CAPABILITY_NAME, configFlags: pickFlags('supportAgent.retrieveFromKnowledgeBase'), data: [ // Happy Path diff --git a/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent-e2e-tool-use.eval.ts b/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent-e2e-tool-use.eval.ts index d398f2e5..290f8bb5 100644 --- a/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent-e2e-tool-use.eval.ts +++ b/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent-e2e-tool-use.eval.ts @@ -2,6 +2,7 @@ import { Eval } from 'axiom/ai/evals'; import { Scorer } from 'axiom/ai/evals/scorers'; import { pickFlags } from '@/lib/app-scope'; import { + SUPPORT_AGENT_CAPABILITY_NAME, runSupportAgent, SupportAgentResult, } from '@/lib/capabilities/support-agent/support-agent'; @@ -29,8 +30,8 @@ const toolUseMatch = Scorer( }, ); -Eval('support-agent-e2e-tool-use', { - capability: 'support-agent', +Eval('e2e-tool-use', { + capability: SUPPORT_AGENT_CAPABILITY_NAME, configFlags: pickFlags('supportAgent'), data: [ { diff --git a/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent.ts b/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent.ts index 5f2c9dd5..616d63d4 100644 --- a/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent.ts +++ b/examples/kitchen-sink/src/lib/capabilities/support-agent/support-agent.ts @@ -9,6 +9,8 @@ import { categorizeMessage, MessageCategory } from './categorize-messages'; import { extractTicketInfo, ExtractTicketInfoResult } from './extract-ticket-info'; import { veryBadRAG } from './retrieve-from-knowledge-base'; +export const SUPPORT_AGENT_CAPABILITY_NAME = 'support-agent'; + type ToolCalls = Awaited>['toolCalls']; export type SupportAgentResult = { @@ -37,16 +39,20 @@ const supportAgentTools = wrapTools({ }), }); -export const runSupportAgent = async (messages: ModelMessage[]): Promise => { - return startActiveSpan('support-agent', null, async (span) => { +export const runSupportAgent = async ( + messages: ModelMessage[], + conversationId?: string, +): Promise => { + return startActiveSpan(SUPPORT_AGENT_CAPABILITY_NAME, null, async (span) => { const links: FeedbackLinks = { traceId: span.spanContext().traceId, spanId: span.spanContext().spanId, - capability: 'support-agent', + capability: SUPPORT_AGENT_CAPABILITY_NAME, + conversationId, }; // 1. Categorize - const category = await categorizeMessage(messages); + const category = await categorizeMessage(messages, conversationId); // 2. Hard routing / early exits if (category === 'spam') { @@ -76,13 +82,13 @@ export const runSupportAgent = async (messages: ModelMessage[]): Promise { const modelName = flag('supportAgent.main.model'); const model = wrapAISDKModel(openai(modelName)); - return await withSpan({ capability: 'support-agent', step: 'generate-answer' }, async (span) => { - const { text, toolCalls } = await generateText({ - model, - tools: supportAgentTools, - stopWhen: stepCountIs(10), - messages: [ - { - role: 'system', - content: ` + return await withSpan( + { capability: SUPPORT_AGENT_CAPABILITY_NAME, step: 'generate-answer', conversationId }, + async (span) => { + const { text, toolCalls } = await generateText({ + model, + tools: supportAgentTools, + stopWhen: stepCountIs(10), + messages: [ + { + role: 'system', + content: ` You are a support assistant for Pets.ai. Your goal is to help the user resolve their issue. @@ -130,18 +139,19 @@ async function generateSupportAnswer( Do not make up information. If the knowledge base doesn't have the answer, say so. `.trim(), - }, - ...messages, - ], - }); + }, + ...messages, + ], + }); - const traceId = span.spanContext().traceId; - const spanId = span.spanContext().spanId; + const traceId = span.spanContext().traceId; + const spanId = span.spanContext().spanId; - return { - message: { role: 'assistant', content: text }, - toolCalls, - links: { traceId, spanId, capability: 'support-agent' }, - }; - }); + return { + message: { role: 'assistant', content: text }, + toolCalls, + links: { traceId, spanId, capability: SUPPORT_AGENT_CAPABILITY_NAME, conversationId }, + }; + }, + ); } diff --git a/examples/kitchen-sink/src/lib/conversations.ts b/examples/kitchen-sink/src/lib/conversations.ts new file mode 100644 index 00000000..4121ea56 --- /dev/null +++ b/examples/kitchen-sink/src/lib/conversations.ts @@ -0,0 +1,157 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +export type Conversation = { + id: string; + title: string; + createdAt: number; + updatedAt: number; +}; + +export type FeedbackLinksData = { + traceId: string; + spanId?: string; + capability: string; + conversationId?: string; +}; + +export type StoredMessage = { + role: 'user' | 'assistant' | 'system'; + content: string; + links?: FeedbackLinksData; +}; + +type ConversationMessages = StoredMessage[]; + +const CONVERSATIONS_KEY = 'kitchen-sink:conversations'; +const MESSAGES_KEY_PREFIX = 'kitchen-sink:messages:'; + +function generateId(): string { + return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function getStoredConversations(): Conversation[] { + if (typeof window === 'undefined') return []; + try { + const stored = localStorage.getItem(CONVERSATIONS_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +function saveConversations(conversations: Conversation[]): void { + if (typeof window === 'undefined') return; + localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(conversations)); +} + +function getStoredMessages(conversationId: string): ConversationMessages { + if (typeof window === 'undefined') return []; + try { + const stored = localStorage.getItem(MESSAGES_KEY_PREFIX + conversationId); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +function saveMessages(conversationId: string, messages: ConversationMessages): void { + if (typeof window === 'undefined') return; + localStorage.setItem(MESSAGES_KEY_PREFIX + conversationId, JSON.stringify(messages)); +} + +function deleteStoredMessages(conversationId: string): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(MESSAGES_KEY_PREFIX + conversationId); +} + +export function useConversations() { + const [conversations, setConversations] = useState([]); + const [currentId, setCurrentId] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + const stored = getStoredConversations(); + setConversations(stored); + if (stored.length > 0) { + setCurrentId(stored[0].id); + } + setIsLoaded(true); + }, []); + + const createConversation = useCallback((title?: string): string => { + const id = generateId(); + const now = Date.now(); + const newConversation: Conversation = { + id, + title: title || 'New conversation', + createdAt: now, + updatedAt: now, + }; + setConversations((prev) => { + const updated = [newConversation, ...prev]; + saveConversations(updated); + return updated; + }); + setCurrentId(id); + return id; + }, []); + + const selectConversation = useCallback((id: string) => { + setCurrentId(id); + }, []); + + const updateConversationTitle = useCallback((id: string, title: string) => { + setConversations((prev) => { + const updated = prev.map((c) => + c.id === id ? { ...c, title, updatedAt: Date.now() } : c + ); + saveConversations(updated); + return updated; + }); + }, []); + + const deleteConversation = useCallback((id: string) => { + setConversations((prev) => { + const updated = prev.filter((c) => c.id !== id); + saveConversations(updated); + deleteStoredMessages(id); + return updated; + }); + setCurrentId((prev) => { + if (prev === id) { + const remaining = conversations.filter((c) => c.id !== id); + return remaining.length > 0 ? remaining[0].id : null; + } + return prev; + }); + }, [conversations]); + + const getMessages = useCallback((conversationId: string): ConversationMessages => { + return getStoredMessages(conversationId); + }, []); + + const setMessages = useCallback((conversationId: string, messages: ConversationMessages) => { + saveMessages(conversationId, messages); + setConversations((prev) => { + const updated = prev.map((c) => + c.id === conversationId ? { ...c, updatedAt: Date.now() } : c + ); + saveConversations(updated); + return updated; + }); + }, []); + + return { + conversations, + currentId, + isLoaded, + createConversation, + selectConversation, + updateConversationTitle, + deleteConversation, + getMessages, + setMessages, + }; +}