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:
iven
2026-04-02 19:24:44 +08:00
parent d40c4605b2
commit 28299807b6
70 changed files with 4938 additions and 618 deletions

View 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 }),
}));

View 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;
}
}
}
},
},
),
);