fix(desktop): DeerFlow UI — ChatArea refactor + ai-elements + dead CSS cleanup
ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
54
desktop/src/store/chat/artifactStore.ts
Normal file
54
desktop/src/store/chat/artifactStore.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* ArtifactStore — manages the artifact panel state.
|
||||
*
|
||||
* Extracted from chatStore.ts as part of the structured refactor.
|
||||
* This store has zero external dependencies — the simplest slice to extract.
|
||||
*
|
||||
* @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.5
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { ArtifactFile } from '../../components/ai/ArtifactPanel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ArtifactState {
|
||||
/** All artifacts generated in the current session */
|
||||
artifacts: ArtifactFile[];
|
||||
/** Currently selected artifact ID */
|
||||
selectedArtifactId: string | null;
|
||||
/** Whether the artifact panel is open */
|
||||
artifactPanelOpen: boolean;
|
||||
|
||||
// Actions
|
||||
addArtifact: (artifact: ArtifactFile) => void;
|
||||
selectArtifact: (id: string | null) => void;
|
||||
setArtifactPanelOpen: (open: boolean) => void;
|
||||
clearArtifacts: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useArtifactStore = create<ArtifactState>()((set) => ({
|
||||
artifacts: [],
|
||||
selectedArtifactId: null,
|
||||
artifactPanelOpen: false,
|
||||
|
||||
addArtifact: (artifact: ArtifactFile) =>
|
||||
set((state) => ({
|
||||
artifacts: [...state.artifacts, artifact],
|
||||
selectedArtifactId: artifact.id,
|
||||
artifactPanelOpen: true,
|
||||
})),
|
||||
|
||||
selectArtifact: (id: string | null) => set({ selectedArtifactId: id }),
|
||||
|
||||
setArtifactPanelOpen: (open: boolean) => set({ artifactPanelOpen: open }),
|
||||
|
||||
clearArtifacts: () =>
|
||||
set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
|
||||
}));
|
||||
368
desktop/src/store/chat/conversationStore.ts
Normal file
368
desktop/src/store/chat/conversationStore.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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 } from 'zustand/middleware';
|
||||
import { generateRandomString } from '../lib/crypto-utils';
|
||||
import { createLogger } from '../lib/logger';
|
||||
import type { Message } from './chatStore';
|
||||
|
||||
const log = createLogger('ConversationStore');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
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;
|
||||
}
|
||||
|
||||
// Re-export Message for internal use (avoids circular imports during migration)
|
||||
export type { Message };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConversationState {
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
agents: Agent[];
|
||||
currentAgent: Agent | null;
|
||||
sessionKey: string | null;
|
||||
currentModel: string;
|
||||
|
||||
// Actions
|
||||
newConversation: (currentMessages: Message[]) => Conversation[];
|
||||
switchConversation: (id: string, currentMessages: Message[]) => {
|
||||
conversations: Conversation[];
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
currentAgent: Agent;
|
||||
currentConversationId: string;
|
||||
isStreaming: boolean;
|
||||
} | null;
|
||||
deleteConversation: (id: string, currentConversationId: string | null) => {
|
||||
conversations: Conversation[];
|
||||
resetMessages: boolean;
|
||||
};
|
||||
setCurrentAgent: (agent: Agent, currentMessages: Message[]) => {
|
||||
conversations: Conversation[];
|
||||
currentAgent: Agent;
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
isStreaming: boolean;
|
||||
currentConversationId: string | null;
|
||||
};
|
||||
syncAgents: (profiles: AgentProfileLike[]) => {
|
||||
agents: Agent[];
|
||||
currentAgent: Agent;
|
||||
};
|
||||
setCurrentModel: (model: string) => void;
|
||||
upsertActiveConversation: (currentMessages: Message[]) => Conversation[];
|
||||
getCurrentConversationId: () => string | null;
|
||||
getCurrentAgent: () => Agent | null;
|
||||
getSessionKey: () => string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateConvId(): string {
|
||||
return `conv_${Date.now()}_${generateRandomString(4)}`;
|
||||
}
|
||||
|
||||
function deriveTitle(messages: Message[]): 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: Message[],
|
||||
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: Message[]) => {
|
||||
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: Message[]) => {
|
||||
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: Message[]) => {
|
||||
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: Message[]) => {
|
||||
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',
|
||||
partialize: (state) => ({
|
||||
conversations: state.conversations,
|
||||
currentModel: state.currentModel,
|
||||
currentAgentId: state.currentAgent?.id,
|
||||
currentConversationId: state.currentConversationId,
|
||||
}),
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user