Files
zclaw_openfang/desktop/src/store/chat/conversationStore.ts
iven a0d1392371
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
fix(ui): 5 项 E2E 测试 Bug 修复 — Agent 502 / 错误持久化 / 模型标记 / 侧面板 / 记忆页
- BUG-01: createFromTemplate 在 saas-relay 模式下 try-catch 跳过本地 Kernel
- BUG-02: upsertActiveConversation 持久化前剥离 error/streaming/optimistic 字段
- BUG-04: ModelSelector 添加 available 标记,ChatArea 追踪失败模型 ID
- BUG-05: VikingPanel 移除 status?.available 门控,不可用时 disabled + 重连按钮
- BUG-06: 侧面板 tooltip 改为"查看产物文件",空状态增加图标和说明
2026-04-16 19:12:21 +08:00

381 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: '', // 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;
}
}
}
},
},
),
);