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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
|
||||
223
desktop/src/store/classroomStore.ts
Normal file
223
desktop/src/store/classroomStore.ts
Normal 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,
|
||||
}),
|
||||
}));
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user