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,223 @@
//! Classroom multi-agent chat commands
//!
//! - `classroom_chat` — send a message and receive multi-agent responses
//! - `classroom_chat_history` — retrieve chat history for a classroom
use std::sync::Arc;
use tokio::sync::Mutex;
use serde::{Deserialize, Serialize};
use tauri::State;
use zclaw_kernel::generation::{
AgentProfile, AgentRole,
ClassroomChatMessage, ClassroomChatState,
ClassroomChatRequest,
build_chat_prompt, parse_chat_responses,
};
use zclaw_runtime::CompletionRequest;
use super::ClassroomStore;
use crate::kernel_commands::KernelState;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Chat state store: classroom_id → chat state
pub type ChatStore = Arc<Mutex<std::collections::HashMap<String, ClassroomChatState>>>;
pub fn create_chat_state() -> ChatStore {
Arc::new(Mutex::new(std::collections::HashMap::new()))
}
// ---------------------------------------------------------------------------
// Request / Response
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomChatCmdRequest {
pub classroom_id: String,
pub user_message: String,
pub scene_context: Option<String>,
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/// Send a message in the classroom chat and get multi-agent responses.
#[tauri::command]
pub async fn classroom_chat(
store: State<'_, ClassroomStore>,
chat_store: State<'_, ChatStore>,
kernel_state: State<'_, KernelState>,
request: ClassroomChatCmdRequest,
) -> Result<Vec<ClassroomChatMessage>, String> {
if request.user_message.trim().is_empty() {
return Err("Message cannot be empty".to_string());
}
// Get classroom data
let classroom = {
let s = store.lock().await;
s.get(&request.classroom_id)
.cloned()
.ok_or_else(|| format!("Classroom '{}' not found", request.classroom_id))?
};
// Create user message
let user_msg = ClassroomChatMessage::user_message(&request.user_message);
// Get chat history for context
let history: Vec<ClassroomChatMessage> = {
let cs = chat_store.lock().await;
cs.get(&request.classroom_id)
.map(|s| s.messages.clone())
.unwrap_or_default()
};
// Try LLM-powered multi-agent responses, fallback to placeholder
let agent_responses = match generate_llm_responses(&kernel_state, &classroom.agents, &request.user_message, request.scene_context.as_deref(), &history).await {
Ok(responses) => responses,
Err(e) => {
tracing::warn!("LLM chat generation failed, using placeholders: {}", e);
generate_placeholder_responses(
&classroom.agents,
&request.user_message,
request.scene_context.as_deref(),
)
}
};
// Store in chat state
{
let mut cs = chat_store.lock().await;
let state = cs.entry(request.classroom_id.clone())
.or_insert_with(|| ClassroomChatState {
messages: vec![],
active: true,
});
state.messages.push(user_msg);
state.messages.extend(agent_responses.clone());
}
Ok(agent_responses)
}
/// Retrieve chat history for a classroom
#[tauri::command]
pub async fn classroom_chat_history(
chat_store: State<'_, ChatStore>,
classroom_id: String,
) -> Result<Vec<ClassroomChatMessage>, String> {
let cs = chat_store.lock().await;
Ok(cs.get(&classroom_id)
.map(|s| s.messages.clone())
.unwrap_or_default())
}
// ---------------------------------------------------------------------------
// Placeholder response generation
// ---------------------------------------------------------------------------
fn generate_placeholder_responses(
agents: &[AgentProfile],
user_message: &str,
scene_context: Option<&str>,
) -> Vec<ClassroomChatMessage> {
let mut responses = Vec::new();
// Teacher always responds
if let Some(teacher) = agents.iter().find(|a| a.role == AgentRole::Teacher) {
let context_hint = scene_context
.map(|ctx| format!("关于「{}」,", ctx))
.unwrap_or_default();
responses.push(ClassroomChatMessage::agent_message(
teacher,
&format!("{}这是一个很好的问题!让我来详细解释一下「{}」的核心概念...", context_hint, user_message),
));
}
// Assistant chimes in
if let Some(assistant) = agents.iter().find(|a| a.role == AgentRole::Assistant) {
responses.push(ClassroomChatMessage::agent_message(
assistant,
"我来补充一下要点 📌",
));
}
// One student responds
if let Some(student) = agents.iter().find(|a| a.role == AgentRole::Student) {
responses.push(ClassroomChatMessage::agent_message(
student,
&format!("谢谢老师!我大概理解了{}", user_message),
));
}
responses
}
// ---------------------------------------------------------------------------
// LLM-powered response generation
// ---------------------------------------------------------------------------
async fn generate_llm_responses(
kernel_state: &State<'_, KernelState>,
agents: &[AgentProfile],
user_message: &str,
scene_context: Option<&str>,
history: &[ClassroomChatMessage],
) -> Result<Vec<ClassroomChatMessage>, String> {
let driver = {
let ks = kernel_state.lock().await;
ks.as_ref()
.map(|k| k.driver())
.ok_or_else(|| "Kernel not initialized".to_string())?
};
if !driver.is_configured() {
return Err("LLM driver not configured".to_string());
}
// Build the chat request for prompt generation (include history)
let chat_request = ClassroomChatRequest {
classroom_id: String::new(),
user_message: user_message.to_string(),
agents: agents.to_vec(),
scene_context: scene_context.map(|s| s.to_string()),
history: history.to_vec(),
};
let prompt = build_chat_prompt(&chat_request);
let request = CompletionRequest {
model: "default".to_string(),
system: Some("你是一个课堂多智能体讨论的协调器。".to_string()),
messages: vec![zclaw_types::Message::User {
content: prompt,
}],
..Default::default()
};
let response = driver.complete(request).await
.map_err(|e| format!("LLM call failed: {}", e))?;
// Extract text from response
let text = response.content.iter()
.filter_map(|block| match block {
zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
let responses = parse_chat_responses(&text, agents);
if responses.is_empty() {
return Err("LLM returned no parseable agent responses".to_string());
}
Ok(responses)
}

View File

@@ -0,0 +1,152 @@
//! Classroom export commands
//!
//! - `classroom_export` — export classroom as HTML, Markdown, or JSON
use serde::{Deserialize, Serialize};
use tauri::State;
use zclaw_kernel::generation::Classroom;
use super::ClassroomStore;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomExportRequest {
pub classroom_id: String,
pub format: String, // "html" | "markdown" | "json"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomExportResponse {
pub content: String,
pub filename: String,
pub mime_type: String,
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn classroom_export(
store: State<'_, ClassroomStore>,
request: ClassroomExportRequest,
) -> Result<ClassroomExportResponse, String> {
let classroom = {
let s = store.lock().await;
s.get(&request.classroom_id)
.cloned()
.ok_or_else(|| format!("Classroom '{}' not found", request.classroom_id))?
};
match request.format.as_str() {
"json" => export_json(&classroom),
"html" => export_html(&classroom),
"markdown" | "md" => export_markdown(&classroom),
_ => Err(format!("Unsupported export format: '{}'. Use html, markdown, or json.", request.format)),
}
}
// ---------------------------------------------------------------------------
// Exporters
// ---------------------------------------------------------------------------
fn export_json(classroom: &Classroom) -> Result<ClassroomExportResponse, String> {
let content = serde_json::to_string_pretty(classroom)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
Ok(ClassroomExportResponse {
filename: format!("{}.json", sanitize_filename(&classroom.title)),
content,
mime_type: "application/json".to_string(),
})
}
fn export_html(classroom: &Classroom) -> Result<ClassroomExportResponse, String> {
let mut html = String::from(r#"<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">"#);
html.push_str(&format!("<title>{}</title>", html_escape(&classroom.title)));
html.push_str(r#"<style>body{font-family:system-ui,sans-serif;max-width:800px;margin:0 auto;padding:2rem;color:#333}h1{color:#4F46E5}h2{color:#7C3AED;border-bottom:2px solid #E5E7EB;padding-bottom:0.5rem}.scene{margin:2rem 0;padding:1rem;border-left:4px solid #4F46E5;background:#F9FAFB}.quiz{border-left-color:#F59E0B}.discussion{border-left-color:#10B981}.agent{display:inline-flex;align-items:center;gap:0.5rem;margin:0.25rem;padding:0.25rem 0.75rem;border-radius:9999px;font-size:0.875rem;font-weight:500}</style></head><body>"#);
html.push_str(&format!("<h1>{}</h1>", html_escape(&classroom.title)));
html.push_str(&format!("<p>{}</p>", html_escape(&classroom.description)));
// Agents
html.push_str("<h2>课堂角色</h2><div>");
for agent in &classroom.agents {
html.push_str(&format!(
r#"<span class="agent" style="background:{};color:white">{} {}</span>"#,
agent.color, agent.avatar, html_escape(&agent.name)
));
}
html.push_str("</div>");
// Scenes
html.push_str("<h2>课程内容</h2>");
for scene in &classroom.scenes {
let type_class = match scene.content.scene_type {
zclaw_kernel::generation::SceneType::Quiz => "quiz",
zclaw_kernel::generation::SceneType::Discussion => "discussion",
_ => "",
};
html.push_str(&format!(
r#"<div class="scene {}"><h3>{}</h3><p>类型: {:?} | 时长: {}秒</p></div>"#,
type_class,
html_escape(&scene.content.title),
scene.content.scene_type,
scene.content.duration_seconds
));
}
html.push_str("</body></html>");
Ok(ClassroomExportResponse {
filename: format!("{}.html", sanitize_filename(&classroom.title)),
content: html,
mime_type: "text/html".to_string(),
})
}
fn export_markdown(classroom: &Classroom) -> Result<ClassroomExportResponse, String> {
let mut md = String::new();
md.push_str(&format!("# {}\n\n", &classroom.title));
md.push_str(&format!("{}\n\n", &classroom.description));
md.push_str("## 课堂角色\n\n");
for agent in &classroom.agents {
md.push_str(&format!("- {} **{}** ({:?})\n", agent.avatar, agent.name, agent.role));
}
md.push('\n');
md.push_str("## 课程内容\n\n");
for (i, scene) in classroom.scenes.iter().enumerate() {
md.push_str(&format!("### {}. {}\n\n", i + 1, scene.content.title));
md.push_str(&format!("- 类型: `{:?}`\n", scene.content.scene_type));
md.push_str(&format!("- 时长: {}\n\n", scene.content.duration_seconds));
}
Ok(ClassroomExportResponse {
filename: format!("{}.md", sanitize_filename(&classroom.title)),
content: md,
mime_type: "text/markdown".to_string(),
})
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>()
.trim_matches('_')
.to_string()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}

View File

@@ -0,0 +1,286 @@
//! Classroom generation commands
//!
//! - `classroom_generate` — start 4-stage pipeline, emit progress events
//! - `classroom_generation_progress` — query current progress
//! - `classroom_cancel_generation` — cancel active generation
//! - `classroom_get` — retrieve generated classroom data
//! - `classroom_list` — list all generated classrooms
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use zclaw_kernel::generation::{
Classroom, GenerationPipeline, GenerationRequest as KernelGenRequest, GenerationStage,
TeachingStyle, DifficultyLevel,
};
use super::{ClassroomStore, GenerationTasks};
use crate::kernel_commands::KernelState;
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomGenerateRequest {
pub topic: String,
pub document: Option<String>,
pub style: Option<String>,
pub level: Option<String>,
pub target_duration_minutes: Option<u32>,
pub scene_count: Option<usize>,
pub custom_instructions: Option<String>,
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomGenerateResponse {
pub classroom_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomProgressResponse {
pub stage: String,
pub progress: u8,
pub activity: String,
pub items_progress: Option<(usize, usize)>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn parse_style(s: Option<&str>) -> TeachingStyle {
match s.unwrap_or("lecture") {
"discussion" => TeachingStyle::Discussion,
"pbl" => TeachingStyle::Pbl,
"flipped" => TeachingStyle::Flipped,
"socratic" => TeachingStyle::Socratic,
_ => TeachingStyle::Lecture,
}
}
fn parse_level(l: Option<&str>) -> DifficultyLevel {
match l.unwrap_or("intermediate") {
"beginner" => DifficultyLevel::Beginner,
"advanced" => DifficultyLevel::Advanced,
"expert" => DifficultyLevel::Expert,
_ => DifficultyLevel::Intermediate,
}
}
fn stage_name(stage: &GenerationStage) -> &'static str {
match stage {
GenerationStage::AgentProfiles => "agent_profiles",
GenerationStage::Outline => "outline",
GenerationStage::Scene => "scene",
GenerationStage::Complete => "complete",
}
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/// Start classroom generation (4-stage pipeline).
/// Progress events are emitted via `classroom:progress`.
/// Supports cancellation between stages by removing the task from GenerationTasks.
#[tauri::command]
pub async fn classroom_generate(
app: AppHandle,
store: State<'_, ClassroomStore>,
tasks: State<'_, GenerationTasks>,
kernel_state: State<'_, KernelState>,
request: ClassroomGenerateRequest,
) -> Result<ClassroomGenerateResponse, String> {
if request.topic.trim().is_empty() {
return Err("Topic is required".to_string());
}
let topic_clone = request.topic.clone();
let kernel_request = KernelGenRequest {
topic: request.topic.clone(),
document: request.document.clone(),
style: parse_style(request.style.as_deref()),
level: parse_level(request.level.as_deref()),
target_duration_minutes: request.target_duration_minutes.unwrap_or(30),
scene_count: request.scene_count,
custom_instructions: request.custom_instructions.clone(),
language: request.language.clone().or_else(|| Some("zh-CN".to_string())),
};
// Register generation task so cancellation can check it
{
use zclaw_kernel::generation::GenerationProgress;
let mut t = tasks.lock().await;
t.insert(topic_clone.clone(), GenerationProgress {
stage: zclaw_kernel::generation::GenerationStage::AgentProfiles,
progress: 0,
activity: "Starting generation...".to_string(),
items_progress: None,
eta_seconds: None,
});
}
// Get LLM driver from kernel if available, otherwise use placeholder mode
let pipeline = {
let ks = kernel_state.lock().await;
if let Some(kernel) = ks.as_ref() {
GenerationPipeline::with_driver(kernel.driver())
} else {
GenerationPipeline::new()
}
};
// Helper: check if cancelled
let is_cancelled = || {
let t = tasks.blocking_lock();
!t.contains_key(&topic_clone)
};
// Helper: emit progress event
let emit_progress = |stage: &str, progress: u8, activity: &str| {
let _ = app.emit("classroom:progress", serde_json::json!({
"topic": &topic_clone,
"stage": stage,
"progress": progress,
"activity": activity
}));
};
// ── Stage 0: Agent Profiles ──
emit_progress("agent_profiles", 5, "生成课堂角色...");
let agents = pipeline.generate_agent_profiles(&kernel_request).await;
emit_progress("agent_profiles", 25, "角色生成完成");
if is_cancelled() {
return Err("Generation cancelled".to_string());
}
// ── Stage 1: Outline ──
emit_progress("outline", 30, "分析主题,生成大纲...");
let outline = pipeline.generate_outline(&kernel_request).await
.map_err(|e| format!("Outline generation failed: {}", e))?;
emit_progress("outline", 50, &format!("大纲完成:{} 个场景", outline.len()));
if is_cancelled() {
return Err("Generation cancelled".to_string());
}
// ── Stage 2: Scenes (parallel) ──
emit_progress("scene", 55, &format!("并行生成 {} 个场景...", outline.len()));
let scenes = pipeline.generate_scenes(&outline).await
.map_err(|e| format!("Scene generation failed: {}", e))?;
if is_cancelled() {
return Err("Generation cancelled".to_string());
}
// ── Stage 3: Assemble ──
emit_progress("complete", 90, "组装课堂...");
// Build classroom directly (pipeline.build_classroom is private)
let total_duration: u32 = scenes.iter().map(|s| s.content.duration_seconds).sum();
let objectives = outline.iter()
.take(3)
.map(|item| format!("理解: {}", item.title))
.collect::<Vec<_>>();
let classroom_id = uuid::Uuid::new_v4().to_string();
let classroom = Classroom {
id: classroom_id.clone(),
title: format!("课堂: {}", kernel_request.topic),
description: format!("{:?} 风格课堂 — {}", kernel_request.style, kernel_request.topic),
topic: kernel_request.topic.clone(),
style: kernel_request.style,
level: kernel_request.level,
total_duration,
objectives,
scenes,
agents,
metadata: zclaw_kernel::generation::ClassroomMetadata {
generated_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64,
source_document: kernel_request.document.map(|_| "user_document".to_string()),
model: None,
version: "2.0.0".to_string(),
custom: serde_json::Map::new(),
},
};
// Store classroom
{
let mut s = store.lock().await;
s.insert(classroom_id.clone(), classroom);
}
// Clear generation task
{
let mut t = tasks.lock().await;
t.remove(&topic_clone);
}
// Emit completion
emit_progress("complete", 100, "课堂生成完成");
Ok(ClassroomGenerateResponse {
classroom_id,
})
}
/// Get current generation progress for a topic
#[tauri::command]
pub async fn classroom_generation_progress(
tasks: State<'_, GenerationTasks>,
topic: String,
) -> Result<ClassroomProgressResponse, String> {
let t = tasks.lock().await;
let progress = t.get(&topic);
Ok(ClassroomProgressResponse {
stage: progress.map(|p| stage_name(&p.stage).to_string()).unwrap_or_else(|| "none".to_string()),
progress: progress.map(|p| p.progress).unwrap_or(0),
activity: progress.map(|p| p.activity.clone()).unwrap_or_default(),
items_progress: progress.and_then(|p| p.items_progress),
})
}
/// Cancel an active generation
#[tauri::command]
pub async fn classroom_cancel_generation(
tasks: State<'_, GenerationTasks>,
topic: String,
) -> Result<(), String> {
let mut t = tasks.lock().await;
t.remove(&topic);
Ok(())
}
/// Retrieve a generated classroom by ID
#[tauri::command]
pub async fn classroom_get(
store: State<'_, ClassroomStore>,
classroom_id: String,
) -> Result<Classroom, String> {
let s = store.lock().await;
s.get(&classroom_id)
.cloned()
.ok_or_else(|| format!("Classroom '{}' not found", classroom_id))
}
/// List all generated classrooms (id + title only)
#[tauri::command]
pub async fn classroom_list(
store: State<'_, ClassroomStore>,
) -> Result<Vec<serde_json::Value>, String> {
let s = store.lock().await;
Ok(s.values().map(|c| serde_json::json!({
"id": c.id,
"title": c.title,
"topic": c.topic,
"totalDuration": c.total_duration,
"sceneCount": c.scenes.len(),
})).collect())
}

View File

@@ -0,0 +1,41 @@
//! Classroom generation and interaction commands
//!
//! Tauri commands for the OpenMAIC-style interactive classroom:
//! - Generate classroom (4-stage pipeline with progress events)
//! - Multi-agent chat
//! - Export (HTML/Markdown/JSON)
use std::sync::Arc;
use tokio::sync::Mutex;
use zclaw_kernel::generation::Classroom;
pub mod chat;
pub mod export;
pub mod generate;
// ---------------------------------------------------------------------------
// Shared state types
// ---------------------------------------------------------------------------
/// In-memory classroom store: classroom_id → Classroom
pub type ClassroomStore = Arc<Mutex<std::collections::HashMap<String, Classroom>>>;
/// Active generation tasks: topic → progress
pub type GenerationTasks = Arc<Mutex<std::collections::HashMap<String, zclaw_kernel::generation::GenerationProgress>>>;
// Re-export chat state type
// Re-export chat state type — used by lib.rs to construct managed state
#[allow(unused_imports)]
pub use chat::ChatStore;
// ---------------------------------------------------------------------------
// State constructors
// ---------------------------------------------------------------------------
pub fn create_classroom_state() -> ClassroomStore {
Arc::new(Mutex::new(std::collections::HashMap::new()))
}
pub fn create_generation_tasks() -> GenerationTasks {
Arc::new(Mutex::new(std::collections::HashMap::new()))
}

View File

@@ -258,11 +258,18 @@ impl AgentIdentityManager {
if !identity.instructions.is_empty() {
sections.push(identity.instructions.clone());
}
if !identity.user_profile.is_empty()
&& identity.user_profile != default_user_profile()
{
sections.push(format!("## 用户画像\n{}", identity.user_profile));
}
// NOTE: user_profile injection is intentionally disabled.
// The reflection engine may accumulate overly specific details from past
// conversations (e.g., "广东光华", "汕头玩具产业") into user_profile.
// These details then leak into every new conversation's system prompt,
// causing the model to think about old topics instead of the current query.
// Memory injection should only happen via MemoryMiddleware with relevance
// filtering, not unconditionally via user_profile.
// if !identity.user_profile.is_empty()
// && identity.user_profile != default_user_profile()
// {
// sections.push(format!("## 用户画像\n{}", identity.user_profile));
// }
if let Some(ctx) = memory_context {
sections.push(ctx.to_string());
}

View File

@@ -34,6 +34,7 @@ pub struct ChatResponse {
#[serde(rename_all = "camelCase", tag = "type")]
pub enum StreamChatEvent {
Delta { delta: String },
ThinkingDelta { delta: String },
ToolStart { name: String, input: serde_json::Value },
ToolEnd { name: String, output: serde_json::Value },
IterationStart { iteration: usize, max_iterations: usize },
@@ -218,6 +219,10 @@ pub async fn agent_chat_stream(
tracing::trace!("[agent_chat_stream] Delta: {} bytes", delta.len());
StreamChatEvent::Delta { delta: delta.clone() }
}
LoopEvent::ThinkingDelta(delta) => {
tracing::trace!("[agent_chat_stream] ThinkingDelta: {} bytes", delta.len());
StreamChatEvent::ThinkingDelta { delta: delta.clone() }
}
LoopEvent::ToolStart { name, input } => {
tracing::debug!("[agent_chat_stream] ToolStart: {}", name);
if name.starts_with("hand_") {

View File

@@ -249,3 +249,130 @@ pub async fn kernel_shutdown(
Ok(())
}
/// Apply SaaS-synced configuration to the Kernel config file.
///
/// Writes relevant config values (agent, llm categories) to the TOML config file.
/// The changes take effect on the next Kernel restart.
#[tauri::command]
pub async fn kernel_apply_saas_config(
configs: Vec<SaasConfigItem>,
) -> Result<u32, String> {
use std::io::Write;
let config_path = zclaw_kernel::config::KernelConfig::find_config_path()
.ok_or_else(|| "No config file path found".to_string())?;
// Read existing config or create empty
let existing = if config_path.exists() {
std::fs::read_to_string(&config_path).unwrap_or_default()
} else {
String::new()
};
let mut updated = existing;
let mut applied: u32 = 0;
for config in &configs {
// Only process kernel-relevant categories
if !matches!(config.category.as_str(), "agent" | "llm") {
continue;
}
// Write key=value to the [llm] or [agent] section
let section = &config.category;
let key = config.key.replace('.', "_");
let value = &config.value;
// Simple TOML patching: find or create section, update key
let section_header = format!("[{}]", section);
let line_to_set = format!("{} = {}", key, toml_quote_value(value));
if let Some(section_start) = updated.find(&section_header) {
// Section exists, find or add the key within it
let after_header = section_start + section_header.len();
let next_section = updated[after_header..].find("\n[")
.map(|i| after_header + i)
.unwrap_or(updated.len());
let section_content = &updated[after_header..next_section];
let key_prefix = format!("\n{} =", key);
let key_prefix_alt = format!("\n{}=", key);
if let Some(key_pos) = section_content.find(&key_prefix)
.or_else(|| section_content.find(&key_prefix_alt))
{
// Key exists, replace the line
let line_start = after_header + key_pos + 1; // skip \n
let line_end = updated[line_start..].find('\n')
.map(|i| line_start + i)
.unwrap_or(updated.len());
updated = format!(
"{}{}{}\n{}",
&updated[..line_start],
line_to_set,
if line_end < updated.len() { "" } else { "" },
&updated[line_end..]
);
// Remove the extra newline if line_end included one
updated = updated.replace(&format!("{}\n\n", line_to_set), &format!("{}\n", line_to_set));
} else {
// Key doesn't exist, append to section
updated.insert_str(next_section, format!("\n{}", line_to_set).as_str());
}
} else {
// Section doesn't exist, append it
updated = format!("{}\n{}\n{}\n", updated.trim_end(), section_header, line_to_set);
}
applied += 1;
}
if applied > 0 {
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {}", e))?;
}
let mut file = std::fs::File::create(&config_path)
.map_err(|e| format!("Failed to write config: {}", e))?;
file.write_all(updated.as_bytes())
.map_err(|e| format!("Failed to write config: {}", e))?;
tracing::info!(
"[kernel_apply_saas_config] Applied {} config items to {:?} (restart required)",
applied,
config_path
);
}
Ok(applied)
}
/// Single config item from SaaS sync
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaasConfigItem {
pub category: String,
pub key: String,
pub value: String,
}
/// Quote a value for TOML format
fn toml_quote_value(value: &str) -> String {
// Try to parse as number or boolean
if value == "true" || value == "false" {
return value.to_string();
}
if let Ok(n) = value.parse::<i64>() {
return n.to_string();
}
if let Ok(n) = value.parse::<f64>() {
return n.to_string();
}
// Handle multi-line strings with TOML triple-quote syntax
if value.contains('\n') {
return format!("\"\"\"\n{}\"\"\"", value.replace('\\', "\\\\").replace("\"\"\"", "'\"'\"'\""));
}
// Default: quote as string
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}

View File

@@ -34,6 +34,9 @@ mod kernel_commands;
// Pipeline commands (DSL-based workflows)
mod pipeline_commands;
// Classroom generation and interaction commands
mod classroom_commands;
// Gateway sub-modules (runtime, config, io, commands)
mod gateway;
@@ -99,6 +102,11 @@ pub fn run() {
// Initialize Pipeline state (DSL-based workflows)
let pipeline_state = pipeline_commands::create_pipeline_state();
// Initialize Classroom state (generation + chat)
let classroom_state = classroom_commands::create_classroom_state();
let classroom_chat_state = classroom_commands::chat::create_chat_state();
let classroom_gen_tasks = classroom_commands::create_generation_tasks();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(browser_state)
@@ -110,11 +118,15 @@ pub fn run() {
.manage(scheduler_state)
.manage(kernel_commands::SessionStreamGuard::default())
.manage(pipeline_state)
.manage(classroom_state)
.manage(classroom_chat_state)
.manage(classroom_gen_tasks)
.invoke_handler(tauri::generate_handler![
// Internal ZCLAW Kernel commands (preferred)
kernel_commands::lifecycle::kernel_init,
kernel_commands::lifecycle::kernel_status,
kernel_commands::lifecycle::kernel_shutdown,
kernel_commands::lifecycle::kernel_apply_saas_config,
kernel_commands::agent::agent_create,
kernel_commands::agent::agent_list,
kernel_commands::agent::agent_get,
@@ -300,7 +312,16 @@ pub fn run() {
intelligence::identity::identity_get_snapshots,
intelligence::identity::identity_restore_snapshot,
intelligence::identity::identity_list_agents,
intelligence::identity::identity_delete_agent
intelligence::identity::identity_delete_agent,
// Classroom generation and interaction commands
classroom_commands::generate::classroom_generate,
classroom_commands::generate::classroom_generation_progress,
classroom_commands::generate::classroom_cancel_generation,
classroom_commands::generate::classroom_get,
classroom_commands::generate::classroom_list,
classroom_commands::chat::classroom_chat,
classroom_commands::chat::classroom_chat_history,
classroom_commands::export::classroom_export
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -29,6 +29,7 @@ import { useProposalNotifications, ProposalNotificationHandler } from './lib/use
import { useToast } from './components/ui/Toast';
import type { Clone } from './store/agentStore';
import { createLogger } from './lib/logger';
import { startOfflineMonitor } from './store/offlineStore';
const log = createLogger('App');
@@ -86,6 +87,8 @@ function App() {
useEffect(() => {
document.title = 'ZCLAW';
const stopOfflineMonitor = startOfflineMonitor();
return () => { stopOfflineMonitor(); };
}, []);
// Restore SaaS session from OS keyring on startup (before auth gate)
@@ -152,8 +155,11 @@ function App() {
let mounted = true;
const bootstrap = async () => {
// 未登录时不启动 bootstrap
if (!useSaaSStore.getState().isLoggedIn) return;
// 未登录时不启动 bootstrap,直接结束 loading
if (!useSaaSStore.getState().isLoggedIn) {
setBootstrapping(false);
return;
}
try {
// Step 1: Check and start local gateway in Tauri environment

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObjec
import { motion, AnimatePresence } from 'framer-motion';
import { List, type ListImperativeAPI } from 'react-window';
import { useChatStore, Message } from '../store/chatStore';
import { useArtifactStore } from '../store/chat/artifactStore';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
@@ -12,6 +13,8 @@ import { ArtifactPanel } from './ai/ArtifactPanel';
import { ToolCallChain } from './ai/ToolCallChain';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { ClassroomPlayer } from './classroom_player';
import { useClassroomStore } from '../store/classroomStore';
// MessageSearch temporarily removed during DeerFlow redesign
import { OfflineIndicator } from './OfflineIndicator';
import {
@@ -45,11 +48,14 @@ export function ChatArea() {
messages, currentAgent, isStreaming, isLoading, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation, chatMode, setChatMode, suggestions,
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
totalInputTokens, totalOutputTokens,
} = useChatStore();
const {
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
} = useArtifactStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const { activeClassroom, classroomOpen, closeClassroom, generating, progressPercent, progressActivity, error: classroomError, clearError: clearClassroomError } = useClassroomStore();
const clones = useAgentStore((s) => s.clones);
const models = useConfigStore((s) => s.models);
@@ -203,9 +209,76 @@ export function ChatArea() {
);
return (
<div className="relative h-full">
{/* Generation progress overlay */}
<AnimatePresence>
{generating && (
<motion.div
key="generation-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm flex items-center justify-center"
>
<div className="text-center space-y-4">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-500 rounded-full animate-spin mx-auto" />
<div>
<p className="text-lg font-medium text-gray-900 dark:text-white">
...
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{progressActivity || '准备中...'}
</p>
</div>
{progressPercent > 0 && (
<div className="w-64 mx-auto">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{progressPercent}%</p>
</div>
)}
<button
onClick={() => useClassroomStore.getState().cancelGeneration()}
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg"
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ClassroomPlayer overlay */}
<AnimatePresence>
{classroomOpen && activeClassroom && (
<motion.div
key="classroom-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 bg-white dark:bg-gray-900"
>
<ClassroomPlayer
onClose={closeClassroom}
/>
</motion.div>
)}
</AnimatePresence>
<ResizableChatLayout
chatPanel={
<div className="flex flex-col h-full">
{/* Classroom generation error banner */}
{classroomError && (
<div className="mx-4 mt-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center justify-between text-sm">
<span className="text-red-600 dark:text-red-400">: {classroomError}</span>
<button onClick={clearClassroomError} className="text-red-400 hover:text-red-600 ml-3 text-xs"></button>
</div>
)}
{/* Header — DeerFlow-style: minimal */}
<div className="h-14 border-b border-transparent flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2 text-sm text-gray-500">
@@ -298,6 +371,7 @@ export function ChatArea() {
getHeight={getHeight}
onHeightChange={setHeight}
messageRefs={messageRefs}
setInput={setInput}
/>
) : (
messages.map((message) => (
@@ -310,7 +384,7 @@ export function ChatArea() {
layout
transition={defaultTransition}
>
<MessageBubble message={message} />
<MessageBubble message={message} setInput={setInput} />
</motion.div>
))
)}
@@ -433,19 +507,16 @@ export function ChatArea() {
rightPanelOpen={artifactPanelOpen}
onRightPanelToggle={setArtifactPanelOpen}
/>
</div>
);
}
function MessageBubble({ message }: { message: Message }) {
// Tool messages are now absorbed into the assistant message's toolSteps chain.
// Legacy standalone tool messages (from older sessions) still render as before.
function MessageBubble({ message, setInput }: { message: Message; setInput: (text: string) => void }) {
if (message.role === 'tool') {
return null;
}
const isUser = message.role === 'user';
// 思考中状态streaming 且内容为空时显示思考指示器
const isThinking = message.streaming && !message.content;
// Download message as Markdown file
@@ -518,7 +589,20 @@ function MessageBubble({ message }: { message: Message }) {
: '...'}
</div>
{message.error && (
<p className="text-xs text-red-500 mt-2">{message.error}</p>
<div className="flex items-center gap-2 mt-2">
<p className="text-xs text-red-500">{message.error}</p>
<button
onClick={() => {
const text = typeof message.content === 'string' ? message.content : '';
if (text) {
setInput(text);
}
}}
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
>
</button>
</div>
)}
{/* Download button for AI messages - show on hover */}
{!isUser && message.content && !message.streaming && (
@@ -543,6 +627,7 @@ interface VirtualizedMessageRowProps {
message: Message;
onHeightChange: (height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
}
/**
@@ -553,6 +638,7 @@ function VirtualizedMessageRow({
message,
onHeightChange,
messageRefs,
setInput,
style,
ariaAttributes,
}: VirtualizedMessageRowProps & {
@@ -587,7 +673,7 @@ function VirtualizedMessageRow({
className="py-3"
{...ariaAttributes}
>
<MessageBubble message={message} />
<MessageBubble message={message} setInput={setInput} />
</div>
);
}
@@ -598,6 +684,7 @@ interface VirtualizedMessageListProps {
getHeight: (id: string, role: string) => number;
onHeightChange: (id: string, height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
}
/**
@@ -610,6 +697,7 @@ function VirtualizedMessageList({
getHeight,
onHeightChange,
messageRefs,
setInput,
}: VirtualizedMessageListProps) {
// Row component for react-window v2
const RowComponent = (props: {
@@ -625,6 +713,7 @@ function VirtualizedMessageList({
message={messages[props.index]}
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
messageRefs={messageRefs}
setInput={setInput}
style={props.style}
ariaAttributes={props.ariaAttributes}
/>

View File

@@ -67,6 +67,7 @@ interface ClassroomPreviewerProps {
data: ClassroomData;
onClose?: () => void;
onExport?: (format: 'pptx' | 'html' | 'pdf') => void;
onOpenFullPlayer?: () => void;
}
// === Sub-Components ===
@@ -271,6 +272,7 @@ function OutlinePanel({
export function ClassroomPreviewer({
data,
onExport,
onOpenFullPlayer,
}: ClassroomPreviewerProps) {
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
@@ -398,6 +400,15 @@ export function ClassroomPreviewer({
</p>
</div>
<div className="flex items-center gap-2">
{onOpenFullPlayer && (
<button
onClick={onOpenFullPlayer}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded-md hover:bg-indigo-200 dark:hover:bg-indigo-900/50 transition-colors"
>
<Play className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleExport('pptx')}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-md hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"

View File

@@ -22,13 +22,16 @@ import {
} from '../lib/personality-presets';
import type { Clone } from '../store/agentStore';
import { useChatStore } from '../store/chatStore';
import { useClassroomStore } from '../store/classroomStore';
import { useHandStore } from '../store/handStore';
// Quick action chip definitions — DeerFlow-style colored pills
// handId maps to actual Hand names in the runtime
const QUICK_ACTIONS = [
{ key: 'surprise', label: '小惊喜', icon: Sparkles, color: 'text-orange-500' },
{ key: 'write', label: '写作', icon: PenLine, color: 'text-blue-500' },
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500' },
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500' },
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500', handId: 'researcher' },
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500', handId: 'collector' },
{ key: 'learn', label: '学习', icon: GraduationCap, color: 'text-indigo-500' },
];
@@ -69,6 +72,41 @@ export function FirstConversationPrompt({
});
const handleQuickAction = (key: string) => {
if (key === 'learn') {
// Trigger classroom generation flow
const classroomStore = useClassroomStore.getState();
// Extract a clean topic from the prompt
const prompt = QUICK_ACTION_PROMPTS[key] || '';
const topic = prompt
.replace(/^[你我].*?(想了解|想学|了解|学习|分析|研究|探索)\s*/g, '')
.replace(/[,。?!].*$/g, '')
.replace(/^(能|帮|请|可不可以).*/g, '')
.trim() || '互动课堂';
classroomStore.startGeneration({
topic,
style: 'lecture',
level: 'intermediate',
language: 'zh-CN',
}).catch(() => {
// Error is already stored in classroomStore.error and displayed in ChatArea
});
return;
}
// Check if this action maps to a Hand
const actionDef = QUICK_ACTIONS.find((a) => a.key === key);
if (actionDef?.handId) {
const handStore = useHandStore.getState();
handStore.triggerHand(actionDef.handId, {
action: key === 'research' ? 'report' : 'collect',
query: { query: QUICK_ACTION_PROMPTS[key] || '' },
}).catch(() => {
// Fallback: fill prompt into input bar
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
});
return;
}
const prompt = QUICK_ACTION_PROMPTS[key] || '你好!';
onSelectSuggestion?.(prompt);
};

View File

@@ -25,6 +25,8 @@ import { PipelineRunResponse } from '../lib/pipeline-client';
import { useToast } from './ui/Toast';
import DOMPurify from 'dompurify';
import { ClassroomPreviewer, type ClassroomData } from './ClassroomPreviewer';
import { useClassroomStore } from '../store/classroomStore';
import { adaptToClassroom } from '../lib/classroom-adapter';
// === Types ===
@@ -286,6 +288,11 @@ export function PipelineResultPreview({
// Handle export
handleClassroomExport(format, classroomData);
}}
onOpenFullPlayer={() => {
const classroom = adaptToClassroom(classroomData);
useClassroomStore.getState().setActiveClassroom(classroom);
useClassroomStore.getState().openClassroom();
}}
/>
</div>
);

View File

@@ -109,7 +109,7 @@ export function Conversation({ children, className = '' }: ConversationProps) {
<div
ref={containerRef}
onScroll={handleScroll}
className={`overflow-y-auto custom-scrollbar ${className}`}
className={`overflow-y-auto custom-scrollbar min-h-0 ${className}`}
>
{children}
</div>

View File

@@ -62,7 +62,7 @@ export function ResizableChatLayout({
if (!rightPanelOpen || !rightPanel) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
<div className="h-full flex flex-col overflow-hidden relative">
{chatPanel}
<button
onClick={handleToggle}
@@ -76,7 +76,7 @@ export function ResizableChatLayout({
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="h-full flex flex-col overflow-hidden">
<Group
orientation="horizontal"
onLayoutChanged={(layout) => savePanelSizes(layout)}

View File

@@ -0,0 +1,121 @@
/**
* AgentChat — Multi-agent chat panel for classroom interaction.
*
* Displays chat bubbles from different agents (teacher, assistant, students)
* with distinct colors and avatars. Users can send messages.
*/
import { useState, useRef, useEffect } from 'react';
import type { ClassroomChatMessage as ChatMessage, AgentProfile } from '../../types/classroom';
interface AgentChatProps {
messages: ChatMessage[];
agents: AgentProfile[];
loading: boolean;
onSend: (message: string) => Promise<void>;
}
export function AgentChat({ messages, loading, onSend }: AgentChatProps) {
const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
const trimmed = input.trim();
if (!trimmed || loading) return;
setInput('');
await onSend(trimmed);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex flex-col w-80 border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
{/* Header */}
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Classroom Chat
</h3>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-auto p-3 space-y-3">
{messages.length === 0 ? (
<div className="text-center text-xs text-gray-400 py-8">
Start a conversation with the classroom
</div>
) : (
messages.map((msg) => {
const isUser = msg.role === 'user';
return (
<div key={msg.id} className={`flex gap-2 ${isUser ? 'justify-end' : ''}`}>
{/* Avatar */}
{!isUser && (
<span
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: msg.color + '20' }}
>
{msg.agentAvatar}
</span>
)}
{/* Message bubble */}
<div className={`max-w-[200px] ${isUser ? 'text-right' : ''}`}>
{!isUser && (
<span className="text-xs font-medium" style={{ color: msg.color }}>
{msg.agentName}
</span>
)}
<div
className={`text-sm px-3 py-1.5 rounded-lg ${
isUser
? 'bg-indigo-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
}`}
>
{msg.content}
</div>
</div>
</div>
);
})
)}
</div>
{/* Input */}
<div className="px-3 py-2 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question..."
disabled={loading}
className="flex-1 px-2 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
className="px-3 py-1.5 text-sm rounded bg-indigo-500 text-white disabled:opacity-50 hover:bg-indigo-600"
>
{loading ? '...' : 'Send'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
/**
* ClassroomPlayer — Full-screen interactive classroom player.
*
* Layout: Notes sidebar | Main stage | Chat panel
* Top: Title + Agent avatars
* Bottom: Scene navigation + playback controls
*/
import { useState, useCallback, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useClassroom } from '../../hooks/useClassroom';
import { SceneRenderer } from './SceneRenderer';
import { AgentChat } from './AgentChat';
import { NotesSidebar } from './NotesSidebar';
import { TtsPlayer } from './TtsPlayer';
import { Download } from 'lucide-react';
interface ClassroomPlayerProps {
onClose: () => void;
}
export function ClassroomPlayer({ onClose }: ClassroomPlayerProps) {
const {
activeClassroom,
chatMessages,
chatLoading,
sendChatMessage,
} = useClassroom();
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [chatOpen, setChatOpen] = useState(true);
const [exporting, setExporting] = useState(false);
const classroom = activeClassroom;
const scenes = classroom?.scenes ?? [];
const agents = classroom?.agents ?? [];
const currentScene = scenes[currentSceneIndex] ?? null;
// Navigate to next/prev scene
const goNext = useCallback(() => {
setCurrentSceneIndex((i) => Math.min(i + 1, scenes.length - 1));
}, [scenes.length]);
const goPrev = useCallback(() => {
setCurrentSceneIndex((i) => Math.max(i - 1, 0));
}, []);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') goNext();
else if (e.key === 'ArrowLeft') goPrev();
else if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [goNext, goPrev, onClose]);
// Chat handler
const handleChatSend = useCallback(async (message: string) => {
const sceneContext = currentScene?.content.title;
await sendChatMessage(message, sceneContext);
}, [sendChatMessage, currentScene]);
// Export handler
const handleExport = useCallback(async (format: 'html' | 'markdown' | 'json') => {
if (!classroom) return;
setExporting(true);
try {
const result = await invoke<{ content: string; filename: string; mimeType: string }>(
'classroom_export',
{ request: { classroomId: classroom.id, format } }
);
// Download the exported file
const blob = new Blob([result.content], { type: result.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error('Export failed:', e);
} finally {
setExporting(false);
}
}, [classroom]);
if (!classroom) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
No classroom loaded
</div>
);
}
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Close classroom"
>
</button>
<h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-md">
{classroom.title}
</h1>
</div>
{/* Agent avatars */}
<div className="flex items-center gap-1">
{agents.map((agent) => (
<span
key={agent.id}
className="inline-flex items-center justify-center w-8 h-8 rounded-full text-sm"
style={{ backgroundColor: agent.color + '20', color: agent.color }}
title={agent.name}
>
{agent.avatar}
</span>
))}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className={`px-2 py-1 rounded text-xs ${sidebarOpen ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500'}`}
>
Notes
</button>
<button
onClick={() => setChatOpen(!chatOpen)}
className={`px-2 py-1 rounded text-xs ${chatOpen ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500'}`}
>
Chat
</button>
{/* Export dropdown */}
<div className="relative group">
<button
disabled={exporting}
className="px-2 py-1 rounded text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
title="导出课堂"
>
<Download className="w-3.5 h-3.5" />
{exporting ? '...' : '导出'}
</button>
<div className="absolute right-0 top-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg hidden group-hover:block z-10">
<button onClick={() => handleExport('html')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">HTML</button>
<button onClick={() => handleExport('markdown')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Markdown</button>
<button onClick={() => handleExport('json')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">JSON</button>
</div>
</div>
</div>
</header>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Notes sidebar */}
{sidebarOpen && (
<NotesSidebar
scenes={scenes}
currentIndex={currentSceneIndex}
onSelectScene={setCurrentSceneIndex}
/>
)}
{/* Main stage */}
<main className="flex-1 overflow-auto p-4">
{currentScene ? (
<SceneRenderer key={currentScene.id} scene={currentScene} agents={agents} />
) : (
<div className="flex items-center justify-center h-full text-gray-400">
No scenes available
</div>
)}
</main>
{/* Chat panel */}
{chatOpen && (
<AgentChat
messages={chatMessages}
agents={agents}
loading={chatLoading}
onSend={handleChatSend}
/>
)}
</div>
{/* Bottom navigation */}
<footer className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="flex items-center gap-2">
<button
onClick={goPrev}
disabled={currentSceneIndex === 0}
className="px-3 py-1 rounded text-sm bg-gray-100 dark:bg-gray-700 disabled:opacity-50"
>
Previous
</button>
<span className="text-sm text-gray-500">
{currentSceneIndex + 1} / {scenes.length}
</span>
<button
onClick={goNext}
disabled={currentSceneIndex >= scenes.length - 1}
className="px-3 py-1 rounded text-sm bg-gray-100 dark:bg-gray-700 disabled:opacity-50"
>
Next
</button>
</div>
{/* TTS + Scene info */}
<div className="flex items-center gap-3">
{currentScene?.content.notes && (
<TtsPlayer text={currentScene.content.notes} />
)}
<div className="text-xs text-gray-400">
{currentScene?.content.sceneType ?? ''}
{currentScene?.content.durationSeconds
? ` · ${Math.floor(currentScene.content.durationSeconds / 60)}:${String(currentScene.content.durationSeconds % 60).padStart(2, '0')}`
: ''}
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,71 @@
/**
* NotesSidebar — Scene outline navigation + notes.
*
* Left panel showing all scenes as clickable items with notes.
*/
import type { GeneratedScene } from '../../types/classroom';
interface NotesSidebarProps {
scenes: GeneratedScene[];
currentIndex: number;
onSelectScene: (index: number) => void;
}
export function NotesSidebar({ scenes, currentIndex, onSelectScene }: NotesSidebarProps) {
return (
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-auto">
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Outline
</h3>
</div>
<nav className="py-1">
{scenes.map((scene, i) => {
const isActive = i === currentIndex;
const typeColor = getTypeColor(scene.content.sceneType);
return (
<button
key={scene.id}
onClick={() => onSelectScene(i)}
className={`w-full text-left px-3 py-2 text-sm border-l-2 transition-colors ${
isActive
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: typeColor }}
/>
<span className={`font-medium ${isActive ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
{i + 1}. {scene.content.title}
</span>
</div>
{scene.content.notes && (
<p className="text-xs text-gray-400 mt-0.5 ml-3.5 line-clamp-2">
{scene.content.notes}
</p>
)}
</button>
);
})}
</nav>
</div>
);
}
function getTypeColor(type: string): string {
switch (type) {
case 'slide': return '#6366F1';
case 'quiz': return '#F59E0B';
case 'discussion': return '#10B981';
case 'interactive': return '#8B5CF6';
case 'pbl': return '#EF4444';
case 'media': return '#06B6D4';
default: return '#9CA3AF';
}
}

View File

@@ -0,0 +1,219 @@
/**
* SceneRenderer — Renders a single classroom scene.
*
* Supports scene types: slide, quiz, discussion, interactive, text, pbl, media.
* Executes scene actions (speech, whiteboard, quiz, discussion).
*/
import { useState, useEffect, useCallback } from 'react';
import type { GeneratedScene, SceneContent, SceneAction, AgentProfile } from '../../types/classroom';
interface SceneRendererProps {
scene: GeneratedScene;
agents: AgentProfile[];
autoPlay?: boolean;
}
export function SceneRenderer({ scene, agents, autoPlay = true }: SceneRendererProps) {
const { content } = scene;
const [actionIndex, setActionIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [whiteboardItems, setWhiteboardItems] = useState<Array<{
type: string;
data: SceneAction;
}>>([]);
const actions = content.actions ?? [];
const currentAction = actions[actionIndex] ?? null;
// Auto-advance through actions
useEffect(() => {
if (!isPlaying || actions.length === 0) return;
if (actionIndex >= actions.length) {
setIsPlaying(false);
return;
}
const delay = getActionDelay(actions[actionIndex]);
const timer = setTimeout(() => {
processAction(actions[actionIndex]);
setActionIndex((i) => i + 1);
}, delay);
return () => clearTimeout(timer);
}, [actionIndex, isPlaying, actions]);
const processAction = useCallback((action: SceneAction) => {
switch (action.type) {
case 'whiteboard_draw_text':
case 'whiteboard_draw_shape':
case 'whiteboard_draw_chart':
case 'whiteboard_draw_latex':
setWhiteboardItems((prev) => [...prev, { type: action.type, data: action }]);
break;
case 'whiteboard_clear':
setWhiteboardItems([]);
break;
}
}, []);
// Render scene based on type
return (
<div className="flex flex-col h-full">
{/* Scene title */}
<div className="mb-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{content.title}
</h2>
{content.notes && (
<p className="text-sm text-gray-500 mt-1">{content.notes}</p>
)}
</div>
{/* Main content area */}
<div className="flex-1 flex gap-4 overflow-hidden">
{/* Content panel */}
<div className="flex-1 overflow-auto">
{renderContent(content)}
</div>
{/* Whiteboard area */}
{whiteboardItems.length > 0 && (
<div className="w-80 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 p-2 overflow-auto">
<svg viewBox="0 0 800 600" className="w-full h-full">
{whiteboardItems.map((item, i) => (
<g key={i}>{renderWhiteboardItem(item)}</g>
))}
</svg>
</div>
)}
</div>
{/* Current action indicator */}
{currentAction && (
<div className="mt-4 p-3 rounded-lg bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800">
{renderCurrentAction(currentAction, agents)}
</div>
)}
{/* Playback controls */}
<div className="flex items-center justify-center gap-2 mt-4">
<button
onClick={() => { setActionIndex(0); setWhiteboardItems([]); }}
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700"
>
Restart
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="px-3 py-1 text-sm rounded bg-indigo-500 text-white"
>
{isPlaying ? 'Pause' : 'Play'}
</button>
<span className="text-xs text-gray-400">
Action {Math.min(actionIndex + 1, actions.length)} / {actions.length}
</span>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getActionDelay(action: SceneAction): number {
switch (action.type) {
case 'speech': return 2000;
case 'whiteboard_draw_text': return 800;
case 'whiteboard_draw_shape': return 600;
case 'quiz_show': return 5000;
case 'discussion': return 10000;
default: return 1000;
}
}
function renderContent(content: SceneContent) {
const data = content.content;
if (!data || typeof data !== 'object') return null;
// Handle slide content
const keyPoints = data.key_points as string[] | undefined;
const description = data.description as string | undefined;
const slides = data.slides as Array<{ title: string; content: string }> | undefined;
return (
<div className="space-y-4">
{description && (
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{description}</p>
)}
{keyPoints && keyPoints.length > 0 && (
<ul className="space-y-2">
{keyPoints.map((point, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-indigo-500 mt-0.5"></span>
<span className="text-gray-700 dark:text-gray-300">{point}</span>
</li>
))}
</ul>
)}
{slides && slides.map((slide, i) => (
<div key={i} className="p-3 rounded border border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white">{slide.title}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{slide.content}</p>
</div>
))}
</div>
);
}
function renderCurrentAction(action: SceneAction, agents: AgentProfile[]) {
switch (action.type) {
case 'speech': {
const agent = agents.find(a => a.role === action.agentRole);
return (
<div className="flex items-start gap-2">
<span className="text-lg">{agent?.avatar ?? '💬'}</span>
<div>
<span className="text-xs font-medium text-gray-600">{agent?.name ?? action.agentRole}</span>
<p className="text-sm text-gray-700 dark:text-gray-300">{action.text}</p>
</div>
</div>
);
}
case 'quiz_show':
return <div className="text-sm text-amber-600">Quiz: {action.quizId}</div>;
case 'discussion':
return <div className="text-sm text-green-600">Discussion: {action.topic}</div>;
default:
return <div className="text-xs text-gray-400">{action.type}</div>;
}
}
function renderWhiteboardItem(item: { type: string; data: Record<string, unknown> }) {
switch (item.type) {
case 'whiteboard_draw_text': {
const d = item.data;
if ('text' in d && 'x' in d && 'y' in d) {
return (
<text x={typeof d.x === 'number' ? d.x : 100} y={typeof d.y === 'number' ? d.y : 100} fontSize={typeof d.fontSize === 'number' ? d.fontSize : 16} fill={typeof d.color === 'string' ? d.color : '#333'}>
{String(d.text ?? '')}
</text>
);
}
return null;
}
case 'whiteboard_draw_shape': {
const d = item.data as Record<string, unknown>;
const x = typeof d.x === 'number' ? d.x : 0;
const y = typeof d.y === 'number' ? d.y : 0;
const w = typeof d.width === 'number' ? d.width : 100;
const h = typeof d.height === 'number' ? d.height : 50;
const fill = typeof d.fill === 'string' ? d.fill : '#e5e5e5';
return (
<rect x={x} y={y} width={w} height={h} fill={fill} />
);
}
}
}

View File

@@ -0,0 +1,155 @@
/**
* TtsPlayer — Text-to-Speech playback controls for classroom narration.
*
* Uses the browser's built-in SpeechSynthesis API.
* Provides play/pause, speed, and volume controls.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Volume2, VolumeX, Pause, Play, Gauge } from 'lucide-react';
interface TtsPlayerProps {
text: string;
autoPlay?: boolean;
onEnd?: () => void;
}
export function TtsPlayer({ text, autoPlay = false, onEnd }: TtsPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [rate, setRate] = useState(1.0);
const [isMuted, setIsMuted] = useState(false);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const speak = useCallback(() => {
if (!text || typeof window === 'undefined') return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-CN';
utterance.rate = rate;
utterance.volume = isMuted ? 0 : 1;
utterance.onend = () => {
setIsPlaying(false);
setIsPaused(false);
onEnd?.();
};
utterance.onerror = () => {
setIsPlaying(false);
setIsPaused(false);
};
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
setIsPlaying(true);
setIsPaused(false);
}, [text, rate, isMuted, onEnd]);
const togglePlay = useCallback(() => {
if (isPlaying && !isPaused) {
window.speechSynthesis.pause();
setIsPaused(true);
} else if (isPaused) {
window.speechSynthesis.resume();
setIsPaused(false);
} else {
speak();
}
}, [isPlaying, isPaused, speak]);
const stop = useCallback(() => {
window.speechSynthesis.cancel();
setIsPlaying(false);
setIsPaused(false);
}, []);
// Auto-play when text changes
useEffect(() => {
if (autoPlay && text) {
speak();
}
return () => {
if (typeof window !== 'undefined') {
window.speechSynthesis.cancel();
}
};
}, [text, autoPlay, speak]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (typeof window !== 'undefined') {
window.speechSynthesis.cancel();
}
};
}, []);
if (!text) return null;
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
{/* Play/Pause button */}
<button
onClick={togglePlay}
className="w-8 h-8 flex items-center justify-center rounded-full bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
aria-label={isPlaying && !isPaused ? '暂停' : '播放'}
>
{isPlaying && !isPaused ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</button>
{/* Stop button */}
{isPlaying && (
<button
onClick={stop}
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
aria-label="停止"
>
<VolumeX className="w-3.5 h-3.5" />
</button>
)}
{/* Speed control */}
<div className="flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-gray-400" />
<select
value={rate}
onChange={(e) => setRate(Number(e.target.value))}
className="text-xs bg-transparent border-none text-gray-600 dark:text-gray-400 cursor-pointer"
>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
</select>
</div>
{/* Mute toggle */}
<button
onClick={() => setIsMuted(!isMuted)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={isMuted ? '取消静音' : '静音'}
>
{isMuted ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
{/* Status indicator */}
{isPlaying && (
<span className="text-xs text-gray-400">
{isPaused ? '已暂停' : '朗读中...'}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,295 @@
/**
* WhiteboardCanvas — SVG-based whiteboard for classroom scene rendering.
*
* Supports incremental drawing operations:
* - Text (positioned labels)
* - Shapes (rectangles, circles, arrows)
* - Charts (bar/line/pie via simple SVG)
* - LaTeX (rendered as styled text blocks)
*/
import { useCallback } from 'react';
import type { SceneAction } from '../../types/classroom';
interface WhiteboardCanvasProps {
items: WhiteboardItem[];
width?: number;
height?: number;
}
export interface WhiteboardItem {
type: string;
data: SceneAction;
}
export function WhiteboardCanvas({
items,
width = 800,
height = 600,
}: WhiteboardCanvasProps) {
const renderItem = useCallback((item: WhiteboardItem, index: number) => {
switch (item.type) {
case 'whiteboard_draw_text':
return <TextItem key={index} data={item.data as TextDrawData} />;
case 'whiteboard_draw_shape':
return <ShapeItem key={index} data={item.data as ShapeDrawData} />;
case 'whiteboard_draw_chart':
return <ChartItem key={index} data={item.data as ChartDrawData} />;
case 'whiteboard_draw_latex':
return <LatexItem key={index} data={item.data as LatexDrawData} />;
default:
return null;
}
}, []);
return (
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-full"
xmlns="http://www.w3.org/2000/svg"
>
{/* Grid background */}
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f0f0f0" strokeWidth="0.5" />
</pattern>
</defs>
<rect width={width} height={height} fill="url(#grid)" />
{/* Rendered items */}
{items.map((item, i) => renderItem(item, i))}
</svg>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface TextDrawData {
type: 'whiteboard_draw_text';
x: number;
y: number;
text: string;
fontSize?: number;
color?: string;
}
function TextItem({ data }: { data: TextDrawData }) {
return (
<text
x={data.x}
y={data.y}
fontSize={data.fontSize ?? 16}
fill={data.color ?? '#333333'}
fontFamily="system-ui, sans-serif"
>
{data.text}
</text>
);
}
interface ShapeDrawData {
type: 'whiteboard_draw_shape';
shape: string;
x: number;
y: number;
width: number;
height: number;
fill?: string;
}
function ShapeItem({ data }: { data: ShapeDrawData }) {
switch (data.shape) {
case 'circle':
return (
<ellipse
cx={data.x + data.width / 2}
cy={data.y + data.height / 2}
rx={data.width / 2}
ry={data.height / 2}
fill={data.fill ?? '#e5e7eb'}
stroke="#9ca3af"
strokeWidth={1}
/>
);
case 'arrow':
return (
<g>
<line
x1={data.x}
y1={data.y + data.height / 2}
x2={data.x + data.width}
y2={data.y + data.height / 2}
stroke={data.fill ?? '#6b7280'}
strokeWidth={2}
markerEnd="url(#arrowhead)"
/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill={data.fill ?? '#6b7280'} />
</marker>
</defs>
</g>
);
default: // rectangle
return (
<rect
x={data.x}
y={data.y}
width={data.width}
height={data.height}
fill={data.fill ?? '#e5e7eb'}
stroke="#9ca3af"
strokeWidth={1}
rx={4}
/>
);
}
}
interface ChartDrawData {
type: 'whiteboard_draw_chart';
chartType: string;
data: Record<string, unknown>;
x: number;
y: number;
width: number;
height: number;
}
function ChartItem({ data }: { data: ChartDrawData }) {
const chartData = data.data;
const labels = (chartData?.labels as string[]) ?? [];
const values = (chartData?.values as number[]) ?? [];
if (labels.length === 0 || values.length === 0) return null;
switch (data.chartType) {
case 'bar':
return <BarChart data={data} labels={labels} values={values} />;
case 'line':
return <LineChart data={data} labels={labels} values={values} />;
default:
return <BarChart data={data} labels={labels} values={values} />;
}
}
function BarChart({ data, labels, values }: {
data: ChartDrawData;
labels: string[];
values: number[];
}) {
const maxVal = Math.max(...values, 1);
const barWidth = data.width / (labels.length * 2);
const chartHeight = data.height - 30;
return (
<g transform={`translate(${data.x}, ${data.y})`}>
{values.map((val, i) => {
const barHeight = (val / maxVal) * chartHeight;
return (
<g key={i}>
<rect
x={i * (barWidth * 2) + barWidth / 2}
y={chartHeight - barHeight}
width={barWidth}
height={barHeight}
fill="#6366f1"
rx={2}
/>
<text
x={i * (barWidth * 2) + barWidth}
y={data.height - 5}
textAnchor="middle"
fontSize={10}
fill="#666"
>
{labels[i]}
</text>
</g>
);
})}
</g>
);
}
function LineChart({ data, labels, values }: {
data: ChartDrawData;
labels: string[];
values: number[];
}) {
const maxVal = Math.max(...values, 1);
const chartHeight = data.height - 30;
const stepX = data.width / Math.max(labels.length - 1, 1);
const points = values.map((val, i) => {
const x = i * stepX;
const y = chartHeight - (val / maxVal) * chartHeight;
return `${x},${y}`;
}).join(' ');
return (
<g transform={`translate(${data.x}, ${data.y})`}>
<polyline
points={points}
fill="none"
stroke="#6366f1"
strokeWidth={2}
/>
{values.map((val, i) => {
const x = i * stepX;
const y = chartHeight - (val / maxVal) * chartHeight;
return (
<g key={i}>
<circle cx={x} cy={y} r={3} fill="#6366f1" />
<text
x={x}
y={data.height - 5}
textAnchor="middle"
fontSize={10}
fill="#666"
>
{labels[i]}
</text>
</g>
);
})}
</g>
);
}
interface LatexDrawData {
type: 'whiteboard_draw_latex';
latex: string;
x: number;
y: number;
}
function LatexItem({ data }: { data: LatexDrawData }) {
return (
<g transform={`translate(${data.x}, ${data.y})`}>
<rect
x={-4}
y={-20}
width={data.latex.length * 10 + 8}
height={28}
fill="#fef3c7"
stroke="#f59e0b"
strokeWidth={1}
rx={4}
/>
<text
x={0}
y={0}
fontSize={14}
fill="#92400e"
fontFamily="'Courier New', monospace"
>
{data.latex}
</text>
</g>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Classroom Player Components
*
* Re-exports all classroom player components.
*/
export { ClassroomPlayer } from './ClassroomPlayer';
export { SceneRenderer } from './SceneRenderer';
export { AgentChat } from './AgentChat';
export { NotesSidebar } from './NotesSidebar';
export { WhiteboardCanvas } from './WhiteboardCanvas';
export { TtsPlayer } from './TtsPlayer';

View File

@@ -0,0 +1,76 @@
/**
* useClassroom — React hook wrapping the classroom store for component consumption.
*
* Provides a simplified interface for classroom generation and chat.
*/
import { useCallback } from 'react';
import {
useClassroomStore,
type GenerationRequest,
} from '../store/classroomStore';
import type {
Classroom,
ClassroomChatMessage,
} from '../types/classroom';
export interface UseClassroomReturn {
/** Is generation in progress */
generating: boolean;
/** Current generation stage name */
progressStage: string | null;
/** Progress percentage 0-100 */
progressPercent: number;
/** The active classroom */
activeClassroom: Classroom | null;
/** Chat messages for active classroom */
chatMessages: ClassroomChatMessage[];
/** Is a chat request loading */
chatLoading: boolean;
/** Error message, if any */
error: string | null;
/** Start classroom generation */
startGeneration: (request: GenerationRequest) => Promise<string>;
/** Cancel active generation */
cancelGeneration: () => void;
/** Send a chat message in the active classroom */
sendChatMessage: (message: string, sceneContext?: string) => Promise<void>;
/** Clear current error */
clearError: () => void;
}
/**
* Hook for classroom generation and multi-agent chat.
*
* Components should use this hook rather than accessing the store directly,
* to keep the rendering logic decoupled from state management.
*/
export function useClassroom(): UseClassroomReturn {
const {
generating,
progressStage,
progressPercent,
activeClassroom,
chatMessages,
chatLoading,
error,
startGeneration,
cancelGeneration,
sendChatMessage,
clearError,
} = useClassroomStore();
return {
generating,
progressStage,
progressPercent,
activeClassroom,
chatMessages,
chatLoading,
error,
startGeneration: useCallback((req: GenerationRequest) => startGeneration(req), [startGeneration]),
cancelGeneration: useCallback(() => cancelGeneration(), [cancelGeneration]),
sendChatMessage: useCallback((msg, ctx) => sendChatMessage(msg, ctx), [sendChatMessage]),
clearError: useCallback(() => clearError(), [clearError]),
};
}

View File

@@ -1,27 +1,5 @@
@import "tailwindcss";
/* Aurora gradient animation for welcome title (DeerFlow-inspired) */
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.aurora-title {
background: linear-gradient(
135deg,
#f97316 0%, /* orange-500 */
#ef4444 25%, /* red-500 */
#f97316 50%, /* orange-500 */
#fb923c 75%, /* orange-400 */
#f97316 100% /* orange-500 */
);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 4s ease infinite;
}
:root {
/* Brand Colors - 中性灰色系 */
--color-primary: #374151; /* gray-700 */
@@ -154,3 +132,38 @@ textarea:focus-visible {
outline: none !important;
box-shadow: none !important;
}
/* === Accessibility: reduced motion === */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* === Responsive breakpoints for small windows/tablets === */
@media (max-width: 768px) {
/* Auto-collapse sidebar aside on narrow viewports */
aside.w-64 {
width: 0 !important;
min-width: 0 !important;
overflow: hidden;
border-right: none !important;
}
aside.w-64.sidebar-open {
width: 260px !important;
min-width: 260px !important;
position: fixed;
z-index: 50;
height: 100vh;
}
}
@media (max-width: 480px) {
.chat-bubble-assistant,
.chat-bubble-user {
max-width: 95% !important;
}
}

View File

@@ -3,6 +3,10 @@
*
* 为 ZCLAW 前端操作提供统一的审计日志记录功能。
* 记录关键操作Hand 触发、Agent 创建等)到本地存储。
*
* @reserved This module is reserved for future audit logging integration.
* It is not currently imported by any component. When audit logging is needed,
* import { logAudit, logAuditSuccess, logAuditFailure } from this module.
*/
import { createLogger } from './logger';

View File

@@ -0,0 +1,142 @@
/**
* Classroom Adapter
*
* Bridges the old ClassroomData type (ClassroomPreviewer) with the new
* Classroom type (ClassroomPlayer + Tauri backend).
*/
import type { Classroom, GeneratedScene } from '../types/classroom';
import { SceneType, TeachingStyle, DifficultyLevel } from '../types/classroom';
import type { ClassroomData, ClassroomScene } from '../components/ClassroomPreviewer';
// ---------------------------------------------------------------------------
// Old → New (ClassroomData → Classroom)
// ---------------------------------------------------------------------------
/**
* Convert a legacy ClassroomData to the new Classroom format.
* Used when opening ClassroomPlayer from Pipeline result previews.
*/
export function adaptToClassroom(data: ClassroomData): Classroom {
const scenes: GeneratedScene[] = data.scenes.map((scene, index) => ({
id: scene.id,
outlineId: `outline-${index}`,
content: {
title: scene.title,
sceneType: mapSceneType(scene.type),
content: {
heading: scene.content.heading ?? scene.title,
key_points: scene.content.bullets ?? [],
description: scene.content.explanation,
quiz: scene.content.quiz ?? undefined,
},
actions: [],
durationSeconds: scene.duration ?? 60,
notes: scene.narration,
},
order: index,
})) as GeneratedScene[];
return {
id: data.id,
title: data.title,
description: data.subject,
topic: data.subject,
style: TeachingStyle.Lecture,
level: mapDifficulty(data.difficulty),
totalDuration: data.duration * 60,
objectives: [],
scenes,
agents: [],
metadata: {
generatedAt: new Date(data.createdAt).getTime(),
version: '1.0',
custom: {},
},
};
}
// ---------------------------------------------------------------------------
// New → Old (Classroom → ClassroomData)
// ---------------------------------------------------------------------------
/**
* Convert a new Classroom to the legacy ClassroomData format.
* Used when rendering ClassroomPreviewer from new pipeline results.
*/
export function adaptToClassroomData(classroom: Classroom): ClassroomData {
const scenes: ClassroomScene[] = classroom.scenes.map((scene) => {
const data = scene.content.content as Record<string, unknown>;
return {
id: scene.id,
title: scene.content.title,
type: mapToLegacySceneType(scene.content.sceneType),
content: {
heading: (data?.heading as string) ?? scene.content.title,
bullets: (data?.key_points as string[]) ?? [],
explanation: (data?.description as string) ?? '',
quiz: (data?.quiz as ClassroomScene['content']['quiz']) ?? undefined,
},
narration: scene.content.notes,
duration: scene.content.durationSeconds,
};
});
return {
id: classroom.id,
title: classroom.title,
subject: classroom.topic,
difficulty: mapToLegacyDifficulty(classroom.level),
duration: Math.ceil(classroom.totalDuration / 60),
scenes,
outline: {
sections: classroom.scenes.map((scene) => ({
title: scene.content.title,
scenes: [scene.id],
})),
},
createdAt: new Date(classroom.metadata.generatedAt).toISOString(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mapSceneType(type: ClassroomScene['type']): SceneType {
switch (type) {
case 'title': return SceneType.Slide;
case 'content': return SceneType.Slide;
case 'quiz': return SceneType.Quiz;
case 'interactive': return SceneType.Interactive;
case 'summary': return SceneType.Text;
default: return SceneType.Slide;
}
}
function mapToLegacySceneType(sceneType: string): ClassroomScene['type'] {
switch (sceneType) {
case 'quiz': return 'quiz';
case 'interactive': return 'interactive';
case 'text': return 'summary';
default: return 'content';
}
}
function mapDifficulty(difficulty: string): DifficultyLevel {
switch (difficulty) {
case '初级': return DifficultyLevel.Beginner;
case '中级': return DifficultyLevel.Intermediate;
case '高级': return DifficultyLevel.Advanced;
default: return DifficultyLevel.Intermediate;
}
}
function mapToLegacyDifficulty(level: string): ClassroomData['difficulty'] {
switch (level) {
case 'beginner': return '初级';
case 'advanced': return '高级';
case 'expert': return '高级';
default: return '中级';
}
}

View File

@@ -56,12 +56,19 @@ function initErrorStore(): void {
errors: [],
addError: (error: AppError) => {
// Dedup: skip if same title+message already exists and undismissed
const isDuplicate = errorStore.errors.some(
(e) => !e.dismissed && e.title === error.title && e.message === error.message
);
if (isDuplicate) return;
const storedError: StoredError = {
...error,
dismissed: false,
reported: false,
};
errorStore.errors = [storedError, ...errorStore.errors];
// Cap at 50 errors to prevent unbounded growth
errorStore.errors = [storedError, ...errorStore.errors].slice(0, 50);
// Notify listeners
notifyErrorListeners(error);
},

View File

@@ -103,6 +103,12 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
callbacks.onDelta(streamEvent.delta);
break;
case 'thinkingDelta':
if (callbacks.onThinkingDelta) {
callbacks.onThinkingDelta(streamEvent.delta);
}
break;
case 'tool_start':
log.debug('Tool started:', streamEvent.name, streamEvent.input);
if (callbacks.onTool) {

View File

@@ -5,8 +5,20 @@
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { createLogger } from './logger';
import type { KernelClient } from './kernel-client';
const log = createLogger('KernelHands');
/** Payload emitted by the Rust backend on `hand-execution-complete` events. */
export interface HandExecutionCompletePayload {
approvalId: string;
handId: string;
success: boolean;
error?: string | null;
}
export function installHandMethods(ClientClass: { prototype: KernelClient }): void {
const proto = ClientClass.prototype as any;
@@ -92,7 +104,7 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo
*/
proto.getHandStatus = async function (this: KernelClient, name: string, runId: string): Promise<{ status: string; result?: unknown }> {
try {
return await invoke('hand_run_status', { handName: name, runId });
return await invoke('hand_run_status', { runId });
} catch (e) {
const { createLogger } = await import('./logger');
createLogger('KernelHands').debug('hand_run_status failed', { name, runId, error: e });
@@ -171,4 +183,26 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo
proto.respondToApproval = async function (this: KernelClient, approvalId: string, approved: boolean, reason?: string): Promise<void> {
return invoke('approval_respond', { id: approvalId, approved, reason });
};
// ─── Event Listeners ───
/**
* Listen for `hand-execution-complete` events emitted by the Rust backend
* after a hand finishes executing (both from direct trigger and approval flow).
*
* Returns an unlisten function for cleanup.
*/
proto.onHandExecutionComplete = async function (
this: KernelClient,
callback: (payload: HandExecutionCompletePayload) => void,
): Promise<UnlistenFn> {
const unlisten = await listen<HandExecutionCompletePayload>(
'hand-execution-complete',
(event) => {
log.debug('hand-execution-complete', event.payload);
callback(event.payload);
},
);
return unlisten;
};
}

View File

@@ -109,7 +109,11 @@ export function installSkillMethods(ClientClass: { prototype: KernelClient }): v
}> {
return invoke('skill_execute', {
id,
context: {},
context: {
agentId: '',
sessionId: '',
workingDir: '',
},
input: input || {},
});
};

View File

@@ -96,7 +96,12 @@ export function installTriggerMethods(ClientClass: { prototype: KernelClient }):
triggerType?: TriggerTypeSpec;
}): Promise<TriggerItem> {
try {
return await invoke<TriggerItem>('trigger_update', { id, updates });
return await invoke<TriggerItem>('trigger_update', {
id,
name: updates.name,
enabled: updates.enabled,
handId: updates.handId,
});
} catch (error) {
this.log('error', `[TriggersAPI] updateTrigger(${id}) failed: ${this.formatError(error)}`);
throw error;

View File

@@ -58,6 +58,7 @@ export interface EventCallback {
export interface StreamCallbacks {
onDelta: (delta: string) => void;
onThinkingDelta?: (delta: string) => void;
onTool?: (tool: string, input: string, output: string) => void;
onHand?: (name: string, status: string, result?: unknown) => void;
onComplete: (inputTokens?: number, outputTokens?: number) => void;
@@ -71,6 +72,11 @@ export interface StreamEventDelta {
delta: string;
}
export interface StreamEventThinkingDelta {
type: 'thinkingDelta';
delta: string;
}
export interface StreamEventToolStart {
type: 'tool_start';
name: string;
@@ -114,6 +120,7 @@ export interface StreamEventHandEnd {
export type StreamChatEvent =
| StreamEventDelta
| StreamEventThinkingDelta
| StreamEventToolStart
| StreamEventToolEnd
| StreamEventIterationStart

View File

@@ -1,233 +0,0 @@
/**
* SaaS Admin Methods — Mixin
*
* Installs admin panel API methods onto SaaSClient.prototype.
* Uses the same mixin pattern as gateway-api.ts.
*
* Reserved for future admin UI (Next.js admin dashboard).
* These methods are not called by the desktop app but are kept as thin API
* wrappers for when the admin panel is built.
*/
import type {
ProviderInfo,
CreateProviderRequest,
UpdateProviderRequest,
ModelInfo,
CreateModelRequest,
UpdateModelRequest,
AccountApiKeyInfo,
CreateApiKeyRequest,
AccountPublic,
UpdateAccountRequest,
PaginatedResponse,
TokenInfo,
CreateTokenRequest,
OperationLogInfo,
DashboardStats,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
PermissionTemplate,
CreateTemplateRequest,
} from './saas-types';
export function installAdminMethods(ClientClass: { prototype: any }): void {
const proto = ClientClass.prototype;
// --- Provider Management (Admin) ---
/** List all providers */
proto.listProviders = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<ProviderInfo[]> {
return this.request<ProviderInfo[]>('GET', '/api/v1/providers');
};
/** Get provider by ID */
proto.getProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ProviderInfo> {
return this.request<ProviderInfo>('GET', `/api/v1/providers/${id}`);
};
/** Create a new provider (admin only) */
proto.createProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('POST', '/api/v1/providers', data);
};
/** Update a provider (admin only) */
proto.updateProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('PATCH', `/api/v1/providers/${id}`, data);
};
/** Delete a provider (admin only) */
proto.deleteProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/providers/${id}`);
};
// --- Model Management (Admin) ---
/** List models, optionally filtered by provider */
proto.listModelsAdmin = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, providerId?: string): Promise<ModelInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<ModelInfo[]>('GET', `/api/v1/models${qs}`);
};
/** Get model by ID */
proto.getModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ModelInfo> {
return this.request<ModelInfo>('GET', `/api/v1/models/${id}`);
};
/** Create a new model (admin only) */
proto.createModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('POST', '/api/v1/models', data);
};
/** Update a model (admin only) */
proto.updateModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('PATCH', `/api/v1/models/${id}`, data);
};
/** Delete a model (admin only) */
proto.deleteModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/models/${id}`);
};
// --- Account API Keys ---
/** List account's API keys */
proto.listApiKeys = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, providerId?: string): Promise<AccountApiKeyInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<AccountApiKeyInfo[]>('GET', `/api/v1/keys${qs}`);
};
/** Create a new API key */
proto.createApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateApiKeyRequest): Promise<AccountApiKeyInfo> {
return this.request<AccountApiKeyInfo>('POST', '/api/v1/keys', data);
};
/** Rotate an API key */
proto.rotateApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, newKeyValue: string): Promise<void> {
await this.request<void>('POST', `/api/v1/keys/${id}/rotate`, { new_key_value: newKeyValue });
};
/** Revoke an API key */
proto.revokeApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/keys/${id}`);
};
// --- Account Management (Admin) ---
/** List all accounts (admin only) */
proto.listAccounts = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
if (params?.role) qs.set('role', params.role);
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
const query = qs.toString();
return this.request<PaginatedResponse<AccountPublic>>('GET', `/api/v1/accounts${query ? '?' + query : ''}`);
};
/** Get account by ID (admin or self) */
proto.getAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<AccountPublic> {
return this.request<AccountPublic>('GET', `/api/v1/accounts/${id}`);
};
/** Update account (admin or self) */
proto.updateAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateAccountRequest): Promise<AccountPublic> {
return this.request<AccountPublic>('PATCH', `/api/v1/accounts/${id}`, data);
};
/** Update account status (admin only) */
proto.updateAccountStatus = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void> {
await this.request<void>('PATCH', `/api/v1/accounts/${id}/status`, { status });
};
// --- API Token Management ---
/** List API tokens for current account */
proto.listTokens = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<TokenInfo[]> {
return this.request<TokenInfo[]>('GET', '/api/v1/tokens');
};
/** Create a new API token */
proto.createToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTokenRequest): Promise<TokenInfo> {
return this.request<TokenInfo>('POST', '/api/v1/tokens', data);
};
/** Revoke an API token */
proto.revokeToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/tokens/${id}`);
};
// --- Operation Logs (Admin) ---
/** List operation logs (admin only) */
proto.listOperationLogs = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
const query = qs.toString();
return this.request<OperationLogInfo[]>('GET', `/api/v1/logs/operations${query ? '?' + query : ''}`);
};
// --- Dashboard Statistics (Admin) ---
/** Get dashboard statistics (admin only) */
proto.getDashboardStats = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<DashboardStats> {
return this.request<DashboardStats>('GET', '/api/v1/stats/dashboard');
};
// --- Role Management (Admin) ---
/** List all roles */
proto.listRoles = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<RoleInfo[]> {
return this.request<RoleInfo[]>('GET', '/api/v1/roles');
};
/** Get role by ID */
proto.getRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<RoleInfo> {
return this.request<RoleInfo>('GET', `/api/v1/roles/${id}`);
};
/** Create a new role (admin only) */
proto.createRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('POST', '/api/v1/roles', data);
};
/** Update a role (admin only) */
proto.updateRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('PUT', `/api/v1/roles/${id}`, data);
};
/** Delete a role (admin only) */
proto.deleteRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/roles/${id}`);
};
// --- Permission Templates ---
/** List permission templates */
proto.listPermissionTemplates = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<PermissionTemplate[]> {
return this.request<PermissionTemplate[]>('GET', '/api/v1/permission-templates');
};
/** Get permission template by ID */
proto.getPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('GET', `/api/v1/permission-templates/${id}`);
};
/** Create a permission template (admin only) */
proto.createPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTemplateRequest): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('POST', '/api/v1/permission-templates', data);
};
/** Delete a permission template (admin only) */
proto.deletePermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/permission-templates/${id}`);
};
/** Apply permission template to accounts (admin only) */
proto.applyPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }> {
return this.request<{ ok: boolean; applied_count: number }>('POST', `/api/v1/permission-templates/${templateId}/apply`, { account_ids: accountIds });
};
}

View File

@@ -17,7 +17,6 @@
* - saas-errors.ts — SaaSApiError class
* - saas-session.ts — session persistence (load/save/clear)
* - saas-auth.ts — login/register/TOTP methods (mixin)
* - saas-admin.ts — admin panel API methods (mixin)
* - saas-relay.ts — relay tasks, chat completion, usage (mixin)
* - saas-prompt.ts — prompt OTA methods (mixin)
* - saas-telemetry.ts — telemetry reporting methods (mixin)
@@ -96,26 +95,6 @@ import type {
SaaSErrorResponse,
RelayTaskInfo,
UsageStats,
ProviderInfo,
CreateProviderRequest,
UpdateProviderRequest,
ModelInfo,
CreateModelRequest,
UpdateModelRequest,
AccountApiKeyInfo,
CreateApiKeyRequest,
AccountPublic,
UpdateAccountRequest,
PaginatedResponse,
TokenInfo,
CreateTokenRequest,
OperationLogInfo,
DashboardStats,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
PermissionTemplate,
CreateTemplateRequest,
PromptCheckResult,
PromptTemplateInfo,
PromptVersionInfo,
@@ -128,7 +107,7 @@ import { createLogger } from './logger';
const saasLog = createLogger('saas-client');
import { installAuthMethods } from './saas-auth';
import { installAdminMethods } from './saas-admin';
import { installRelayMethods } from './saas-relay';
import { installPromptMethods } from './saas-prompt';
import { installTelemetryMethods } from './saas-telemetry';
@@ -140,6 +119,25 @@ export class SaaSClient {
private baseUrl: string;
private token: string | null = null;
/**
* Refresh mutex: shared Promise to prevent concurrent token refresh.
* When multiple requests hit 401 simultaneously, they all await the same
* refresh Promise instead of triggering N parallel refresh calls.
*/
private _refreshPromise: Promise<string> | null = null;
/**
* Thread-safe token refresh — coalesces concurrent refresh attempts into one.
* First caller triggers the actual refresh; subsequent callers await the same Promise.
*/
async refreshMutex(): Promise<string> {
if (this._refreshPromise) return this._refreshPromise;
this._refreshPromise = this.refreshToken().finally(() => {
this._refreshPromise = null;
});
return this._refreshPromise;
}
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
@@ -237,7 +235,7 @@ export class SaaSClient {
// 401: 尝试刷新 Token 后重试 (防止递归)
if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) {
try {
const newToken = await this.refreshToken();
const newToken = await this.refreshMutex();
if (newToken) {
return this.request<T>(method, path, body, timeoutMs, true);
}
@@ -394,7 +392,7 @@ export class SaaSClient {
* Used for template selection during onboarding.
*/
async fetchAvailableTemplates(): Promise<AgentTemplateAvailable[]> {
return this.request<AgentTemplateAvailable[]>('GET', '/agent-templates/available');
return this.request<AgentTemplateAvailable[]>('GET', '/api/v1/agent-templates/available');
}
/**
@@ -402,13 +400,12 @@ export class SaaSClient {
* Returns all fields needed to create an agent from template.
*/
async fetchTemplateFull(id: string): Promise<AgentTemplateFull> {
return this.request<AgentTemplateFull>('GET', `/agent-templates/${id}/full`);
return this.request<AgentTemplateFull>('GET', `/api/v1/agent-templates/${id}/full`);
}
}
// === Install mixin methods ===
installAuthMethods(SaaSClient);
installAdminMethods(SaaSClient);
installRelayMethods(SaaSClient);
installPromptMethods(SaaSClient);
installTelemetryMethods(SaaSClient);
@@ -429,57 +426,6 @@ export interface SaaSClient {
verifyTotp(code: string): Promise<TotpResultResponse>;
disableTotp(password: string): Promise<TotpResultResponse>;
// --- Admin: Providers (saas-admin.ts) ---
listProviders(): Promise<ProviderInfo[]>;
getProvider(id: string): Promise<ProviderInfo>;
createProvider(data: CreateProviderRequest): Promise<ProviderInfo>;
updateProvider(id: string, data: UpdateProviderRequest): Promise<ProviderInfo>;
deleteProvider(id: string): Promise<void>;
// --- Admin: Models (saas-admin.ts) ---
listModelsAdmin(providerId?: string): Promise<ModelInfo[]>;
getModel(id: string): Promise<ModelInfo>;
createModel(data: CreateModelRequest): Promise<ModelInfo>;
updateModel(id: string, data: UpdateModelRequest): Promise<ModelInfo>;
deleteModel(id: string): Promise<void>;
// --- Admin: API Keys (saas-admin.ts) ---
listApiKeys(providerId?: string): Promise<AccountApiKeyInfo[]>;
createApiKey(data: CreateApiKeyRequest): Promise<AccountApiKeyInfo>;
rotateApiKey(id: string, newKeyValue: string): Promise<void>;
revokeApiKey(id: string): Promise<void>;
// --- Admin: Accounts (saas-admin.ts) ---
listAccounts(params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>>;
getAccount(id: string): Promise<AccountPublic>;
updateAccount(id: string, data: UpdateAccountRequest): Promise<AccountPublic>;
updateAccountStatus(id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void>;
// --- Admin: Tokens (saas-admin.ts) ---
listTokens(): Promise<TokenInfo[]>;
createToken(data: CreateTokenRequest): Promise<TokenInfo>;
revokeToken(id: string): Promise<void>;
// --- Admin: Logs (saas-admin.ts) ---
listOperationLogs(params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]>;
// --- Admin: Dashboard (saas-admin.ts) ---
getDashboardStats(): Promise<DashboardStats>;
// --- Admin: Roles (saas-admin.ts) ---
listRoles(): Promise<RoleInfo[]>;
getRole(id: string): Promise<RoleInfo>;
createRole(data: CreateRoleRequest): Promise<RoleInfo>;
updateRole(id: string, data: UpdateRoleRequest): Promise<RoleInfo>;
deleteRole(id: string): Promise<void>;
// --- Admin: Permission Templates (saas-admin.ts) ---
listPermissionTemplates(): Promise<PermissionTemplate[]>;
getPermissionTemplate(id: string): Promise<PermissionTemplate>;
createPermissionTemplate(data: CreateTemplateRequest): Promise<PermissionTemplate>;
deletePermissionTemplate(id: string): Promise<void>;
applyPermissionTemplate(templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }>;
// --- Relay (saas-relay.ts) ---
listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]>;
getRelayTask(taskId: string): Promise<RelayTaskInfo>;

View File

@@ -55,6 +55,7 @@ export function installRelayMethods(ClientClass: { prototype: any }): void {
_serverReachable: boolean;
_isAuthEndpoint(path: string): boolean;
refreshToken(): Promise<string>;
refreshMutex(): Promise<string>;
},
body: unknown,
signal?: AbortSignal,
@@ -87,7 +88,7 @@ export function installRelayMethods(ClientClass: { prototype: any }): void {
// On 401, attempt token refresh once
if (response.status === 401 && attempt === 0 && !this._isAuthEndpoint('/api/v1/relay/chat/completions')) {
try {
const newToken = await this.refreshToken();
const newToken = await this.refreshMutex();
if (newToken) continue; // Retry with refreshed token
} catch (e) {
logger.debug('Token refresh failed', { error: e });

View File

@@ -299,36 +299,6 @@ function readLocalStorageBackup(key: string): string | null {
}
}
/**
* Synchronous versions for compatibility with existing code
* These use localStorage only and are provided for gradual migration
*/
export const secureStorageSync = {
/**
* Synchronously get a value from localStorage (for migration only)
* @deprecated Use async secureStorage.get() instead
*/
get(key: string): string | null {
return readLocalStorageBackup(key);
},
/**
* Synchronously set a value in localStorage (for migration only)
* @deprecated Use async secureStorage.set() instead
*/
set(key: string, value: string): void {
writeLocalStorageBackup(key, value);
},
/**
* Synchronously delete a value from localStorage (for migration only)
* @deprecated Use async secureStorage.delete() instead
*/
delete(key: string): void {
clearLocalStorageBackup(key);
},
};
// === Device Keys Secure Storage ===
/**

View File

@@ -47,7 +47,6 @@ export type { EncryptedData } from './crypto-utils';
// Re-export secure storage
export {
secureStorage,
secureStorageSync,
isSecureStorageAvailable,
storeDeviceKeys,
getDeviceKeys,

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

133
desktop/src/types/chat.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Unified chat types for the ZCLAW desktop chat system.
*
* This module consolidates types previously scattered across
* chatStore.ts, session.ts, and component-level type exports.
*/
// --- Re-export from component modules for backward compat ---
export type { ChatModeType, ChatModeConfig } from '../components/ai/ChatMode';
export { CHAT_MODES } from '../components/ai/ChatMode';
export type { Subtask } from '../components/ai/TaskProgress';
export type { ToolCallStep } from '../components/ai/ToolCallChain';
export type { ArtifactFile } from '../components/ai/ArtifactPanel';
// --- Core chat types ---
export interface MessageFile {
name: string;
path?: string;
size?: number;
type?: string;
}
export interface CodeBlock {
language?: string;
filename?: string;
content?: string;
}
/**
* Unified message type for all chat messages.
* Supersedes both ChatStore.Message (6 roles) and SessionMessage (3 roles).
*/
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow' | 'system';
content: string;
timestamp: Date;
streaming?: boolean;
optimistic?: boolean;
runId?: string;
// Thinking/reasoning
thinkingContent?: string;
// Error & retry
error?: string;
/** Preserved original content before error overlay, used for retry */
originalContent?: string;
// Tool call chain
toolSteps?: import('../components/ai/ToolCallChain').ToolCallStep[];
toolName?: string;
toolInput?: string;
toolOutput?: string;
// Hand event fields
handName?: string;
handStatus?: string;
handResult?: unknown;
// Workflow event fields
workflowId?: string;
workflowStep?: string;
workflowStatus?: string;
workflowResult?: unknown;
// Sub-agent task tracking
subtasks?: import('../components/ai/TaskProgress').Subtask[];
// Attachments
files?: MessageFile[];
codeBlocks?: CodeBlock[];
// Metadata
metadata?: {
inputTokens?: number;
outputTokens?: number;
model?: string;
};
}
/**
* A conversation container with messages, session key, and agent binding.
*/
export interface Conversation {
id: string;
title: string;
messages: ChatMessage[];
sessionKey: string | null;
agentId: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight agent representation for the chat UI sidebar.
* Distinct from types/agent.ts Agent (which is a backend entity).
*/
export interface ChatAgent {
id: string;
name: string;
icon: string;
color: string;
lastMessage: string;
time: string;
}
/**
* Minimal profile shape for agent sync operations.
*/
export interface AgentProfileLike {
id: string;
name: string;
nickname?: string;
role?: string;
}
/**
* Token usage reported on stream completion.
*/
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
}
/**
* Context passed when sending a message.
*/
export interface SendMessageContext {
files?: MessageFile[];
parentMessageId?: string;
}

View File

@@ -0,0 +1,181 @@
/**
* Classroom Generation Types
*
* Mirror of Rust `zclaw-kernel::generation` module types.
* Used by classroom player, hooks, and store.
*/
// --- Agent Types ---
export enum AgentRole {
Teacher = 'teacher',
Assistant = 'assistant',
Student = 'student',
}
export interface AgentProfile {
id: string;
name: string;
role: AgentRole;
persona: string;
avatar: string;
color: string;
allowedActions: string[];
priority: number;
}
// --- Scene Types ---
export enum SceneType {
Slide = 'slide',
Quiz = 'quiz',
Interactive = 'interactive',
Pbl = 'pbl',
Discussion = 'discussion',
Media = 'media',
Text = 'text',
}
export enum GenerationStage {
AgentProfiles = 'agent_profiles',
Outline = 'outline',
Scene = 'scene',
Complete = 'complete',
}
// --- Scene Actions ---
export type SceneAction =
| { type: 'speech'; text: string; agentRole: string }
| { type: 'whiteboard_draw_text'; x: number; y: number; text: string; fontSize?: number; color?: string }
| { type: 'whiteboard_draw_shape'; shape: string; x: number; y: number; width: number; height: number; fill?: string }
| { type: 'whiteboard_draw_chart'; chartType: string; data: unknown; x: number; y: number; width: number; height: number }
| { type: 'whiteboard_draw_latex'; latex: string; x: number; y: number }
| { type: 'whiteboard_clear' }
| { type: 'slideshow_spotlight'; elementId: string }
| { type: 'slideshow_next' }
| { type: 'quiz_show'; quizId: string }
| { type: 'discussion'; topic: string; durationSeconds?: number };
// --- Content Structures ---
export interface SceneContent {
title: string;
sceneType: SceneType;
content: Record<string, unknown>;
actions: SceneAction[];
durationSeconds: number;
notes?: string;
}
export interface OutlineItem {
id: string;
title: string;
description: string;
sceneType: SceneType;
keyPoints: string[];
durationSeconds: number;
dependencies: string[];
}
export interface GeneratedScene {
id: string;
outlineId: string;
content: SceneContent;
order: number;
}
// --- Teaching Config ---
export enum TeachingStyle {
Lecture = 'lecture',
Discussion = 'discussion',
Pbl = 'pbl',
Flipped = 'flipped',
Socratic = 'socratic',
}
export enum DifficultyLevel {
Beginner = 'beginner',
Intermediate = 'intermediate',
Advanced = 'advanced',
Expert = 'expert',
}
// --- Classroom ---
export interface ClassroomMetadata {
generatedAt: number;
sourceDocument?: string;
model?: string;
version: string;
custom: Record<string, unknown>;
}
export interface Classroom {
id: string;
title: string;
description: string;
topic: string;
style: TeachingStyle;
level: DifficultyLevel;
totalDuration: number;
objectives: string[];
scenes: GeneratedScene[];
agents: AgentProfile[];
metadata: ClassroomMetadata;
outline?: string;
}
// --- Generation Request ---
export interface GenerationRequest {
topic: string;
document?: string;
style: TeachingStyle;
level: DifficultyLevel;
targetDurationMinutes: number;
sceneCount?: number;
customInstructions?: string;
language?: string;
}
// --- Generation Progress ---
export interface GenerationProgress {
stage: GenerationStage;
progress: number;
activity: string;
itemsProgress?: [number, number];
etaSeconds?: number;
}
// --- Chat Types ---
export interface ClassroomChatMessage {
id: string;
agentId: string;
agentName: string;
agentAvatar: string;
content: string;
timestamp: number;
role: string;
color: string;
}
export interface ClassroomChatState {
messages: ClassroomChatMessage[];
active: boolean;
}
export interface ClassroomChatRequest {
classroomId: string;
userMessage: string;
agents: AgentProfile[];
sceneContext?: string;
history: ClassroomChatMessage[];
}
export interface ClassroomChatResponse {
responses: ClassroomChatMessage[];
}

View File

@@ -156,3 +156,44 @@ export {
filterByStatus,
searchAutomationItems,
} from './automation';
// Classroom Types
export type {
AgentProfile,
SceneContent,
GeneratedScene,
ClassroomMetadata,
Classroom,
GenerationRequest,
GenerationProgress,
ClassroomChatMessage,
ClassroomChatState,
ClassroomChatRequest,
ClassroomChatResponse,
SceneAction,
OutlineItem,
} from './classroom';
export {
AgentRole,
SceneType,
GenerationStage,
TeachingStyle,
DifficultyLevel,
} from './classroom';
// Chat Types (unified)
export type {
ChatMessage,
Conversation,
ChatAgent,
AgentProfileLike,
TokenUsage,
SendMessageContext,
MessageFile,
CodeBlock,
} from './chat';
export {
CHAT_MODES,
} from './chat';