Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Split monolithic chatStore.ts (908 lines) into 4 focused stores: - chatStore.ts: facade layer, owns messages[], backward-compatible selectors - conversationStore.ts: conversation CRUD, agent switching, IndexedDB persistence - streamStore.ts: streaming orchestration, chat mode, suggestions - messageStore.ts: token tracking Key fixes from 3-round deep audit: - C1: Fix Rust serde camelCase vs TS snake_case mismatch (toolStart/toolEnd/iterationStart) - C2: Fix IDB async rehydration race with persist.hasHydrated() subscribe - C3: Add sessionKey to partialize to survive page refresh - H3: Fix IDB migration retry on failure (don't set migrated=true in catch) - M3: Fix ToolCallStep deduplication (toolStart creates, toolEnd updates) - M-NEW-2: Clear sessionKey on cancelStream Also adds: - Rust backend stream cancellation via AtomicBool + cancel_stream command - IndexedDB storage adapter with one-time localStorage migration - HMR cleanup for cross-store subscriptions
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
/**
|
|
* 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<ConversationState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
conversations: [],
|
|
currentConversationId: null,
|
|
agents: [DEFAULT_AGENT],
|
|
currentAgent: DEFAULT_AGENT,
|
|
sessionKey: null,
|
|
currentModel: 'glm-4-flash',
|
|
|
|
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 conversations = upsertActiveConversation(
|
|
[...state.conversations], currentMessages, state.sessionKey,
|
|
state.currentConversationId, state.currentAgent,
|
|
);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
},
|
|
),
|
|
);
|