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:
223
desktop/src-tauri/src/classroom_commands/chat.rs
Normal file
223
desktop/src-tauri/src/classroom_commands/chat.rs
Normal 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)
|
||||
}
|
||||
152
desktop/src-tauri/src/classroom_commands/export.rs
Normal file
152
desktop/src-tauri/src/classroom_commands/export.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
286
desktop/src-tauri/src/classroom_commands/generate.rs
Normal file
286
desktop/src-tauri/src/classroom_commands/generate.rs
Normal 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())
|
||||
}
|
||||
41
desktop/src-tauri/src/classroom_commands/mod.rs
Normal file
41
desktop/src-tauri/src/classroom_commands/mod.rs
Normal 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()))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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_") {
|
||||
|
||||
@@ -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(§ion_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('"', "\\\""))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
121
desktop/src/components/classroom_player/AgentChat.tsx
Normal file
121
desktop/src/components/classroom_player/AgentChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
desktop/src/components/classroom_player/ClassroomPlayer.tsx
Normal file
231
desktop/src/components/classroom_player/ClassroomPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
desktop/src/components/classroom_player/NotesSidebar.tsx
Normal file
71
desktop/src/components/classroom_player/NotesSidebar.tsx
Normal 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';
|
||||
}
|
||||
}
|
||||
219
desktop/src/components/classroom_player/SceneRenderer.tsx
Normal file
219
desktop/src/components/classroom_player/SceneRenderer.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
desktop/src/components/classroom_player/TtsPlayer.tsx
Normal file
155
desktop/src/components/classroom_player/TtsPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
295
desktop/src/components/classroom_player/WhiteboardCanvas.tsx
Normal file
295
desktop/src/components/classroom_player/WhiteboardCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
desktop/src/components/classroom_player/index.ts
Normal file
12
desktop/src/components/classroom_player/index.ts
Normal 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';
|
||||
76
desktop/src/hooks/useClassroom.ts
Normal file
76
desktop/src/hooks/useClassroom.ts
Normal 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]),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
142
desktop/src/lib/classroom-adapter.ts
Normal file
142
desktop/src/lib/classroom-adapter.ts
Normal 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 '中级';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +109,11 @@ export function installSkillMethods(ClientClass: { prototype: KernelClient }): v
|
||||
}> {
|
||||
return invoke('skill_execute', {
|
||||
id,
|
||||
context: {},
|
||||
context: {
|
||||
agentId: '',
|
||||
sessionId: '',
|
||||
workingDir: '',
|
||||
},
|
||||
input: input || {},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,7 +47,6 @@ export type { EncryptedData } from './crypto-utils';
|
||||
// Re-export secure storage
|
||||
export {
|
||||
secureStorage,
|
||||
secureStorageSync,
|
||||
isSecureStorageAvailable,
|
||||
storeDeviceKeys,
|
||||
getDeviceKeys,
|
||||
|
||||
54
desktop/src/store/chat/artifactStore.ts
Normal file
54
desktop/src/store/chat/artifactStore.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* ArtifactStore — manages the artifact panel state.
|
||||
*
|
||||
* Extracted from chatStore.ts as part of the structured refactor.
|
||||
* This store has zero external dependencies — the simplest slice to extract.
|
||||
*
|
||||
* @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.5
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { ArtifactFile } from '../../components/ai/ArtifactPanel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ArtifactState {
|
||||
/** All artifacts generated in the current session */
|
||||
artifacts: ArtifactFile[];
|
||||
/** Currently selected artifact ID */
|
||||
selectedArtifactId: string | null;
|
||||
/** Whether the artifact panel is open */
|
||||
artifactPanelOpen: boolean;
|
||||
|
||||
// Actions
|
||||
addArtifact: (artifact: ArtifactFile) => void;
|
||||
selectArtifact: (id: string | null) => void;
|
||||
setArtifactPanelOpen: (open: boolean) => void;
|
||||
clearArtifacts: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useArtifactStore = create<ArtifactState>()((set) => ({
|
||||
artifacts: [],
|
||||
selectedArtifactId: null,
|
||||
artifactPanelOpen: false,
|
||||
|
||||
addArtifact: (artifact: ArtifactFile) =>
|
||||
set((state) => ({
|
||||
artifacts: [...state.artifacts, artifact],
|
||||
selectedArtifactId: artifact.id,
|
||||
artifactPanelOpen: true,
|
||||
})),
|
||||
|
||||
selectArtifact: (id: string | null) => set({ selectedArtifactId: id }),
|
||||
|
||||
setArtifactPanelOpen: (open: boolean) => set({ artifactPanelOpen: open }),
|
||||
|
||||
clearArtifacts: () =>
|
||||
set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
|
||||
}));
|
||||
368
desktop/src/store/chat/conversationStore.ts
Normal file
368
desktop/src/store/chat/conversationStore.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* ConversationStore — manages conversation lifecycle, agent switching, and persistence.
|
||||
*
|
||||
* Extracted from chatStore.ts as part of the structured refactor.
|
||||
* Responsible for: conversation CRUD, agent list/sync, session/model state.
|
||||
*
|
||||
* @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.2
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { generateRandomString } from '../lib/crypto-utils';
|
||||
import { createLogger } from '../lib/logger';
|
||||
import type { Message } from './chatStore';
|
||||
|
||||
const log = createLogger('ConversationStore');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
agentId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
lastMessage: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface AgentProfileLike {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
// Re-export Message for internal use (avoids circular imports during migration)
|
||||
export type { Message };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConversationState {
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
agents: Agent[];
|
||||
currentAgent: Agent | null;
|
||||
sessionKey: string | null;
|
||||
currentModel: string;
|
||||
|
||||
// Actions
|
||||
newConversation: (currentMessages: Message[]) => Conversation[];
|
||||
switchConversation: (id: string, currentMessages: Message[]) => {
|
||||
conversations: Conversation[];
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
currentAgent: Agent;
|
||||
currentConversationId: string;
|
||||
isStreaming: boolean;
|
||||
} | null;
|
||||
deleteConversation: (id: string, currentConversationId: string | null) => {
|
||||
conversations: Conversation[];
|
||||
resetMessages: boolean;
|
||||
};
|
||||
setCurrentAgent: (agent: Agent, currentMessages: Message[]) => {
|
||||
conversations: Conversation[];
|
||||
currentAgent: Agent;
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
isStreaming: boolean;
|
||||
currentConversationId: string | null;
|
||||
};
|
||||
syncAgents: (profiles: AgentProfileLike[]) => {
|
||||
agents: Agent[];
|
||||
currentAgent: Agent;
|
||||
};
|
||||
setCurrentModel: (model: string) => void;
|
||||
upsertActiveConversation: (currentMessages: Message[]) => Conversation[];
|
||||
getCurrentConversationId: () => string | null;
|
||||
getCurrentAgent: () => Agent | null;
|
||||
getSessionKey: () => string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateConvId(): string {
|
||||
return `conv_${Date.now()}_${generateRandomString(4)}`;
|
||||
}
|
||||
|
||||
function deriveTitle(messages: Message[]): string {
|
||||
const firstUser = messages.find(m => m.role === 'user');
|
||||
if (firstUser) {
|
||||
const text = firstUser.content.trim();
|
||||
return text.length > 30 ? text.slice(0, 30) + '...' : text;
|
||||
}
|
||||
return '新对话';
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT: Agent = {
|
||||
id: '1',
|
||||
name: 'ZCLAW',
|
||||
icon: '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: '发送消息开始对话',
|
||||
time: '',
|
||||
};
|
||||
|
||||
export { DEFAULT_AGENT };
|
||||
|
||||
export function toChatAgent(profile: AgentProfileLike): Agent {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
icon: profile.nickname?.slice(0, 1) || '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: profile.role || '新分身',
|
||||
time: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConversationAgentId(agent: Agent | null): string | null {
|
||||
if (!agent || agent.id === DEFAULT_AGENT.id) {
|
||||
return null;
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
export function resolveGatewayAgentId(agent: Agent | null): string | undefined {
|
||||
if (!agent || agent.id === DEFAULT_AGENT.id || agent.id.startsWith('clone_')) {
|
||||
return undefined;
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
export function resolveAgentForConversation(agentId: string | null, agents: Agent[]): Agent {
|
||||
if (!agentId) {
|
||||
return DEFAULT_AGENT;
|
||||
}
|
||||
return agents.find((agent) => agent.id === agentId) || DEFAULT_AGENT;
|
||||
}
|
||||
|
||||
function upsertActiveConversation(
|
||||
conversations: Conversation[],
|
||||
messages: Message[],
|
||||
sessionKey: string | null,
|
||||
currentConversationId: string | null,
|
||||
currentAgent: Agent | null,
|
||||
): Conversation[] {
|
||||
if (messages.length === 0) {
|
||||
return conversations;
|
||||
}
|
||||
|
||||
const currentId = currentConversationId || generateConvId();
|
||||
const existingIdx = conversations.findIndex((conv) => conv.id === currentId);
|
||||
const nextConversation: Conversation = {
|
||||
id: currentId,
|
||||
title: deriveTitle(messages),
|
||||
messages: [...messages],
|
||||
sessionKey,
|
||||
agentId: resolveConversationAgentId(currentAgent),
|
||||
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
const updated = [...conversations];
|
||||
updated[existingIdx] = nextConversation;
|
||||
return updated;
|
||||
}
|
||||
|
||||
return [nextConversation, ...conversations];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useConversationStore = create<ConversationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [DEFAULT_AGENT],
|
||||
currentAgent: DEFAULT_AGENT,
|
||||
sessionKey: null,
|
||||
currentModel: 'glm-4-flash',
|
||||
|
||||
newConversation: (currentMessages: Message[]) => {
|
||||
const state = get();
|
||||
const conversations = upsertActiveConversation(
|
||||
[...state.conversations], currentMessages, state.sessionKey,
|
||||
state.currentConversationId, state.currentAgent,
|
||||
);
|
||||
set({
|
||||
conversations,
|
||||
sessionKey: null,
|
||||
currentConversationId: null,
|
||||
});
|
||||
return conversations;
|
||||
},
|
||||
|
||||
switchConversation: (id: string, currentMessages: Message[]) => {
|
||||
const state = get();
|
||||
const conversations = upsertActiveConversation(
|
||||
[...state.conversations], currentMessages, state.sessionKey,
|
||||
state.currentConversationId, state.currentAgent,
|
||||
);
|
||||
|
||||
const target = conversations.find(c => c.id === id);
|
||||
if (target) {
|
||||
set({
|
||||
conversations,
|
||||
currentAgent: resolveAgentForConversation(target.agentId, state.agents),
|
||||
currentConversationId: target.id,
|
||||
});
|
||||
return {
|
||||
conversations,
|
||||
messages: [...target.messages],
|
||||
sessionKey: target.sessionKey,
|
||||
currentAgent: resolveAgentForConversation(target.agentId, state.agents),
|
||||
currentConversationId: target.id,
|
||||
isStreaming: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
deleteConversation: (id: string, currentConversationId: string | null) => {
|
||||
const state = get();
|
||||
const conversations = state.conversations.filter(c => c.id !== id);
|
||||
const resetMessages = currentConversationId === id;
|
||||
if (resetMessages) {
|
||||
set({ conversations, currentConversationId: null, sessionKey: null });
|
||||
} else {
|
||||
set({ conversations });
|
||||
}
|
||||
return { conversations, resetMessages };
|
||||
},
|
||||
|
||||
setCurrentAgent: (agent: Agent, currentMessages: Message[]) => {
|
||||
const state = get();
|
||||
if (state.currentAgent?.id === agent.id) {
|
||||
set({ currentAgent: agent });
|
||||
return {
|
||||
conversations: state.conversations,
|
||||
currentAgent: agent,
|
||||
messages: currentMessages,
|
||||
sessionKey: state.sessionKey,
|
||||
isStreaming: false,
|
||||
currentConversationId: state.currentConversationId,
|
||||
};
|
||||
}
|
||||
|
||||
const conversations = upsertActiveConversation(
|
||||
[...state.conversations], currentMessages, state.sessionKey,
|
||||
state.currentConversationId, state.currentAgent,
|
||||
);
|
||||
|
||||
const agentConversation = conversations.find(c =>
|
||||
c.agentId === agent.id ||
|
||||
(agent.id === DEFAULT_AGENT.id && c.agentId === null)
|
||||
);
|
||||
|
||||
if (agentConversation) {
|
||||
set({
|
||||
conversations,
|
||||
currentAgent: agent,
|
||||
currentConversationId: agentConversation.id,
|
||||
});
|
||||
return {
|
||||
conversations,
|
||||
currentAgent: agent,
|
||||
messages: [...agentConversation.messages],
|
||||
sessionKey: agentConversation.sessionKey,
|
||||
isStreaming: false,
|
||||
currentConversationId: agentConversation.id,
|
||||
};
|
||||
}
|
||||
|
||||
set({
|
||||
conversations,
|
||||
currentAgent: agent,
|
||||
sessionKey: null,
|
||||
currentConversationId: null,
|
||||
});
|
||||
return {
|
||||
conversations,
|
||||
currentAgent: agent,
|
||||
messages: [],
|
||||
sessionKey: null,
|
||||
isStreaming: false,
|
||||
currentConversationId: null,
|
||||
};
|
||||
},
|
||||
|
||||
syncAgents: (profiles: AgentProfileLike[]) => {
|
||||
const state = get();
|
||||
const cloneAgents = profiles.length > 0 ? profiles.map(toChatAgent) : [];
|
||||
const agents = cloneAgents.length > 0
|
||||
? [DEFAULT_AGENT, ...cloneAgents]
|
||||
: [DEFAULT_AGENT];
|
||||
const currentAgent = state.currentConversationId
|
||||
? resolveAgentForConversation(
|
||||
state.conversations.find((conv) => conv.id === state.currentConversationId)?.agentId || null,
|
||||
agents
|
||||
)
|
||||
: state.currentAgent
|
||||
? agents.find((a) => a.id === state.currentAgent?.id) || agents[0]
|
||||
: agents[0];
|
||||
|
||||
set({ agents, currentAgent });
|
||||
return { agents, currentAgent };
|
||||
},
|
||||
|
||||
setCurrentModel: (model: string) => set({ currentModel: model }),
|
||||
|
||||
upsertActiveConversation: (currentMessages: Message[]) => {
|
||||
const state = get();
|
||||
const conversations = upsertActiveConversation(
|
||||
[...state.conversations], currentMessages, state.sessionKey,
|
||||
state.currentConversationId, state.currentAgent,
|
||||
);
|
||||
set({ conversations });
|
||||
return conversations;
|
||||
},
|
||||
|
||||
getCurrentConversationId: () => get().currentConversationId,
|
||||
getCurrentAgent: () => get().currentAgent,
|
||||
getSessionKey: () => get().sessionKey,
|
||||
}),
|
||||
{
|
||||
name: 'zclaw-conversation-storage',
|
||||
partialize: (state) => ({
|
||||
conversations: state.conversations,
|
||||
currentModel: state.currentModel,
|
||||
currentAgentId: state.currentAgent?.id,
|
||||
currentConversationId: state.currentConversationId,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state?.conversations) {
|
||||
for (const conv of state.conversations) {
|
||||
conv.createdAt = new Date(conv.createdAt);
|
||||
conv.updatedAt = new Date(conv.updatedAt);
|
||||
for (const msg of conv.messages) {
|
||||
msg.timestamp = new Date(msg.timestamp);
|
||||
msg.streaming = false;
|
||||
msg.optimistic = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -103,10 +103,6 @@ interface ChatState {
|
||||
chatMode: ChatModeType;
|
||||
// Follow-up suggestions
|
||||
suggestions: string[];
|
||||
// Artifacts (DeerFlow-inspired)
|
||||
artifacts: import('../components/ai/ArtifactPanel').ArtifactFile[];
|
||||
selectedArtifactId: string | null;
|
||||
artifactPanelOpen: boolean;
|
||||
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||
@@ -128,11 +124,6 @@ interface ChatState {
|
||||
setSuggestions: (suggestions: string[]) => void;
|
||||
addSubtask: (messageId: string, task: Subtask) => void;
|
||||
updateSubtask: (messageId: string, taskId: string, updates: Partial<Subtask>) => void;
|
||||
// Artifact management (DeerFlow-inspired)
|
||||
addArtifact: (artifact: import('../components/ai/ArtifactPanel').ArtifactFile) => void;
|
||||
selectArtifact: (id: string | null) => void;
|
||||
setArtifactPanelOpen: (open: boolean) => void;
|
||||
clearArtifacts: () => void;
|
||||
}
|
||||
|
||||
function generateConvId(): string {
|
||||
@@ -271,10 +262,6 @@ export const useChatStore = create<ChatState>()(
|
||||
totalOutputTokens: 0,
|
||||
chatMode: 'thinking' as ChatModeType,
|
||||
suggestions: [],
|
||||
artifacts: [],
|
||||
selectedArtifactId: null,
|
||||
artifactPanelOpen: false,
|
||||
|
||||
addMessage: (message: Message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
@@ -401,6 +388,10 @@ export const useChatStore = create<ChatState>()(
|
||||
},
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
// Concurrency guard: prevent rapid double-click bypassing UI-level isStreaming check.
|
||||
// React re-render is async — two clicks within the same frame both read isStreaming=false.
|
||||
if (get().isStreaming) return;
|
||||
|
||||
const { addMessage, currentAgent, sessionKey } = get();
|
||||
// Clear stale suggestions when user sends a new message
|
||||
set({ suggestions: [] });
|
||||
@@ -436,27 +427,10 @@ export const useChatStore = create<ChatState>()(
|
||||
|
||||
// Context compaction is handled by the kernel (AgentLoop with_compaction_threshold).
|
||||
// Frontend no longer performs duplicate compaction — see crates/zclaw-runtime/src/compaction.rs.
|
||||
|
||||
// Build memory-enhanced content using layered context (L0/L1/L2)
|
||||
let enhancedContent = content;
|
||||
try {
|
||||
const contextResult = await intelligenceClient.memory.buildContext(
|
||||
agentId,
|
||||
content,
|
||||
500, // token budget for memory context
|
||||
);
|
||||
if (contextResult.systemPromptAddition) {
|
||||
const systemPrompt = await intelligenceClient.identity.buildPrompt(
|
||||
agentId,
|
||||
contextResult.systemPromptAddition,
|
||||
);
|
||||
if (systemPrompt) {
|
||||
enhancedContent = `<context>\n${systemPrompt}\n</context>\n\n${content}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Memory context build failed, proceeding without:', err);
|
||||
}
|
||||
// Memory context injection is handled by backend MemoryMiddleware (before_completion),
|
||||
// which injects relevant memories into the system prompt. Frontend must NOT duplicate
|
||||
// this by embedding old conversation memories into the user message content — that causes
|
||||
// context leaking (old conversations appearing in new chat thinking/output).
|
||||
|
||||
// Add user message (original content for display)
|
||||
// Mark as optimistic -- will be cleared when server confirms via onComplete
|
||||
@@ -504,7 +478,7 @@ export const useChatStore = create<ChatState>()(
|
||||
|
||||
// Try streaming first (ZCLAW WebSocket)
|
||||
const result = await client.chatStream(
|
||||
enhancedContent,
|
||||
content,
|
||||
{
|
||||
onDelta: (delta: string) => {
|
||||
// Update message content directly (works for both KernelClient and GatewayClient)
|
||||
@@ -516,6 +490,15 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
}));
|
||||
},
|
||||
onThinkingDelta: (delta: string) => {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, thinkingContent: (m.thinkingContent || '') + delta }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
},
|
||||
onTool: (tool: string, input: string, output: string) => {
|
||||
const step: ToolCallStep = {
|
||||
id: `step_${Date.now()}_${generateRandomString(4)}`,
|
||||
@@ -732,20 +715,6 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
})),
|
||||
|
||||
// Artifact management (DeerFlow-inspired)
|
||||
addArtifact: (artifact) =>
|
||||
set((state) => ({
|
||||
artifacts: [...state.artifacts, artifact],
|
||||
selectedArtifactId: artifact.id,
|
||||
artifactPanelOpen: true,
|
||||
})),
|
||||
|
||||
selectArtifact: (id) => set({ selectedArtifactId: id }),
|
||||
|
||||
setArtifactPanelOpen: (open) => set({ artifactPanelOpen: open }),
|
||||
|
||||
clearArtifacts: () => set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
|
||||
|
||||
initStreamListener: () => {
|
||||
const client = getClient();
|
||||
|
||||
|
||||
223
desktop/src/store/classroomStore.ts
Normal file
223
desktop/src/store/classroomStore.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Classroom Store
|
||||
*
|
||||
* Zustand store for classroom generation, chat messages,
|
||||
* and active classroom data. Uses Tauri invoke for backend calls.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type {
|
||||
Classroom,
|
||||
ClassroomChatMessage,
|
||||
} from '../types/classroom';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('ClassroomStore');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GenerationRequest {
|
||||
topic: string;
|
||||
document?: string;
|
||||
style?: string;
|
||||
level?: string;
|
||||
targetDurationMinutes?: number;
|
||||
sceneCount?: number;
|
||||
customInstructions?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface GenerationResult {
|
||||
classroomId: string;
|
||||
}
|
||||
|
||||
export interface GenerationProgressEvent {
|
||||
topic: string;
|
||||
stage: string;
|
||||
progress: number;
|
||||
activity: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClassroomState {
|
||||
/** Currently generating classroom */
|
||||
generating: boolean;
|
||||
/** Generation progress stage */
|
||||
progressStage: string | null;
|
||||
progressPercent: number;
|
||||
progressActivity: string;
|
||||
/** Topic being generated (used for cancel) */
|
||||
generatingTopic: string | null;
|
||||
/** The active classroom */
|
||||
activeClassroom: Classroom | null;
|
||||
/** Whether the ClassroomPlayer overlay is open */
|
||||
classroomOpen: boolean;
|
||||
/** Chat messages for the active classroom */
|
||||
chatMessages: ClassroomChatMessage[];
|
||||
/** Whether chat is loading */
|
||||
chatLoading: boolean;
|
||||
/** Generation error message */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ClassroomActions {
|
||||
startGeneration: (request: GenerationRequest) => Promise<string>;
|
||||
cancelGeneration: () => void;
|
||||
loadClassroom: (id: string) => Promise<void>;
|
||||
setActiveClassroom: (classroom: Classroom) => void;
|
||||
openClassroom: () => void;
|
||||
closeClassroom: () => void;
|
||||
sendChatMessage: (message: string, sceneContext?: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type ClassroomStore = ClassroomState & ClassroomActions;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useClassroomStore = create<ClassroomStore>()((set, get) => ({
|
||||
generating: false,
|
||||
progressStage: null,
|
||||
progressPercent: 0,
|
||||
progressActivity: '',
|
||||
generatingTopic: null,
|
||||
activeClassroom: null,
|
||||
classroomOpen: false,
|
||||
chatMessages: [],
|
||||
chatLoading: false,
|
||||
error: null,
|
||||
|
||||
startGeneration: async (request) => {
|
||||
set({
|
||||
generating: true,
|
||||
progressStage: 'agent_profiles',
|
||||
progressPercent: 0,
|
||||
progressActivity: 'Starting generation...',
|
||||
generatingTopic: request.topic,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Listen for progress events from Rust
|
||||
const unlisten = await listen<GenerationProgressEvent>('classroom:progress', (event) => {
|
||||
const { stage, progress, activity } = event.payload;
|
||||
set({
|
||||
progressStage: stage,
|
||||
progressPercent: progress,
|
||||
progressActivity: activity,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await invoke<GenerationResult>('classroom_generate', { request });
|
||||
set({ generating: false });
|
||||
await get().loadClassroom(result.classroomId);
|
||||
set({ classroomOpen: true });
|
||||
return result.classroomId;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log.error('Generation failed', { error: msg });
|
||||
set({ generating: false, error: msg });
|
||||
throw e;
|
||||
} finally {
|
||||
unlisten();
|
||||
}
|
||||
},
|
||||
|
||||
cancelGeneration: () => {
|
||||
const topic = get().generatingTopic;
|
||||
if (topic) {
|
||||
invoke('classroom_cancel_generation', { topic }).catch(() => {});
|
||||
}
|
||||
set({ generating: false, generatingTopic: null });
|
||||
},
|
||||
|
||||
loadClassroom: async (id) => {
|
||||
try {
|
||||
const classroom = await invoke<Classroom>('classroom_get', { classroomId: id });
|
||||
set({ activeClassroom: classroom, chatMessages: [] });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log.error('Failed to load classroom', { error: msg });
|
||||
set({ error: msg });
|
||||
}
|
||||
},
|
||||
|
||||
setActiveClassroom: (classroom) => {
|
||||
set({ activeClassroom: classroom, chatMessages: [], classroomOpen: true });
|
||||
},
|
||||
|
||||
openClassroom: () => {
|
||||
set({ classroomOpen: true });
|
||||
},
|
||||
|
||||
closeClassroom: () => {
|
||||
set({ classroomOpen: false });
|
||||
},
|
||||
|
||||
sendChatMessage: async (message, sceneContext) => {
|
||||
const classroom = get().activeClassroom;
|
||||
if (!classroom) {
|
||||
log.error('No active classroom');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a local user message for display
|
||||
const userMsg: ClassroomChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
agentId: 'user',
|
||||
agentName: '你',
|
||||
agentAvatar: '👤',
|
||||
content: message,
|
||||
timestamp: Date.now(),
|
||||
role: 'user',
|
||||
color: '#3b82f6',
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
chatMessages: [...state.chatMessages, userMsg],
|
||||
chatLoading: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const responses = await invoke<ClassroomChatMessage[]>('classroom_chat', {
|
||||
request: {
|
||||
classroomId: classroom.id,
|
||||
userMessage: message,
|
||||
sceneContext: sceneContext ?? null,
|
||||
},
|
||||
});
|
||||
set((state) => ({
|
||||
chatMessages: [...state.chatMessages, ...responses],
|
||||
chatLoading: false,
|
||||
}));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log.error('Chat failed', { error: msg });
|
||||
set({ chatLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
reset: () => set({
|
||||
generating: false,
|
||||
progressStage: null,
|
||||
progressPercent: 0,
|
||||
progressActivity: '',
|
||||
activeClassroom: null,
|
||||
classroomOpen: false,
|
||||
chatMessages: [],
|
||||
chatLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
@@ -55,6 +55,17 @@ export type {
|
||||
SessionOptions,
|
||||
} from '../components/BrowserHand/templates/types';
|
||||
|
||||
// === Classroom Store ===
|
||||
export { useClassroomStore } from './classroomStore';
|
||||
export type {
|
||||
ClassroomState,
|
||||
ClassroomActions,
|
||||
ClassroomStore,
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
GenerationProgressEvent,
|
||||
} from './classroomStore';
|
||||
|
||||
// === Store Initialization ===
|
||||
|
||||
import { getClient } from './connectionStore';
|
||||
|
||||
@@ -536,6 +536,27 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
// Update last sync timestamp
|
||||
localStorage.setItem(lastSyncKey, result.pulled_at);
|
||||
log.info(`Synced ${result.configs.length} config items from SaaS`);
|
||||
|
||||
// Propagate Kernel-relevant configs to Rust backend
|
||||
const kernelCategories = ['agent', 'llm'];
|
||||
const kernelConfigs = result.configs.filter(
|
||||
(c) => kernelCategories.includes(c.category) && c.value !== null
|
||||
);
|
||||
if (kernelConfigs.length > 0) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('kernel_apply_saas_config', {
|
||||
configs: kernelConfigs.map((c) => ({
|
||||
category: c.category,
|
||||
key: c.key,
|
||||
value: c.value,
|
||||
})),
|
||||
});
|
||||
log.info(`Propagated ${kernelConfigs.length} Kernel configs to Rust backend`);
|
||||
} catch (invokeErr: unknown) {
|
||||
log.warn('Failed to propagate configs to Kernel (non-fatal):', invokeErr);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('Failed to sync config from SaaS:', err);
|
||||
}
|
||||
|
||||
133
desktop/src/types/chat.ts
Normal file
133
desktop/src/types/chat.ts
Normal 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;
|
||||
}
|
||||
181
desktop/src/types/classroom.ts
Normal file
181
desktop/src/types/classroom.ts
Normal 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[];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user