/** * ConversationStore — manages conversation lifecycle, agent switching, and persistence. * * Extracted from chatStore.ts as part of the structured refactor. * Responsible for: conversation CRUD, agent list/sync, session/model state. * * @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.2 */ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { generateRandomString } from '../../lib/crypto-utils'; import { createIdbStorageAdapter } from '../../lib/idb-storage'; import type { ChatMessage } from '../../types/chat'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface Conversation { id: string; title: string; messages: ChatMessage[]; sessionKey: string | null; agentId: string | null; createdAt: Date; updatedAt: Date; } export interface Agent { id: string; name: string; icon: string; color: string; lastMessage: string; time: string; } export interface AgentProfileLike { id: string; name: string; nickname?: string; role?: string; } // --------------------------------------------------------------------------- // State interface // --------------------------------------------------------------------------- export interface ConversationState { conversations: Conversation[]; currentConversationId: string | null; agents: Agent[]; currentAgent: Agent | null; sessionKey: string | null; currentModel: string; // Actions newConversation: (currentMessages: ChatMessage[]) => Conversation[]; switchConversation: (id: string, currentMessages: ChatMessage[]) => { conversations: Conversation[]; messages: ChatMessage[]; sessionKey: string | null; currentAgent: Agent; currentConversationId: string; isStreaming: boolean; } | null; deleteConversation: (id: string, currentConversationId: string | null) => { conversations: Conversation[]; resetMessages: boolean; }; setCurrentAgent: (agent: Agent, currentMessages: ChatMessage[]) => { conversations: Conversation[]; currentAgent: Agent; messages: ChatMessage[]; sessionKey: string | null; isStreaming: boolean; currentConversationId: string | null; }; syncAgents: (profiles: AgentProfileLike[]) => { agents: Agent[]; currentAgent: Agent; }; setCurrentModel: (model: string) => void; upsertActiveConversation: (currentMessages: ChatMessage[]) => Conversation[]; getCurrentConversationId: () => string | null; getCurrentAgent: () => Agent | null; getSessionKey: () => string | null; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function generateConvId(): string { return `conv_${Date.now()}_${generateRandomString(4)}`; } function deriveTitle(messages: ChatMessage[]): string { const firstUser = messages.find(m => m.role === 'user'); if (firstUser) { const text = firstUser.content.trim(); return text.length > 30 ? text.slice(0, 30) + '...' : text; } return '新对话'; } const DEFAULT_AGENT: Agent = { id: '1', name: 'ZCLAW', icon: '🦞', color: 'bg-gradient-to-br from-orange-500 to-red-500', lastMessage: '发送消息开始对话', time: '', }; export { DEFAULT_AGENT }; export function toChatAgent(profile: AgentProfileLike): Agent { return { id: profile.id, name: profile.name, icon: profile.nickname?.slice(0, 1) || '🦞', color: 'bg-gradient-to-br from-orange-500 to-red-500', lastMessage: profile.role || '新分身', time: '', }; } export function resolveConversationAgentId(agent: Agent | null): string | null { if (!agent || agent.id === DEFAULT_AGENT.id) { return null; } return agent.id; } export function resolveGatewayAgentId(agent: Agent | null): string | undefined { if (!agent || agent.id === DEFAULT_AGENT.id || agent.id.startsWith('clone_')) { return undefined; } return agent.id; } export function resolveAgentForConversation(agentId: string | null, agents: Agent[]): Agent { if (!agentId) { return DEFAULT_AGENT; } return agents.find((agent) => agent.id === agentId) || DEFAULT_AGENT; } function upsertActiveConversation( conversations: Conversation[], messages: ChatMessage[], sessionKey: string | null, currentConversationId: string | null, currentAgent: Agent | null, ): Conversation[] { if (messages.length === 0) { return conversations; } const currentId = currentConversationId || generateConvId(); const existingIdx = conversations.findIndex((conv) => conv.id === currentId); const nextConversation: Conversation = { id: currentId, title: deriveTitle(messages), messages: [...messages], sessionKey, agentId: resolveConversationAgentId(currentAgent), createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(), updatedAt: new Date(), }; if (existingIdx >= 0) { const updated = [...conversations]; updated[existingIdx] = nextConversation; return updated; } return [nextConversation, ...conversations]; } // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- export const useConversationStore = create()( persist( (set, get) => ({ conversations: [], currentConversationId: null, agents: [DEFAULT_AGENT], currentAgent: DEFAULT_AGENT, sessionKey: null, currentModel: '', // Set dynamically: SaaS models or config.toml default newConversation: (currentMessages: ChatMessage[]) => { const state = get(); const conversations = upsertActiveConversation( [...state.conversations], currentMessages, state.sessionKey, state.currentConversationId, state.currentAgent, ); set({ conversations, sessionKey: null, currentConversationId: null, }); return conversations; }, switchConversation: (id: string, currentMessages: ChatMessage[]) => { const state = get(); const conversations = upsertActiveConversation( [...state.conversations], currentMessages, state.sessionKey, state.currentConversationId, state.currentAgent, ); const target = conversations.find(c => c.id === id); if (target) { set({ conversations, currentAgent: resolveAgentForConversation(target.agentId, state.agents), currentConversationId: target.id, }); return { conversations, messages: [...target.messages], sessionKey: target.sessionKey, currentAgent: resolveAgentForConversation(target.agentId, state.agents), currentConversationId: target.id, isStreaming: false, }; } return null; }, deleteConversation: (id: string, currentConversationId: string | null) => { const state = get(); const conversations = state.conversations.filter(c => c.id !== id); const resetMessages = currentConversationId === id; if (resetMessages) { set({ conversations, currentConversationId: null, sessionKey: null }); } else { set({ conversations }); } return { conversations, resetMessages }; }, setCurrentAgent: (agent: Agent, currentMessages: ChatMessage[]) => { const state = get(); if (state.currentAgent?.id === agent.id) { set({ currentAgent: agent }); return { conversations: state.conversations, currentAgent: agent, messages: currentMessages, sessionKey: state.sessionKey, isStreaming: false, currentConversationId: state.currentConversationId, }; } const conversations = upsertActiveConversation( [...state.conversations], currentMessages, state.sessionKey, state.currentConversationId, state.currentAgent, ); const agentConversation = conversations.find(c => c.agentId === agent.id || (agent.id === DEFAULT_AGENT.id && c.agentId === null) ); if (agentConversation) { set({ conversations, currentAgent: agent, currentConversationId: agentConversation.id, }); return { conversations, currentAgent: agent, messages: [...agentConversation.messages], sessionKey: agentConversation.sessionKey, isStreaming: false, currentConversationId: agentConversation.id, }; } set({ conversations, currentAgent: agent, sessionKey: null, currentConversationId: null, }); return { conversations, currentAgent: agent, messages: [], sessionKey: null, isStreaming: false, currentConversationId: null, }; }, syncAgents: (profiles: AgentProfileLike[]) => { const state = get(); const cloneAgents = profiles.length > 0 ? profiles.map(toChatAgent) : []; const agents = cloneAgents.length > 0 ? [DEFAULT_AGENT, ...cloneAgents] : [DEFAULT_AGENT]; const currentAgent = state.currentConversationId ? resolveAgentForConversation( state.conversations.find((conv) => conv.id === state.currentConversationId)?.agentId || null, agents ) : state.currentAgent ? agents.find((a) => a.id === state.currentAgent?.id) || agents[0] : agents[0]; set({ agents, currentAgent }); return { agents, currentAgent }; }, setCurrentModel: (model: string) => set({ currentModel: model }), upsertActiveConversation: (currentMessages: ChatMessage[]) => { const state = get(); const currentId = state.currentConversationId || null; // Strip transient fields (error, streaming, optimistic) before persistence // so old errors don't permanently show "重试" buttons on reload const cleanMessages = currentMessages.map(m => ({ ...m, error: undefined, streaming: undefined, optimistic: undefined, })); const conversations = upsertActiveConversation( [...state.conversations], cleanMessages, state.sessionKey, state.currentConversationId, state.currentAgent, ); // If this was a new conversation (no prior currentConversationId), // persist the generated ID so subsequent upserts update in-place // instead of creating duplicate entries. if (!currentId && conversations.length > 0) { set({ conversations, currentConversationId: conversations[0].id }); } else { set({ conversations }); } return conversations; }, getCurrentConversationId: () => get().currentConversationId, getCurrentAgent: () => get().currentAgent, getSessionKey: () => get().sessionKey, }), { name: 'zclaw-conversation-storage', storage: createJSONStorage(() => createIdbStorageAdapter()), partialize: (state) => ({ conversations: state.conversations, currentModel: state.currentModel, currentConversationId: state.currentConversationId, sessionKey: state.sessionKey, }), onRehydrateStorage: () => (state) => { if (state?.conversations) { for (const conv of state.conversations) { conv.createdAt = new Date(conv.createdAt); conv.updatedAt = new Date(conv.updatedAt); for (const msg of conv.messages) { msg.timestamp = new Date(msg.timestamp); msg.streaming = false; msg.optimistic = false; } } } }, }, ), );