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

View File

@@ -103,10 +103,6 @@ interface ChatState {
chatMode: ChatModeType;
// Follow-up suggestions
suggestions: string[];
// Artifacts (DeerFlow-inspired)
artifacts: import('../components/ai/ArtifactPanel').ArtifactFile[];
selectedArtifactId: string | null;
artifactPanelOpen: boolean;
addMessage: (message: Message) => void;
updateMessage: (id: string, updates: Partial<Message>) => void;
@@ -128,11 +124,6 @@ interface ChatState {
setSuggestions: (suggestions: string[]) => void;
addSubtask: (messageId: string, task: Subtask) => void;
updateSubtask: (messageId: string, taskId: string, updates: Partial<Subtask>) => void;
// Artifact management (DeerFlow-inspired)
addArtifact: (artifact: import('../components/ai/ArtifactPanel').ArtifactFile) => void;
selectArtifact: (id: string | null) => void;
setArtifactPanelOpen: (open: boolean) => void;
clearArtifacts: () => void;
}
function generateConvId(): string {
@@ -271,10 +262,6 @@ export const useChatStore = create<ChatState>()(
totalOutputTokens: 0,
chatMode: 'thinking' as ChatModeType,
suggestions: [],
artifacts: [],
selectedArtifactId: null,
artifactPanelOpen: false,
addMessage: (message: Message) =>
set((state) => ({ messages: [...state.messages, message] })),
@@ -401,6 +388,10 @@ export const useChatStore = create<ChatState>()(
},
sendMessage: async (content: string) => {
// Concurrency guard: prevent rapid double-click bypassing UI-level isStreaming check.
// React re-render is async — two clicks within the same frame both read isStreaming=false.
if (get().isStreaming) return;
const { addMessage, currentAgent, sessionKey } = get();
// Clear stale suggestions when user sends a new message
set({ suggestions: [] });
@@ -436,27 +427,10 @@ export const useChatStore = create<ChatState>()(
// Context compaction is handled by the kernel (AgentLoop with_compaction_threshold).
// Frontend no longer performs duplicate compaction — see crates/zclaw-runtime/src/compaction.rs.
// Build memory-enhanced content using layered context (L0/L1/L2)
let enhancedContent = content;
try {
const contextResult = await intelligenceClient.memory.buildContext(
agentId,
content,
500, // token budget for memory context
);
if (contextResult.systemPromptAddition) {
const systemPrompt = await intelligenceClient.identity.buildPrompt(
agentId,
contextResult.systemPromptAddition,
);
if (systemPrompt) {
enhancedContent = `<context>\n${systemPrompt}\n</context>\n\n${content}`;
}
}
} catch (err) {
log.warn('Memory context build failed, proceeding without:', err);
}
// Memory context injection is handled by backend MemoryMiddleware (before_completion),
// which injects relevant memories into the system prompt. Frontend must NOT duplicate
// this by embedding old conversation memories into the user message content — that causes
// context leaking (old conversations appearing in new chat thinking/output).
// Add user message (original content for display)
// Mark as optimistic -- will be cleared when server confirms via onComplete
@@ -504,7 +478,7 @@ export const useChatStore = create<ChatState>()(
// Try streaming first (ZCLAW WebSocket)
const result = await client.chatStream(
enhancedContent,
content,
{
onDelta: (delta: string) => {
// Update message content directly (works for both KernelClient and GatewayClient)
@@ -516,6 +490,15 @@ export const useChatStore = create<ChatState>()(
),
}));
},
onThinkingDelta: (delta: string) => {
set((s) => ({
messages: s.messages.map((m) =>
m.id === assistantId
? { ...m, thinkingContent: (m.thinkingContent || '') + delta }
: m
),
}));
},
onTool: (tool: string, input: string, output: string) => {
const step: ToolCallStep = {
id: `step_${Date.now()}_${generateRandomString(4)}`,
@@ -732,20 +715,6 @@ export const useChatStore = create<ChatState>()(
),
})),
// Artifact management (DeerFlow-inspired)
addArtifact: (artifact) =>
set((state) => ({
artifacts: [...state.artifacts, artifact],
selectedArtifactId: artifact.id,
artifactPanelOpen: true,
})),
selectArtifact: (id) => set({ selectedArtifactId: id }),
setArtifactPanelOpen: (open) => set({ artifactPanelOpen: open }),
clearArtifacts: () => set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
initStreamListener: () => {
const client = getClient();

View File

@@ -0,0 +1,223 @@
/**
* Classroom Store
*
* Zustand store for classroom generation, chat messages,
* and active classroom data. Uses Tauri invoke for backend calls.
*/
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type {
Classroom,
ClassroomChatMessage,
} from '../types/classroom';
import { createLogger } from '../lib/logger';
const log = createLogger('ClassroomStore');
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GenerationRequest {
topic: string;
document?: string;
style?: string;
level?: string;
targetDurationMinutes?: number;
sceneCount?: number;
customInstructions?: string;
language?: string;
}
export interface GenerationResult {
classroomId: string;
}
export interface GenerationProgressEvent {
topic: string;
stage: string;
progress: number;
activity: string;
}
// ---------------------------------------------------------------------------
// Store interface
// ---------------------------------------------------------------------------
export interface ClassroomState {
/** Currently generating classroom */
generating: boolean;
/** Generation progress stage */
progressStage: string | null;
progressPercent: number;
progressActivity: string;
/** Topic being generated (used for cancel) */
generatingTopic: string | null;
/** The active classroom */
activeClassroom: Classroom | null;
/** Whether the ClassroomPlayer overlay is open */
classroomOpen: boolean;
/** Chat messages for the active classroom */
chatMessages: ClassroomChatMessage[];
/** Whether chat is loading */
chatLoading: boolean;
/** Generation error message */
error: string | null;
}
export interface ClassroomActions {
startGeneration: (request: GenerationRequest) => Promise<string>;
cancelGeneration: () => void;
loadClassroom: (id: string) => Promise<void>;
setActiveClassroom: (classroom: Classroom) => void;
openClassroom: () => void;
closeClassroom: () => void;
sendChatMessage: (message: string, sceneContext?: string) => Promise<void>;
clearError: () => void;
reset: () => void;
}
export type ClassroomStore = ClassroomState & ClassroomActions;
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useClassroomStore = create<ClassroomStore>()((set, get) => ({
generating: false,
progressStage: null,
progressPercent: 0,
progressActivity: '',
generatingTopic: null,
activeClassroom: null,
classroomOpen: false,
chatMessages: [],
chatLoading: false,
error: null,
startGeneration: async (request) => {
set({
generating: true,
progressStage: 'agent_profiles',
progressPercent: 0,
progressActivity: 'Starting generation...',
generatingTopic: request.topic,
error: null,
});
// Listen for progress events from Rust
const unlisten = await listen<GenerationProgressEvent>('classroom:progress', (event) => {
const { stage, progress, activity } = event.payload;
set({
progressStage: stage,
progressPercent: progress,
progressActivity: activity,
});
});
try {
const result = await invoke<GenerationResult>('classroom_generate', { request });
set({ generating: false });
await get().loadClassroom(result.classroomId);
set({ classroomOpen: true });
return result.classroomId;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('Generation failed', { error: msg });
set({ generating: false, error: msg });
throw e;
} finally {
unlisten();
}
},
cancelGeneration: () => {
const topic = get().generatingTopic;
if (topic) {
invoke('classroom_cancel_generation', { topic }).catch(() => {});
}
set({ generating: false, generatingTopic: null });
},
loadClassroom: async (id) => {
try {
const classroom = await invoke<Classroom>('classroom_get', { classroomId: id });
set({ activeClassroom: classroom, chatMessages: [] });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('Failed to load classroom', { error: msg });
set({ error: msg });
}
},
setActiveClassroom: (classroom) => {
set({ activeClassroom: classroom, chatMessages: [], classroomOpen: true });
},
openClassroom: () => {
set({ classroomOpen: true });
},
closeClassroom: () => {
set({ classroomOpen: false });
},
sendChatMessage: async (message, sceneContext) => {
const classroom = get().activeClassroom;
if (!classroom) {
log.error('No active classroom');
return;
}
// Create a local user message for display
const userMsg: ClassroomChatMessage = {
id: `user-${Date.now()}`,
agentId: 'user',
agentName: '你',
agentAvatar: '👤',
content: message,
timestamp: Date.now(),
role: 'user',
color: '#3b82f6',
};
set((state) => ({
chatMessages: [...state.chatMessages, userMsg],
chatLoading: true,
}));
try {
const responses = await invoke<ClassroomChatMessage[]>('classroom_chat', {
request: {
classroomId: classroom.id,
userMessage: message,
sceneContext: sceneContext ?? null,
},
});
set((state) => ({
chatMessages: [...state.chatMessages, ...responses],
chatLoading: false,
}));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('Chat failed', { error: msg });
set({ chatLoading: false });
}
},
clearError: () => set({ error: null }),
reset: () => set({
generating: false,
progressStage: null,
progressPercent: 0,
progressActivity: '',
activeClassroom: null,
classroomOpen: false,
chatMessages: [],
chatLoading: false,
error: null,
}),
}));

View File

@@ -55,6 +55,17 @@ export type {
SessionOptions,
} from '../components/BrowserHand/templates/types';
// === Classroom Store ===
export { useClassroomStore } from './classroomStore';
export type {
ClassroomState,
ClassroomActions,
ClassroomStore,
GenerationRequest,
GenerationResult,
GenerationProgressEvent,
} from './classroomStore';
// === Store Initialization ===
import { getClient } from './connectionStore';

View File

@@ -536,6 +536,27 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
// Update last sync timestamp
localStorage.setItem(lastSyncKey, result.pulled_at);
log.info(`Synced ${result.configs.length} config items from SaaS`);
// Propagate Kernel-relevant configs to Rust backend
const kernelCategories = ['agent', 'llm'];
const kernelConfigs = result.configs.filter(
(c) => kernelCategories.includes(c.category) && c.value !== null
);
if (kernelConfigs.length > 0) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('kernel_apply_saas_config', {
configs: kernelConfigs.map((c) => ({
category: c.category,
key: c.key,
value: c.value,
})),
});
log.info(`Propagated ${kernelConfigs.length} Kernel configs to Rust backend`);
} catch (invokeErr: unknown) {
log.warn('Failed to propagate configs to Kernel (non-fatal):', invokeErr);
}
}
} catch (err: unknown) {
log.warn('Failed to sync config from SaaS:', err);
}