diff --git a/package.json b/package.json index f21e94b1f..926e99bf3 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-shell": "~2", "@types/react-router-dom": "^5.3.3", + "@types/three": "^0.183.1", "buffer": "^6.0.3", "debug": "^4.4.3", "immer": "^11.1.3", diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs new file mode 100644 index 000000000..4104f2933 --- /dev/null +++ b/src-tauri/src/commands/chat.rs @@ -0,0 +1,1096 @@ +//! Tauri commands for the Rust-side conversation orchestration. +//! +//! Moves the agentic loop (context injection, inference API calls, tool execution) +//! from `Conversations.tsx` into Rust, so the frontend becomes a thin renderer. +//! +//! # Command overview +//! +//! - `chat_send` — spawn the agentic loop in a background task; returns immediately. +//! - `chat_cancel` — cancel an in-flight `chat_send` by thread ID. +//! +//! # Event protocol (Rust → frontend) +//! +//! | Event name | Payload type | When emitted | +//! |-------------------|-----------------------|---------------------------------| +//! | `chat:tool_call` | `ChatToolCallEvent` | Before a tool is executed | +//! | `chat:tool_result`| `ChatToolResultEvent` | After a tool completes | +//! | `chat:done` | `ChatDoneEvent` | Agent loop finishes (success) | +//! | `chat:error` | `ChatErrorEvent` | Any error during the loop | + +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tauri::{Emitter, Manager}; +use tokio_util::sync::CancellationToken; + +use crate::commands::memory::MemoryState; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const MAX_TOOL_ROUNDS: u32 = 5; +const INFERENCE_TIMEOUT_SECS: u64 = 120; +const TOOL_TIMEOUT_SECS: u64 = 60; +const MAX_CONTEXT_CHARS: usize = 20_000; + +/// Names and order of the OpenClaw workspace files. +const OPENCLAW_FILES: &[&str] = &[ + "SOUL.md", + "IDENTITY.md", + "AGENTS.md", + "USER.md", + "BOOTSTRAP.md", + "MEMORY.md", + "TOOLS.md", +]; + +// ─── Input types (frontend → Rust) ─────────────────────────────────────────── + +/// A single message in the conversation history, sent from the frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessagePayload { + pub role: String, // "user" | "assistant" | "system" | "tool" + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallPayload { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, // always "function" + pub function: ToolCallFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallFunction { + pub name: String, + pub arguments: String, // JSON string +} + +/// Parameters for the `chat_send` Tauri command. +#[derive(Debug, Clone, Deserialize)] +pub struct ChatSendParams { + pub thread_id: String, + pub message: String, + pub model: String, + pub auth_token: String, + pub backend_url: String, + pub messages: Vec, + #[serde(default)] + pub notion_context: Option, +} + +// ─── Event payload types (Rust → frontend) ─────────────────────────────────── + +/// Emitted when the agent invokes a tool. +#[derive(Debug, Clone, Serialize)] +pub struct ChatToolCallEvent { + pub thread_id: String, + pub tool_name: String, + pub skill_id: String, + pub args: serde_json::Value, + pub round: u32, +} + +/// Emitted when a tool completes execution. +#[derive(Debug, Clone, Serialize)] +pub struct ChatToolResultEvent { + pub thread_id: String, + pub tool_name: String, + pub skill_id: String, + pub output: String, + pub success: bool, + pub round: u32, +} + +/// Emitted when the agent loop completes successfully. +#[derive(Debug, Clone, Serialize)] +pub struct ChatDoneEvent { + pub thread_id: String, + pub full_response: String, + pub rounds_used: u32, + pub total_input_tokens: u64, + pub total_output_tokens: u64, +} + +/// Emitted when an error occurs during the agent loop. +#[derive(Debug, Clone, Serialize)] +pub struct ChatErrorEvent { + pub thread_id: String, + pub message: String, + /// "network" | "timeout" | "tool_error" | "inference" | "cancelled" + pub error_type: String, + pub round: Option, +} + +// ─── Backend API response types ─────────────────────────────────────────────── + +/// OpenAI-compatible chat completion response. +#[derive(Debug, Clone, Deserialize)] +pub struct ChatCompletionResponse { + #[allow(dead_code)] + pub id: String, + #[allow(dead_code)] + pub model: String, + pub choices: Vec, + #[serde(default)] + pub usage: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChatCompletionChoice { + #[allow(dead_code)] + pub index: u32, + pub message: ChatCompletionMessage, + pub finish_reason: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChatCompletionMessage { + #[allow(dead_code)] + pub role: String, + pub content: Option, + #[serde(default)] + pub tool_calls: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CompletionUsage { + pub prompt_tokens: u64, + pub completion_tokens: u64, + #[allow(dead_code)] + pub total_tokens: u64, +} + +// ─── Internal state ─────────────────────────────────────────────────────────── + +/// Tracks in-flight chat requests for cancellation support. +pub struct ChatState { + active_requests: RwLock>, +} + +impl ChatState { + pub fn new() -> Self { + Self { + active_requests: RwLock::new(HashMap::new()), + } + } + + pub fn register(&self, thread_id: &str) -> CancellationToken { + let token = CancellationToken::new(); + self.active_requests + .write() + .insert(thread_id.to_string(), token.clone()); + token + } + + pub fn cancel(&self, thread_id: &str) -> bool { + if let Some(token) = self.active_requests.write().remove(thread_id) { + token.cancel(); + true + } else { + false + } + } + + pub fn remove(&self, thread_id: &str) { + self.active_requests.write().remove(thread_id); + } +} + +// ─── AI config loader ───────────────────────────────────────────────────────── + +/// In-memory cache for AI config content. +/// Populated on first call; cleared only on app restart. +static AI_CONFIG_CACHE: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| parking_lot::RwLock::new(None)); + +/// Load all AI config files and build the OpenClaw context string. +/// +/// Tries these locations in order: +/// 1. Tauri resource directory (production builds — files bundled via `tauri.conf.json` resources) +/// 2. `{cwd}/../ai/` (dev mode — project root relative to `src-tauri/`) +/// 3. `{cwd}/ai/` (fallback) +/// +/// Returns an empty string if no files are found (non-fatal). +fn load_openclaw_context(app: &tauri::AppHandle) -> String { + // Check cache first + if let Some(cached) = AI_CONFIG_CACHE.read().as_ref() { + return cached.clone(); + } + + let mut sections: Vec = Vec::new(); + + if let Some(dir) = find_ai_directory(app) { + for filename in OPENCLAW_FILES { + let path = dir.join(filename); + if let Ok(content) = std::fs::read_to_string(&path) { + let trimmed = content.trim().to_string(); + if has_meaningful_content(&trimmed) { + sections.push(format!("### {}\n\n{}", filename, trimmed)); + } + } + } + } + + if sections.is_empty() { + log::warn!("[chat] No AI config files found — proceeding without context"); + let empty = String::new(); + *AI_CONFIG_CACHE.write() = Some(empty.clone()); + return empty; + } + + let mut context = format!("## Project Context\n\n{}", sections.join("\n\n---\n\n")); + if context.len() > MAX_CONTEXT_CHARS { + context.truncate(MAX_CONTEXT_CHARS); + context.push_str("\n\n[...truncated]"); + } + + *AI_CONFIG_CACHE.write() = Some(context.clone()); + context +} + +/// Find the `ai/` directory. Returns `None` if not found. +fn find_ai_directory(app: &tauri::AppHandle) -> Option { + // 1. Try resource dir first (production builds) + if let Ok(resource_dir) = app.path().resource_dir() { + let ai_dir: std::path::PathBuf = resource_dir.join("ai"); + if ai_dir.is_dir() { + log::info!( + "[chat] Using AI config from resource dir: {}", + ai_dir.display() + ); + return Some(ai_dir); + } + } + + // 2. Try cwd/../ai/ (dev mode; cwd is src-tauri/) + if let Ok(cwd) = std::env::current_dir() { + let dev_dir = cwd.parent().map(|p| p.join("ai")); + if let Some(ref dir) = dev_dir { + if dir.is_dir() { + log::info!( + "[chat] Using AI config from dev dir: {}", + dir.display() + ); + return dev_dir; + } + } + // 3. Try cwd/ai/ (fallback) + let fallback = cwd.join("ai"); + if fallback.is_dir() { + log::info!( + "[chat] Using AI config from fallback dir: {}", + fallback.display() + ); + return Some(fallback); + } + } + + log::warn!("[chat] No AI config directory found"); + None +} + +/// Check if file content has meaningful data (not just a TODO template). +fn has_meaningful_content(content: &str) -> bool { + let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.len() <= 3 { + return false; + } + let first_content = lines.iter().find(|l| !l.starts_with('#')); + if let Some(line) = first_content { + if line.trim().starts_with("TODO:") { + return false; + } + } + true +} + +// ─── Tool discovery (desktop only) ─────────────────────────────────────────── + +/// Build OpenAI-format tool definitions from the Rust skill registry. +/// Tool names are namespaced as `{skill_id}__{tool_name}`. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn discover_tools( + engine: &crate::runtime::qjs_engine::RuntimeEngine, +) -> Vec { + let raw_tools = engine.all_tools(); + raw_tools + .into_iter() + .map(|(skill_id, tool)| { + serde_json::json!({ + "type": "function", + "function": { + "name": format!("{}__{}", skill_id, tool.name), + "description": tool.description, + "parameters": tool.input_schema, + } + }) + }) + .collect() +} + +// ─── Helper ─────────────────────────────────────────────────────────────────── + +/// Parse a namespaced tool name `"skillId__toolName"` into `(skill_id, tool_name)`. +fn parse_tool_name(full_name: &str) -> (String, String) { + if let Some(idx) = full_name.find("__") { + ( + full_name[..idx].to_string(), + full_name[idx + 2..].to_string(), + ) + } else { + (String::new(), full_name.to_string()) + } +} + +// ─── Commands ──────────────────────────────────────────────────────────────── + +/// Start an agentic conversation loop in a background task. +/// +/// Returns `Ok(())` immediately after spawning; the result is delivered via +/// `chat:done` or `chat:error` Tauri events. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[tauri::command] +pub async fn chat_send( + app: tauri::AppHandle, + thread_id: String, + message: String, + model: String, + auth_token: String, + backend_url: String, + messages: Vec, + notion_context: Option, + engine: tauri::State<'_, Arc>, + memory_state: tauri::State<'_, MemoryState>, + chat_state: tauri::State<'_, Arc>, +) -> Result<(), String> { + // Register cancellation token for this thread + let cancel = chat_state.register(&thread_id); + + // Clone values that need to cross the spawn boundary + let app_clone = app.clone(); + let thread_id_clone = thread_id.clone(); + let chat_state_arc = chat_state.inner().clone(); + let engine_arc = engine.inner().clone(); + + // Clone the MemoryClientRef (Option>) out of the Mutex + let memory_client: Option = { + match memory_state.0.lock() { + Ok(guard) => guard.clone(), + Err(e) => { + log::warn!("[chat] Failed to lock memory state: {e}"); + None + } + } + }; + + tauri::async_runtime::spawn(async move { + let result = chat_send_inner( + &app_clone, + &thread_id_clone, + &message, + &model, + &auth_token, + &backend_url, + messages, + notion_context, + &engine_arc, + memory_client, + &cancel, + ) + .await; + + // Clean up the cancellation token + chat_state_arc.remove(&thread_id_clone); + + if let Err(e) = result { + let _ = app_clone.emit( + "chat:error", + ChatErrorEvent { + thread_id: thread_id_clone, + message: e, + error_type: "inference".to_string(), + round: None, + }, + ); + } + }); + + Ok(()) +} + +/// Mobile stub — tool execution is not supported on Android/iOS. +#[cfg(any(target_os = "android", target_os = "ios"))] +#[tauri::command] +pub async fn chat_send( + app: tauri::AppHandle, + thread_id: String, + message: String, + model: String, + auth_token: String, + backend_url: String, + messages: Vec, + notion_context: Option, + memory_state: tauri::State<'_, MemoryState>, + chat_state: tauri::State<'_, Arc>, +) -> Result<(), String> { + // Register cancellation token for this thread + let cancel = chat_state.register(&thread_id); + + let app_clone = app.clone(); + let thread_id_clone = thread_id.clone(); + let chat_state_arc = chat_state.inner().clone(); + + let memory_client: Option = { + match memory_state.0.lock() { + Ok(guard) => guard.clone(), + Err(e) => { + log::warn!("[chat] Failed to lock memory state: {e}"); + None + } + } + }; + + tauri::async_runtime::spawn(async move { + let result = chat_send_mobile( + &app_clone, + &thread_id_clone, + &message, + &model, + &auth_token, + &backend_url, + messages, + notion_context, + memory_client, + &cancel, + ) + .await; + + chat_state_arc.remove(&thread_id_clone); + + if let Err(e) = result { + let _ = app_clone.emit( + "chat:error", + ChatErrorEvent { + thread_id: thread_id_clone, + message: e, + error_type: "inference".to_string(), + round: None, + }, + ); + } + }); + + Ok(()) +} + +/// Cancel an in-flight `chat_send` request by thread ID. +/// Returns `true` if a request was found and cancelled, `false` otherwise. +#[tauri::command] +pub fn chat_cancel(thread_id: String, chat_state: tauri::State<'_, Arc>) -> bool { + log::info!("[chat] cancel requested for thread={}", thread_id); + chat_state.cancel(&thread_id) +} + +// ─── Inner implementation (desktop) ────────────────────────────────────────── + +/// Agentic loop — runs in a background task on desktop. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn chat_send_inner( + app: &tauri::AppHandle, + thread_id: &str, + user_message: &str, + model: &str, + auth_token: &str, + backend_url: &str, + history: Vec, + notion_context: Option, + engine: &crate::runtime::qjs_engine::RuntimeEngine, + memory_client: Option, + cancel: &CancellationToken, +) -> Result<(), String> { + log::info!("[chat] Backend URL: {}", backend_url); + + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + + // ── Step 1: Load AI context ───────────────────────────────────────── + let openclaw_context = load_openclaw_context(app); + + // ── Step 2: Recall memory context ─────────────────────────────────── + let memory_context: Option = if let Some(ref mem) = memory_client { + match mem + .recall_skill_context("conversations", thread_id, 10) + .await + { + Ok(ctx) => ctx, + Err(e) => { + log::warn!("[chat] Memory recall failed: {}", e); + None + } + } + } else { + None + }; + + // ── Step 3: Build processed user message ──────────────────────────── + let mut processed = user_message.to_string(); + + if !openclaw_context.is_empty() { + processed = format!("{}\n\nUser message: {}", openclaw_context, processed); + } + + if let Some(ref mem) = memory_context { + processed = format!( + "[MEMORY_CONTEXT]\n{}\n[/MEMORY_CONTEXT]\n\n{}", + mem, processed + ); + } + + if let Some(ref notion) = notion_context { + processed = format!("{}\n\n{}", notion, processed); + } + + // ── Step 4: Build chat messages array ──────────────────────────────── + let mut loop_messages: Vec = history + .iter() + .map(|m| { + let mut obj = serde_json::json!({ + "role": m.role, + "content": m.content, + }); + if let Some(ref tc) = m.tool_calls { + obj["tool_calls"] = serde_json::to_value(tc).unwrap_or_default(); + } + if let Some(ref id) = m.tool_call_id { + obj["tool_call_id"] = serde_json::Value::String(id.clone()); + } + obj + }) + .collect(); + + // Append the current user message (with injected context) + loop_messages.push(serde_json::json!({ + "role": "user", + "content": processed, + })); + + // ── Step 5: Discover tools ────────────────────────────────────────── + let tools = discover_tools(engine); + + log::info!( + "[chat] Starting agent loop: model={}, history_msgs={}, tools={}", + model, + loop_messages.len(), + tools.len() + ); + + // ── Step 6: Agentic loop ──────────────────────────────────────────── + let mut final_content = String::new(); + let mut total_input_tokens: u64 = 0; + let mut total_output_tokens: u64 = 0; + + for round in 0..MAX_TOOL_ROUNDS { + // Check cancellation at the start of each round + if cancel.is_cancelled() { + return Err("Request cancelled".to_string()); + } + + // Build request body + let mut request_body = serde_json::json!({ + "model": model, + "messages": loop_messages, + }); + if !tools.is_empty() { + request_body["tools"] = serde_json::Value::Array(tools.clone()); + request_body["tool_choice"] = serde_json::Value::String("auto".to_string()); + } + + let url = format!("{}/openai/v1/chat/completions", backend_url); + log::info!( + "[chat] Round {} — sending inference request ({} messages) to {}", + round + 1, + loop_messages.len(), + url + ); + log::debug!( + "[chat] Request body: {}", + serde_json::to_string_pretty(&request_body).unwrap_or_default() + ); + + // POST to backend with timeout and cancellation support + let response = tokio::select! { + _ = cancel.cancelled() => { + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: "Request cancelled".to_string(), + error_type: "cancelled".to_string(), + round: Some(round), + }); + return Err("Request cancelled".to_string()); + } + result = tokio::time::timeout( + std::time::Duration::from_secs(INFERENCE_TIMEOUT_SECS), + client + .post(&url) + .header("Authorization", format!("Bearer {}", auth_token)) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + ) => { + match result { + Ok(Ok(resp)) => resp, + Ok(Err(e)) => { + log::error!("[chat] reqwest error detail: {:?}", e); + let msg = format!("Network error: {}", e); + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "network".to_string(), + round: Some(round), + }); + return Err(msg); + } + Err(_) => { + let msg = format!( + "Inference request timed out after {}s", + INFERENCE_TIMEOUT_SECS + ); + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "timeout".to_string(), + round: Some(round), + }); + return Err(msg); + } + } + } + }; + + // Check HTTP status + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let msg = format!("Backend returned HTTP {}: {}", status, body); + let _ = app.emit( + "chat:error", + ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "inference".to_string(), + round: Some(round), + }, + ); + return Err(msg); + } + + // Parse the completion response + let completion: ChatCompletionResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse inference response: {}", e))?; + + // Accumulate token usage + if let Some(ref usage) = completion.usage { + total_input_tokens += usage.prompt_tokens; + total_output_tokens += usage.completion_tokens; + } + + let choice = completion + .choices + .first() + .ok_or_else(|| "No choices in inference response".to_string())?; + + log::info!( + "[chat] Round {} — finish_reason={:?}, tool_calls={}", + round + 1, + choice.finish_reason, + choice.message.tool_calls.as_ref().map_or(0, |tc| tc.len()) + ); + + // Decide if we have tool calls to execute + let has_tool_calls = choice.finish_reason.as_deref() == Some("tool_calls") + && choice + .message + .tool_calls + .as_ref() + .map_or(false, |tc| !tc.is_empty()); + + if has_tool_calls { + let tool_calls = choice.message.tool_calls.as_ref().unwrap(); + + // Append the assistant message with tool_calls to the loop + loop_messages.push(serde_json::json!({ + "role": "assistant", + "content": choice.message.content.as_deref().unwrap_or(""), + "tool_calls": tool_calls, + })); + + // Execute only the last tool call (matching current TS behaviour); + // earlier ones get empty placeholder results. + let latest_idx = tool_calls.len() - 1; + + for (i, tc) in tool_calls.iter().enumerate() { + if i != latest_idx { + loop_messages.push(serde_json::json!({ + "role": "tool", + "tool_call_id": tc.id, + "content": "", + })); + continue; + } + + let (skill_id, tool_name) = parse_tool_name(&tc.function.name); + + let args_value: serde_json::Value = + serde_json::from_str(&tc.function.arguments) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())); + + // Emit tool_call event before executing + let _ = app.emit( + "chat:tool_call", + ChatToolCallEvent { + thread_id: thread_id.to_string(), + tool_name: tool_name.clone(), + skill_id: skill_id.clone(), + args: args_value.clone(), + round, + }, + ); + + // Execute the tool with timeout and cancellation + let tool_result = tokio::select! { + _ = cancel.cancelled() => { + return Err("Request cancelled during tool execution".to_string()); + } + result = tokio::time::timeout( + std::time::Duration::from_secs(TOOL_TIMEOUT_SECS), + engine.call_tool(&skill_id, &tool_name, args_value.clone()) + ) => { + match result { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + let msg = format!("Tool \"{}\" failed: {}", tool_name, e); + let _ = app.emit("chat:tool_result", ChatToolResultEvent { + thread_id: thread_id.to_string(), + tool_name: tool_name.clone(), + skill_id: skill_id.clone(), + output: msg.clone(), + success: false, + round, + }); + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "tool_error".to_string(), + round: Some(round), + }); + return Err(msg); + } + Err(_) => { + let msg = format!( + "Tool \"{}\" timed out after {}s", + tool_name, TOOL_TIMEOUT_SECS + ); + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "timeout".to_string(), + round: Some(round), + }); + return Err(msg); + } + } + } + }; + + // Extract text content from the tool result + let tool_content: String = tool_result + .content + .iter() + .filter_map(|c| match c { + crate::runtime::types::ToolContent::Text { text } => { + Some(text.as_str()) + } + crate::runtime::types::ToolContent::Json { .. } => None, + }) + .collect::>() + .join("\n"); + + // Check for JSON error pattern (matching TS behaviour) + let (final_tool_str, final_success) = if !tool_result.is_error { + if let Ok(parsed) = serde_json::from_str::(&tool_content) { + if let Some(error_str) = + parsed.get("error").and_then(|e| e.as_str()) + { + (format!("Error: {}", error_str), false) + } else { + (tool_content.clone(), true) + } + } else { + (tool_content.clone(), true) + } + } else { + let prefixed = if tool_content.starts_with("Error: ") { + tool_content.clone() + } else { + format!("Error: {}", tool_content) + }; + (prefixed, false) + }; + + // Emit tool_result event + let _ = app.emit( + "chat:tool_result", + ChatToolResultEvent { + thread_id: thread_id.to_string(), + tool_name: tool_name.clone(), + skill_id: skill_id.clone(), + output: final_tool_str.clone(), + success: final_success, + round, + }, + ); + + // Append tool result to loop messages + loop_messages.push(serde_json::json!({ + "role": "tool", + "tool_call_id": tc.id, + "content": final_tool_str, + })); + } + + // Continue to the next round + continue; + } + + // Non-tool response — the agent loop is done + final_content = choice.message.content.clone().unwrap_or_default(); + + let _ = app.emit( + "chat:done", + ChatDoneEvent { + thread_id: thread_id.to_string(), + full_response: final_content.clone(), + rounds_used: round + 1, + total_input_tokens, + total_output_tokens, + }, + ); + + return Ok(()); + } + + // Exhausted all rounds — emit whatever we have + let _ = app.emit( + "chat:done", + ChatDoneEvent { + thread_id: thread_id.to_string(), + full_response: final_content, + rounds_used: MAX_TOOL_ROUNDS, + total_input_tokens, + total_output_tokens, + }, + ); + + Ok(()) +} + +// ─── Inner implementation (mobile) ─────────────────────────────────────────── + +/// Simplified agentic loop for mobile — no tool execution, just a single inference call. +#[cfg(any(target_os = "android", target_os = "ios"))] +async fn chat_send_mobile( + app: &tauri::AppHandle, + thread_id: &str, + user_message: &str, + model: &str, + auth_token: &str, + backend_url: &str, + history: Vec, + notion_context: Option, + memory_client: Option, + cancel: &CancellationToken, +) -> Result<(), String> { + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + + // ── Step 1: Load AI context ───────────────────────────────────────── + let openclaw_context = load_openclaw_context(app); + + // ── Step 2: Recall memory context ─────────────────────────────────── + let memory_context: Option = if let Some(ref mem) = memory_client { + match mem + .recall_skill_context("conversations", thread_id, 10) + .await + { + Ok(ctx) => ctx, + Err(e) => { + log::warn!("[chat] Memory recall failed: {}", e); + None + } + } + } else { + None + }; + + // ── Step 3: Build processed user message ──────────────────────────── + let mut processed = user_message.to_string(); + + if !openclaw_context.is_empty() { + processed = format!("{}\n\nUser message: {}", openclaw_context, processed); + } + + if let Some(ref mem) = memory_context { + processed = format!( + "[MEMORY_CONTEXT]\n{}\n[/MEMORY_CONTEXT]\n\n{}", + mem, processed + ); + } + + if let Some(ref notion) = notion_context { + processed = format!("{}\n\n{}", notion, processed); + } + + // ── Step 4: Build messages array ───────────────────────────────────── + let mut messages: Vec = history + .iter() + .map(|m| { + let mut obj = serde_json::json!({ + "role": m.role, + "content": m.content, + }); + if let Some(ref tc) = m.tool_calls { + obj["tool_calls"] = serde_json::to_value(tc).unwrap_or_default(); + } + if let Some(ref id) = m.tool_call_id { + obj["tool_call_id"] = serde_json::Value::String(id.clone()); + } + obj + }) + .collect(); + + messages.push(serde_json::json!({ + "role": "user", + "content": processed, + })); + + if cancel.is_cancelled() { + return Err("Request cancelled".to_string()); + } + + let request_body = serde_json::json!({ + "model": model, + "messages": messages, + }); + + log::info!( + "[chat] Mobile inference: model={}, msgs={}", + model, + messages.len() + ); + + let response = tokio::select! { + _ = cancel.cancelled() => { + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: "Request cancelled".to_string(), + error_type: "cancelled".to_string(), + round: Some(0), + }); + return Err("Request cancelled".to_string()); + } + result = tokio::time::timeout( + std::time::Duration::from_secs(INFERENCE_TIMEOUT_SECS), + client + .post(format!("{}/openai/v1/chat/completions", backend_url)) + .header("Authorization", format!("Bearer {}", auth_token)) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + ) => { + match result { + Ok(Ok(resp)) => resp, + Ok(Err(e)) => { + let msg = format!("Network error: {}", e); + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "network".to_string(), + round: Some(0), + }); + return Err(msg); + } + Err(_) => { + let msg = format!( + "Inference request timed out after {}s", + INFERENCE_TIMEOUT_SECS + ); + let _ = app.emit("chat:error", ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "timeout".to_string(), + round: Some(0), + }); + return Err(msg); + } + } + } + }; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let msg = format!("Backend returned HTTP {}: {}", status, body); + let _ = app.emit( + "chat:error", + ChatErrorEvent { + thread_id: thread_id.to_string(), + message: msg.clone(), + error_type: "inference".to_string(), + round: Some(0), + }, + ); + return Err(msg); + } + + let completion: ChatCompletionResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse inference response: {}", e))?; + + let (total_input_tokens, total_output_tokens) = completion + .usage + .map(|u| (u.prompt_tokens, u.completion_tokens)) + .unwrap_or((0, 0)); + + let choice = completion + .choices + .first() + .ok_or_else(|| "No choices in inference response".to_string())?; + + let full_response = choice.message.content.clone().unwrap_or_default(); + + let _ = app.emit( + "chat:done", + ChatDoneEvent { + thread_id: thread_id.to_string(), + full_response, + rounds_used: 1, + total_input_tokens, + total_output_tokens, + }, + ); + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 03cfaec5a..c295a5f62 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod chat; pub mod memory; pub mod model; pub mod runtime; @@ -11,6 +12,7 @@ pub mod window; // Re-export all commands for registration pub use auth::*; +pub use chat::{chat_cancel, chat_send}; pub use memory::*; pub use model::*; pub use runtime::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af1eca6ec..4ad99215c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ mod unified_skills; mod utils; use ai::*; +use commands::chat::ChatState; use commands::unified_skills::{ unified_execute_skill, unified_generate_skill, unified_list_skills, unified_self_evolve_skill, }; @@ -688,6 +689,11 @@ pub fn run() { app.manage(commands::memory::MemoryState(std::sync::Mutex::new(None))); log::info!("[memory] Memory state registered — awaiting JWT from frontend"); + // Initialize ChatState for managing in-flight conversation requests + let chat_state = std::sync::Arc::new(ChatState::new()); + app.manage(chat_state); + log::info!("[chat] ChatState registered"); + // Store SocketManager as Tauri state app.manage(socket_mgr.clone()); @@ -839,6 +845,9 @@ pub fn run() { init_memory_client, memory_query, recall_memory, + // Chat commands (agentic conversation loop) + chat_send, + chat_cancel, ] } #[cfg(not(desktop))] @@ -960,6 +969,9 @@ pub fn run() { init_memory_client, memory_query, recall_memory, + // Chat commands (agentic conversation loop) + chat_send, + chat_cancel, ] } }) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 51ed8a14c..8b068d595 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -38,7 +38,8 @@ "icons/icon.ico" ], "resources": [ - "../skills/skills" + "../skills/skills", + "../ai" ], "macOS": { "minimumSystemVersion": "10.15", diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 2fcaeb9d2..9dfeec93d 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -18,8 +18,18 @@ import { type ModelInfo, type Tool, } from '../services/api/inferenceApi'; +import { + chatCancel, + chatSend, + subscribeChatEvents, + useRustChat, + type ChatToolCallEvent, + type ChatToolResultEvent, +} from '../services/chatService'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import type { NotionPageSummary, NotionSummary, NotionUserProfile } from '../store/notionSlice'; +import { store } from '../store'; +import { BACKEND_URL } from '../utils/config'; import { addInferenceResponse, addMessageLocal, @@ -141,6 +151,17 @@ const Conversations = () => { const [isLoadingModels, setIsLoadingModels] = useState(false); const [isSending, setIsSending] = useState(false); const [sendError, setSendError] = useState(null); + const [activeToolCall, setActiveToolCall] = useState<{ name: string; args: unknown } | null>( + null + ); + const rustChat = useRustChat(); + + // Ref to track selectedThreadId inside event callbacks without re-subscribing + const selectedThreadIdRef = useRef(selectedThreadId); + useEffect(() => { + selectedThreadIdRef.current = selectedThreadId; + }, [selectedThreadId]); + // Budget state const [teamUsage, setTeamUsage] = useState(null); const [isLoadingBudget, setIsLoadingBudget] = useState(false); @@ -288,6 +309,78 @@ const Conversations = () => { } }, [inputValue, sendError]); + // Subscribe to Rust chat events when running in Tauri. + // Registered ONCE (deps: [rustChat]) — uses refs for values that change. + useEffect(() => { + if (!rustChat) return; + + let cleanup: (() => void) | null = null; + let mounted = true; + + subscribeChatEvents({ + onToolCall: (event: ChatToolCallEvent) => { + if (event.thread_id !== selectedThreadIdRef.current) return; + setActiveToolCall({ name: event.tool_name, args: event.args }); + }, + onToolResult: (event: ChatToolResultEvent) => { + if (event.thread_id !== selectedThreadIdRef.current) return; + setActiveToolCall(null); + }, + onDone: event => { + // Guard against duplicate dispatch (React StrictMode double-fires effects in dev) + const currentState = store.getState() as { + thread: { messagesByThreadId: Record }; + }; + const threadMessages = currentState.thread.messagesByThreadId[event.thread_id] || []; + const lastMsg = threadMessages[threadMessages.length - 1]; + if (lastMsg?.sender === 'agent' && lastMsg?.content === event.full_response) { + return; // Already added — skip duplicate + } + + dispatch(addInferenceResponse({ content: event.full_response, threadId: event.thread_id })); + setIsSending(false); + setActiveToolCall(null); + dispatch(setActiveThread(null)); + }, + onError: event => { + if (event.thread_id !== selectedThreadIdRef.current) return; + if (event.error_type !== 'cancelled') { + setSendError(event.message); + } + setIsSending(false); + setActiveToolCall(null); + dispatch(setActiveThread(null)); + + // Remove the optimistic user message on error + dispatch((innerDispatch, getState) => { + const state = getState() as { + thread: { messagesByThreadId: Record }; + }; + const persistedMessages = state.thread.messagesByThreadId[event.thread_id] || []; + const lastUserIdx = [...persistedMessages] + .reverse() + .findIndex(m => m.sender === 'user'); + if (lastUserIdx !== -1) { + const actualIdx = persistedMessages.length - 1 - lastUserIdx; + const updated = persistedMessages.filter((_, i) => i !== actualIdx); + innerDispatch(updateMessagesForThread({ threadId: event.thread_id, messages: updated })); + if (event.thread_id === selectedThreadIdRef.current) { + innerDispatch(setSelectedThread(event.thread_id)); + } + } + }); + }, + }).then(fn => { + if (mounted) cleanup = fn; + }); + + return () => { + mounted = false; + cleanup?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rustChat]); + const handleSelectThread = (threadId: string) => { if (threadId === selectedThreadId) return; navigate(`/conversations/${threadId}`, { replace: true }); @@ -321,49 +414,14 @@ const Conversations = () => { } }; - const handleSendMessage = async (text?: string) => { - const trimmed = text ?? inputValue.trim(); - if (!trimmed || !selectedThreadId || isSending) return; - - // Check if another thread is already sending - if (activeThreadId && activeThreadId !== selectedThreadId) { - return; // Block sending from non-active threads - } - - // Store the original thread ID to ensure response goes to correct thread - const sendingThreadId = selectedThreadId; - - // Create stable user message and persist immediately - const userMessage: ThreadMessage = { - id: `msg_${Date.now()}_${Math.random()}`, - content: trimmed, - type: 'text', - extraMetadata: {}, - sender: 'user', - createdAt: new Date().toISOString(), - }; - - // Immediately persist user message to both current view and persistent storage - dispatch(addMessageLocal({ threadId: sendingThreadId, message: userMessage })); - - // Update current view if this is the selected thread - if (sendingThreadId === selectedThreadId) { - // Message is already added to persistent storage, reload current view - dispatch(setSelectedThread(sendingThreadId)); - } - - // Snapshot history for AI request (excluding the just-added user message since we'll add it manually) - const historySnapshot = messages.filter( - m => !m.id.startsWith('optimistic-') && m.id !== userMessage.id - ); - - setInputValue(''); - setSendError(null); - setIsSending(true); - - // Set this thread as active - dispatch(setActiveThread(sendingThreadId)); - + // Web fallback: the original orchestration logic, preserved exactly as-is. + // Called when not running in Tauri. + const handleSendMessageWeb = async ( + sendingThreadId: string, + trimmed: string, + userMessage: ThreadMessage, + historySnapshot: ThreadMessage[] + ) => { // Safety-net timeout: force-clear loading states if everything hangs const OVERALL_TIMEOUT_MS = 240_000; const safetyTimeout = setTimeout(() => { @@ -387,7 +445,7 @@ const Conversations = () => { const { invoke } = await import('@tauri-apps/api/core'); const recalledContext = await invoke('recall_memory', { skillId: 'conversations', - integrationId: selectedThreadId, + integrationId: sendingThreadId, maxChunks: 10, }); if (recalledContext) { @@ -519,7 +577,10 @@ const Conversations = () => { skillManager.callTool(skillId, toolName, toolArgs), new Promise((_, reject) => setTimeout( - () => reject(new Error(`Tool "${toolName}" timed out after ${TOOL_TIMEOUT_MS / 1000}s`)), + () => + reject( + new Error(`Tool "${toolName}" timed out after ${TOOL_TIMEOUT_MS / 1000}s`) + ), TOOL_TIMEOUT_MS ) ), @@ -575,17 +636,19 @@ const Conversations = () => { } catch (err) { // Remove the user message from persistent storage on error // We'll use a thunk-like approach to access current state - dispatch((dispatch, getState) => { + dispatch((innerDispatch, getState) => { const state = getState() as { thread: { messagesByThreadId: Record }; }; const persistedMessages = state.thread.messagesByThreadId[sendingThreadId] || []; const currentMessages = persistedMessages.filter(m => m.id !== userMessage.id); - dispatch(updateMessagesForThread({ threadId: sendingThreadId, messages: currentMessages })); + innerDispatch( + updateMessagesForThread({ threadId: sendingThreadId, messages: currentMessages }) + ); // Also remove from current view if this is the selected thread if (sendingThreadId === selectedThreadId) { - dispatch(setSelectedThread(sendingThreadId)); + innerDispatch(setSelectedThread(sendingThreadId)); } }); @@ -602,6 +665,96 @@ const Conversations = () => { } }; + const handleSendMessage = async (text?: string) => { + const trimmed = text ?? inputValue.trim(); + if (!trimmed || !selectedThreadId || isSending) return; + + // Check if another thread is already sending + if (activeThreadId && activeThreadId !== selectedThreadId) { + return; // Block sending from non-active threads + } + + // Store the original thread ID to ensure response goes to correct thread + const sendingThreadId = selectedThreadId; + + // Create stable user message and persist immediately + const userMessage: ThreadMessage = { + id: `msg_${Date.now()}_${Math.random()}`, + content: trimmed, + type: 'text', + extraMetadata: {}, + sender: 'user', + createdAt: new Date().toISOString(), + }; + + // Immediately persist user message to both current view and persistent storage + dispatch(addMessageLocal({ threadId: sendingThreadId, message: userMessage })); + + // Update current view if this is the selected thread + if (sendingThreadId === selectedThreadId) { + // Message is already added to persistent storage, reload current view + dispatch(setSelectedThread(sendingThreadId)); + } + + // Snapshot history for AI request (excluding the just-added user message since we'll add it manually) + const historySnapshot = messages.filter( + m => !m.id.startsWith('optimistic-') && m.id !== userMessage.id + ); + + setInputValue(''); + setSendError(null); + setIsSending(true); + + // Set this thread as active + dispatch(setActiveThread(sendingThreadId)); + + if (rustChat) { + // ── Rust path ──────────────────────────────────────────────────────── + try { + const chatMessages = historySnapshot.map(m => ({ + role: m.sender === 'user' ? 'user' : 'assistant', + content: m.content, + })); + + const notionCtx = buildNotionContext( + notionProfile, + notionPages, + notionSummaries, + notionWorkspaceName + ); + + const authToken = (store.getState() as { auth: { token: string | null } }).auth.token; + if (!authToken) { + setSendError('Not authenticated'); + setIsSending(false); + dispatch(setActiveThread(null)); + return; + } + + await chatSend({ + threadId: sendingThreadId, + message: trimmed, + model: selectedModel, + authToken, + backendUrl: BACKEND_URL, + messages: chatMessages, + notionContext: notionCtx, + }); + + // setIsSending(false) and setActiveThread(null) happen in the onDone/onError event handlers + } catch (err) { + // invoke() itself failed (the chat loop reports errors via events) + const msg = err instanceof Error ? err.message : String(err); + setSendError(msg); + setIsSending(false); + dispatch(setActiveThread(null)); + } + } else { + // ── Web fallback (existing orchestration logic) ─────────────────────── + await handleSendMessageWeb(sendingThreadId, trimmed, userMessage, historySnapshot); + } + }; + const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -1010,6 +1163,24 @@ const Conversations = () => { )} + {/* Tool call indicator — shown when Rust backend is executing a tool */} + {activeToolCall && isSending && ( +
+ Running tool: {activeToolCall.name} +
+ )} + {/* Cancel button — shown when Rust backend is processing */} + {isSending && rustChat && ( +
+ +
+ )}
) : ( diff --git a/src/services/chatService.ts b/src/services/chatService.ts new file mode 100644 index 000000000..2e6c09e53 --- /dev/null +++ b/src/services/chatService.ts @@ -0,0 +1,130 @@ +/** + * Chat Service — sends messages via Rust backend (Tauri) or falls back to + * frontend-driven orchestration (web mode). + */ +import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +// ─── Event payload types (must match Rust structs exactly — snake_case) ─────── + +export interface ChatToolCallEvent { + thread_id: string; + tool_name: string; + skill_id: string; + args: Record; + round: number; +} + +export interface ChatToolResultEvent { + thread_id: string; + tool_name: string; + skill_id: string; + output: string; + success: boolean; + round: number; +} + +export interface ChatDoneEvent { + thread_id: string; + full_response: string; + rounds_used: number; + total_input_tokens: number; + total_output_tokens: number; +} + +export interface ChatErrorEvent { + thread_id: string; + message: string; + error_type: 'network' | 'timeout' | 'tool_error' | 'inference' | 'cancelled'; + round: number | null; +} + +// ─── Listener setup ─────────────────────────────────────────────────────────── + +export interface ChatEventListeners { + onToolCall?: (event: ChatToolCallEvent) => void; + onToolResult?: (event: ChatToolResultEvent) => void; + onDone?: (event: ChatDoneEvent) => void; + onError?: (event: ChatErrorEvent) => void; +} + +/** + * Subscribe to chat events from the Rust backend. + * Returns a cleanup function that removes all listeners. + * Only works in Tauri mode. + */ +export async function subscribeChatEvents(listeners: ChatEventListeners): Promise<() => void> { + const unlisteners: UnlistenFn[] = []; + + if (listeners.onToolCall) { + const cb = listeners.onToolCall; + unlisteners.push(await listen('chat:tool_call', e => cb(e.payload))); + } + if (listeners.onToolResult) { + const cb = listeners.onToolResult; + unlisteners.push(await listen('chat:tool_result', e => cb(e.payload))); + } + if (listeners.onDone) { + const cb = listeners.onDone; + unlisteners.push(await listen('chat:done', e => cb(e.payload))); + } + if (listeners.onError) { + const cb = listeners.onError; + unlisteners.push(await listen('chat:error', e => cb(e.payload))); + } + + return () => { + for (const unlisten of unlisteners) { + unlisten(); + } + }; +} + +// ─── Send message ───────────────────────────────────────────────────────────── + +export interface ChatSendParams { + threadId: string; + message: string; + model: string; + authToken: string; + backendUrl: string; + messages: Array<{ + role: string; + content: string; + tool_calls?: unknown[]; + tool_call_id?: string; + }>; + notionContext?: string | null; +} + +/** + * Send a message via the Rust chat_send command. + * Returns immediately — results arrive via events. + * Tauri v2 converts camelCase param names to snake_case for the Rust command. + */ +export async function chatSend(params: ChatSendParams): Promise { + await invoke('chat_send', { + threadId: params.threadId, + message: params.message, + model: params.model, + authToken: params.authToken, + backendUrl: params.backendUrl, + messages: params.messages, + notionContext: params.notionContext ?? null, + }); +} + +/** + * Cancel an in-flight chat request. + */ +export async function chatCancel(threadId: string): Promise { + return await invoke('chat_cancel', { threadId }); +} + +/** + * Check if we should use the Rust backend for chat. + * Returns true when running in Tauri on desktop. + */ +export function useRustChat(): boolean { + return coreIsTauri(); +} diff --git a/yarn.lock b/yarn.lock index 8230fdb87..3f777e24f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -319,6 +319,11 @@ resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz" integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== +"@dimforge/rapier3d-compat@~0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389" + integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== + "@esbuild/aix-ppc64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" @@ -1456,6 +1461,11 @@ minimatch "^9.0.0" parse-imports-exports "^0.2.4" +"@tweenjs/tween.js@~23.1.3": + version "23.1.3" + resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d" + integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz" @@ -1656,11 +1666,29 @@ resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/stats.js@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e" + integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA== + "@types/statuses@^2.0.6": version "2.0.6" resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz" integrity sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA== +"@types/three@^0.183.1": + version "0.183.1" + resolved "https://registry.yarnpkg.com/@types/three/-/three-0.183.1.tgz#d812d028b38ad68843725e3e7bd3268607cef150" + integrity sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw== + dependencies: + "@dimforge/rapier3d-compat" "~0.12.0" + "@tweenjs/tween.js" "~23.1.3" + "@types/stats.js" "*" + "@types/webxr" ">=0.5.17" + "@webgpu/types" "*" + fflate "~0.8.2" + meshoptimizer "~1.0.1" + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz" @@ -1676,6 +1704,11 @@ resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz" integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== +"@types/webxr@>=0.5.17": + version "0.5.24" + resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44" + integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg== + "@types/which@^2.0.1": version "2.0.2" resolved "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz" @@ -2104,6 +2137,11 @@ dependencies: "@wdio/logger" "9.18.0" +"@webgpu/types@*": + version "0.1.69" + resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.69.tgz#6b849bf370a1f29c78bd3aeba8e84c1150b237f2" + integrity sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ== + "@xmldom/xmldom@^0.9.5": version "0.9.8" resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz" @@ -4122,6 +4160,11 @@ fdir@^6.5.0: resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +fflate@~0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" + integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A== + figures@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz" @@ -5589,6 +5632,11 @@ merge2@^1.3.0: resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +meshoptimizer@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.0.1.tgz#c3ef0d509a8b84ac562493dba5a108fd67fa76dc" + integrity sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g== + micromark-core-commonmark@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz" @@ -7395,7 +7443,16 @@ strict-event-emitter@^0.5.1: resolved "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz" integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7494,8 +7551,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8345,8 +8408,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8364,6 +8426,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"