diff --git a/.claude/rules/17-skills-memory-inference-flow.md b/.claude/rules/17-skills-memory-inference-flow.md new file mode 100644 index 000000000..984017d4f --- /dev/null +++ b/.claude/rules/17-skills-memory-inference-flow.md @@ -0,0 +1,275 @@ +# Skills → Memory Layer → Agent Inference: Full Flow + +## Overview + +This document traces the complete data flow from skill discovery and OAuth/sync events, through +the TinyHumans Neocortex memory layer (tinyhumansai SDK), and into the Rust-side agentic +inference loop — showing exactly how skill data is written to and read from memory and how it +reaches the LLM context at inference time. + +--- + +## 1. Skill Discovery & Lifecycle (SkillProvider.tsx) + +**File**: `src/providers/SkillProvider.tsx` + +On app mount (when a JWT token is present) `SkillProvider` calls +`invoke('runtime_discover_skills')` → the Rust runtime scans +`skills/skills/{skill-id}/manifest.json` from the git submodule and returns a manifest list. + +``` +Token present + → discoverSkills() + → invoke('runtime_discover_skills') // Rust: qjs_engine.rs:184 + → reads skills/skills/*/manifest.json + → filters: is_javascript() && supports_current_platform() + → returns manifests[] + → skillManager.registerSkill(manifest) // in-memory registry + → for each manifest with setupComplete: + skillManager.startSkill(manifest) // starts V8/QuickJS instance +``` + +Two Tauri event listeners run continuously: + +| Event | What it does | +|---|---| +| `skill-state-changed` | Dispatches `setSkillState` into Redux `skillsSlice.skillStates[skillId]` | +| `runtime:skill-status-changed` | Updates `skillsSlice.skills[skillId].status`; surfaces errors | + +--- + +## 2. Memory Client Initialisation + +**File**: `src-tauri/src/memory/mod.rs` +**Tauri command**: `init_memory_client` (called by frontend after auth) + +The `MemoryClient` wraps the `TinyHumansMemoryClient` from the `tinyhumansai` Rust crate. +It is constructed with the user's JWT (`authSlice.token`) and stored as `Arc` +in `MemoryState` (a `Mutex>`). + +Base URL resolution (in priority order): +1. `ALPHAHUMAN_BASE_URL` env var +2. `TINYHUMANS_BASE_URL` env var +3. SDK default + +--- + +## 3. Skill → Memory Sync: Two Write Paths + +Both trigger inside `src-tauri/src/runtime/qjs_skill_instance.rs`. + +### 3a. OAuth Completion (`skill/oauth-complete`) + +When a user connects a skill via OAuth, the JS runtime calls back with `skill/oauth-complete`. +After the OAuth flow completes the skill's **ops state** (data published via `state.set()`) is +snapshotted and stored to memory: + +``` +skill/oauth-complete handler + → handle_js_call(rt, ctx, "onOAuthComplete", params) // runs skill JS + → ops_state.read().data.clone() // snapshot skill state + → tokio::spawn (fire-and-forget): + MemoryClient::store_skill_sync( + skill_id = e.g. "gmail" + integration_id = params["integrationId"] // e.g. user email + title = "{skill} OAuth sync — {integrationId}" + content = JSON snapshot of ops state + namespace = "skill:{skill_id}:{integration_id}" + ) + → tinyhumansai::TinyHumansMemoryClient::insert_memory(InsertMemoryParams { ... }) + → HTTP POST to TinyHumans Neocortex API +``` + +### 3b. Periodic Sync (`skill/sync`) + +The runtime fires `skill/sync` events on a cron schedule. The flow is identical to OAuth +completion but uses `integration_id = "default"` and title `"{skill} periodic sync"`: + +``` +skill/sync handler + → handle_js_call(rt, ctx, "onSync", "{}") + → ops_state.read().data.clone() + → tokio::spawn: + MemoryClient::store_skill_sync( + skill_id = e.g. "notion" + integration_id = "default" + namespace = "skill:notion:default" + content = JSON snapshot + ) +``` + +### Namespace Pattern + +All skill memories are stored under `skill:{skill_id}:{integration_id}`. +Examples: `skill:gmail:user@example.com`, `skill:notion:default`. + +--- + +## 4. Memory Operations Reference + +**File**: `src-tauri/src/memory/mod.rs` + +| Method | SDK call | When used | +|---|---|---| +| `store_skill_sync(...)` | `insert_memory` | OAuth complete, periodic sync | +| `query_skill_context(...)` | `query_memory` | RAG query — fetch relevant chunks for a user question | +| `recall_skill_context(...)` | `recall_memory` | Recall synthesised summary from Master node | +| `clear_skill_memory(...)` | `delete_memory` | OAuth revoke / disconnect | + +--- + +## 5. Conversation → Inference: Two Code Paths + +**File**: `src/pages/Conversations.tsx` + +`useRustChat()` returns `true` when running in Tauri (desktop). This selects between two paths: + +``` +handleSendMessage(text) + ├─ rustChat == true → Rust path (invoke chat_send) + └─ rustChat == false → Web path (handleSendMessageWeb — TypeScript loop) +``` + +### 5a. Rust Path (Desktop) + +``` +chatSend({ threadId, message, model, authToken, backendUrl, messages, notionContext }) + → invoke('chat_send') // src-tauri/src/commands/chat.rs:359 + → spawns background task + → chat_send_inner(...) +``` + +Completion events flow back over Tauri events: + +| Event | Frontend handler | +|---|---| +| `chat:tool_call` | shows active tool indicator | +| `chat:tool_result` | clears tool indicator | +| `chat:done` | `dispatch(addInferenceResponse(...))` | +| `chat:error` | shows error, clears loading state | + +### 5b. Web/Fallback Path + +Used when not running in Tauri (browser). Runs the agentic loop entirely in TypeScript: +- Calls `inferenceApi.createChatCompletion(request)` directly +- Executes tools via `skillManager.callTool(skillId, toolName, args)` +- Both paths share the same 5-round `MAX_TOOL_ROUNDS` limit and `{skillId}__{toolName}` naming convention + +--- + +## 6. Rust Agentic Loop in Detail + +**File**: `src-tauri/src/commands/chat.rs` — `chat_send_inner()` + +``` +Step 1: Load OpenClaw context + → load_openclaw_context(app) + → reads ai/SOUL.md, IDENTITY.md, AGENTS.md, USER.md, BOOTSTRAP.md, MEMORY.md, TOOLS.md + → cached in static AI_CONFIG_CACHE (cleared on restart) + → truncated to MAX_CONTEXT_CHARS (20,000 chars) + +Step 2: Recall memory context + → MemoryClient::recall_skill_context("conversations", thread_id, 10) + → tinyhumansai recall_memory(namespace="skill:conversations:{thread_id}") + → returns synthesised summary string or None + +Step 3: Build processed user message + processed = user_message + if openclaw_context → prepend as "## Project Context\n...\n\nUser message: {processed}" + if memory_context → prepend as "[MEMORY_CONTEXT]\n{mem}\n[/MEMORY_CONTEXT]\n\n{processed}" + if notion_context → prepend as "{notionContext}\n\n{processed}" + +Step 4: Build messages array + → history (ChatMessagePayload[]) + processed user message + +Step 5: Discover tools + → engine.all_tools() + → returns all tools from running skills + → namespaced: "{skill_id}__{tool_name}" + → formatted as OpenAI function-calling schema + +Step 6: Agentic loop (max 5 rounds) + for round in 0..MAX_TOOL_ROUNDS: + POST {backend_url}/openai/v1/chat/completions + body: { model, messages, tools, tool_choice: "auto" } + timeout: 120s + auth: Bearer {auth_token} + + if finish_reason == "tool_calls": + emit chat:tool_call + engine.call_tool(skill_id, tool_name, args) // 60s timeout + → QuickJS/V8 runtime executes skill JS tool handler + emit chat:tool_result + append tool result to messages + continue loop + + else (finish_reason == "stop"): + emit chat:done { full_response, rounds_used, token_counts } + return Ok(()) +``` + +--- + +## 7. Tool Execution: Rust → Skill JS + +**File**: `src-tauri/src/runtime/qjs_engine.rs` — `call_tool(skill_id, tool_name, args)` + +The Rust runtime routes `call_tool` into the running QuickJS/V8 skill instance: +- Serialises `args` as JSON +- Calls the JS tool handler registered by the skill +- Returns `ToolCallResult { content: Vec, is_error: bool }` +- Text content is extracted and appended as the `tool` role message in the loop + +--- + +## 8. End-to-End Flow Summary + +``` +User types message (Conversations.tsx) + │ + ├─ [Web path only] invoke('recall_memory') → TinyHumans API (recall) + │ returns synthesised context + ├─ buildNotionContext() → from Redux skillStates.notion + │ + ▼ +invoke('chat_send') [Rust/desktop path] + │ + ▼ +Rust: chat_send_inner() + ├─ load_openclaw_context() → ai/*.md files (cached) + ├─ recall_skill_context("conversations", tid) → TinyHumans API (recall) + ├─ Build prompt: [OpenClaw] + [MEMORY] + [Notion] + user_message + ├─ discover_tools() → all running skill tools + │ + └─ Agentic loop (≤5 rounds): + POST /openai/v1/chat/completions → Backend LLM (neocortex-mk1) + │ + ├─ tool_calls → engine.call_tool() → QuickJS/V8 skill instance + │ └─ JS tool handler + │ └─ returns result string + │ └─ appended as tool message + │ + └─ stop → emit chat:done + └─ Frontend: dispatch(addInferenceResponse(...)) + +Separately (async, fire-and-forget): + Skill onSync() / onOAuthComplete() + → MemoryClient::store_skill_sync() + → TinyHumans insert_memory (namespace: skill:{id}:{integrationId}) +``` + +--- + +## Key Files Reference + +| File | Role | +|---|---| +| `src/providers/SkillProvider.tsx` | Discovery, lifecycle, Redux state sync | +| `src/pages/Conversations.tsx` | Message send, both code paths, Notion context | +| `src/services/chatService.ts` | `chatSend()`, `chatCancel()`, `useRustChat()` | +| `src-tauri/src/commands/chat.rs` | Rust agentic loop, context assembly, tool dispatch | +| `src-tauri/src/commands/memory.rs` | `recall_memory` Tauri command | +| `src-tauri/src/memory/mod.rs` | `MemoryClient` wrapping tinyhumansai SDK | +| `src-tauri/src/runtime/qjs_skill_instance.rs` | Skill sync → memory write triggers | +| `src-tauri/src/runtime/qjs_engine.rs` | `discover_skills()`, `call_tool()`, `all_tools()` | +| `skills/skills/*/manifest.json` | Skill metadata (git submodule) | diff --git a/package.json b/package.json index b9760019d..aac5eab27 100644 --- a/package.json +++ b/package.json @@ -47,12 +47,13 @@ "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", - "@tauri-apps/api": "2.10.1", + "@tauri-apps/api": "^2.10.0", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-opener": "^2", "@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", @@ -69,6 +70,7 @@ "redux-persist": "^6.0.0", "socket.io-client": "^4.8.3", "telegram": "^2.26.22", + "three": "^0.183.2", "util": "^0.12.5", "zustand": "^5.0.10" }, @@ -76,7 +78,7 @@ "@eslint/js": "^9.39.2", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "2.9.6", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -112,5 +114,6 @@ "vite": "^7.0.4", "vite-plugin-node-polyfills": "^0.25.0", "vitest": "^4.0.18" - } + }, + "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a" } diff --git a/skills b/skills index ff2534fc4..14217d3ae 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit ff2534fc4dae2589f9c0d5de892e0c3c30d3d2bf +Subproject commit 14217d3aee6b92f7cb6d4c891095de8947ba1397 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5285c07da..b53176ff3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8457,10 +8457,11 @@ dependencies = [ [[package]] name = "tinyhumansai" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7a88dba7fe194a507d0351c5f8b5cd8518fb30f8307dd788333f9715f51c44e" +checksum = "691a096ecaaeec23ac7bbcfb276058de9d4aff1c574ee074125d5e5080e17b7d" dependencies = [ + "log", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a3755e7c8..790ef4e72 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -140,7 +140,7 @@ opentelemetry_sdk = { version = "0.31", default-features = false, features = ["t opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } tokio-stream = { version = "0.1.18", features = ["full"] } url = "2" -tinyhumansai = "0.1.4" +tinyhumansai = "0.1.5" # Optional integrations matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 6f2942191..62efffafd 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -2,46 +2,47 @@ use std::env; use std::path::PathBuf; fn main() { - maybe_override_tauri_config_for_tests(); + maybe_override_tauri_config_for_local_builds(); tauri_build::build(); } -fn maybe_override_tauri_config_for_tests() { +fn maybe_override_tauri_config_for_local_builds() { let profile = env::var("PROFILE").unwrap_or_default(); let skip_resources = env::var("TAURI_SKIP_RESOURCES").is_ok() || profile == "test"; - if !skip_resources { - return; - } - + let is_release = profile == "release"; let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set")); - let config_path = manifest_dir.join("tauri.conf.json"); - let Ok(raw) = std::fs::read_to_string(&config_path) else { - println!("cargo:warning=Failed to read tauri.conf.json; keeping default config"); + let tdlib_framework_path = manifest_dir.join("libraries/libtdjson.1.8.29.dylib"); + let skip_missing_frameworks = !is_release && !tdlib_framework_path.exists(); + + if !skip_resources && !skip_missing_frameworks { return; - }; - - let mut value: serde_json::Value = match serde_json::from_str(&raw) { - Ok(value) => value, - Err(err) => { - println!("cargo:warning=Failed to parse tauri.conf.json: {err}"); - return; - } - }; - - if let Some(bundle) = value.get_mut("bundle").and_then(|b| b.as_object_mut()) { - bundle.insert("resources".to_string(), serde_json::Value::Array(Vec::new())); } - let out_dir = env::var("OUT_DIR").unwrap_or_else(|_| ".".into()); - let override_path = PathBuf::from(out_dir).join("tauri.conf.test.json"); - if std::fs::write(&override_path, serde_json::to_string_pretty(&value).unwrap_or(raw)).is_ok() - { - env::set_var("TAURI_CONFIG", &override_path); - println!( - "cargo:warning=TAURI resources disabled for test build (using {})", - override_path.display() - ); + let mut merge_config = serde_json::json!({}); + if skip_resources { + merge_config["bundle"]["resources"] = serde_json::json!([]); + } + if skip_missing_frameworks { + merge_config["bundle"]["macOS"]["frameworks"] = serde_json::json!([]); + } + + match serde_json::to_string(&merge_config) { + Ok(json) => { + env::set_var("TAURI_CONFIG", json); + if skip_resources { + println!("cargo:warning=TAURI resources disabled for local build"); + } + if skip_missing_frameworks { + println!( + "cargo:warning=TAURI macOS frameworks disabled because {} is missing", + tdlib_framework_path.display() + ); + } + } + Err(err) => { + println!("cargo:warning=Failed to serialize TAURI_CONFIG override: {err}"); + } } } diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs new file mode 100644 index 000000000..728d469dd --- /dev/null +++ b/src-tauri/src/commands/chat.rs @@ -0,0 +1,1155 @@ +//! 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 ─────────────────────────────────── + log::info!("[chat] Recalling conversation memory (thread_id={thread_id})"); + let memory_context: Option = if let Some(ref mem) = memory_client { + match mem + .recall_skill_context("conversations", thread_id, 10) + .await + { + Ok(ctx) => { + log::info!( + "[chat] Conversation memory recall: has_data={}, len={}", + ctx.is_some(), + ctx.as_deref().map(|s| s.len()).unwrap_or(0) + ); + if let Some(ref data) = ctx { + log::debug!("[chat] Conversation memory content:\n{}", data); + } + ctx + } + Err(e) => { + log::warn!("[chat] Conversation memory recall failed: {}", e); + None + } + } + } else { + log::info!("[chat] No memory client — skipping conversation memory recall"); + None + }; + + // ── Step 2b: Recall skill contexts ────────────────────────────────── + let skill_ids: std::collections::HashSet = engine + .all_tools() + .into_iter() + .map(|(skill_id, _)| skill_id) + .collect(); + + log::info!("[chat] Recalling skill contexts for {} skill(s): {:?}", skill_ids.len(), skill_ids); + + let mut skill_contexts: Vec = Vec::new(); + for sid in &skill_ids { + if let Some(ref mem) = memory_client { + log::info!("[chat] Recalling memory for skill={sid}"); + match mem.recall_skill_context(sid, sid, 10).await { + Ok(Some(ctx)) => { + log::info!( + "[chat] Skill memory recall ok: skill={sid}, len={}", + ctx.len() + ); + log::debug!("[chat] Skill memory content (skill={sid}):\n{}", ctx); + skill_contexts.push(format!( + "[{}_CONTEXT]\n{}\n[/{}_CONTEXT]", + sid.to_uppercase(), + ctx, + sid.to_uppercase() + )); + } + Ok(None) => { + log::info!("[chat] Skill memory recall: no data for skill={sid}"); + } + Err(e) => { + log::warn!("[chat] Skill memory recall failed for skill={sid}: {}", e); + } + } + } + } + + log::info!( + "[chat] Context assembly: conversation_memory={}, skill_contexts={}", + memory_context.is_some(), + skill_contexts.len() + ); + + // ── 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 !skill_contexts.is_empty() { + processed = format!("{}\n\n{}", skill_contexts.join("\n\n"), 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/src/memory/mod.rs b/src-tauri/src/memory/mod.rs index 106588100..f07385d9e 100644 --- a/src-tauri/src/memory/mod.rs +++ b/src-tauri/src/memory/mod.rs @@ -7,14 +7,15 @@ use std::sync::Arc; use tinyhumansai::{ DeleteMemoryParams, InsertMemoryParams, Priority, QueryMemoryParams, RecallMemoryParams, - SourceType, TinyHumanConfig, TinyHumanMemoryClient, + SourceType, TinyHumanConfig, TinyHumansMemoryClient, }; +use uuid::Uuid; /// Shared, cloneable handle to the memory client. pub type MemoryClientRef = Arc; pub struct MemoryClient { - inner: TinyHumanMemoryClient, + inner: TinyHumansMemoryClient, } impl MemoryClient { @@ -38,7 +39,7 @@ impl MemoryClient { TinyHumanConfig::new(jwt_token) } }; - match TinyHumanMemoryClient::new(config) { + match TinyHumansMemoryClient::new(config) { Ok(inner) => { log::info!("[memory] from_token: exit — client created successfully"); Some(Self { inner }) @@ -52,7 +53,9 @@ impl MemoryClient { /// Store a skill data-sync result. /// - /// Namespace pattern: `skill:{skill_id}:{integration_id}` + /// Inserts the document then polls `ingestion_job_status` every 30 s until + /// the job reaches `completed` (or `failed`/`error`). Returns only after the + /// ingestion job is confirmed complete. pub async fn store_skill_sync( &self, skill_id: &str, @@ -66,15 +69,23 @@ impl MemoryClient { updated_at: Option, document_id: Option, ) -> Result<(), String> { - let namespace = format!("skill:{skill_id}:{integration_id}"); - log::info!("[memory] store_skill_sync: entry (namespace={namespace}, title={title:?}, content_len={})", content.len()); - log::debug!( - "[memory] store_skill_sync: payload → namespace={namespace} | title={title} | content={}", - content + let namespace = skill_id.to_string(); + log::info!( + "[memory] store_skill_sync: entry (namespace={namespace}, title={title:?}, content_len={})", + content.len() ); - log::info!("[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?})"); - let result = self.inner + + let document_id_final = document_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + + log::info!( + "[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?}), content_len={}", + content.len() + ); + + let insert_resp = self + .inner .insert_memory(InsertMemoryParams { + document_id: Some(document_id_final), title: title.to_string(), content: content.to_string(), namespace: namespace.clone(), @@ -83,33 +94,97 @@ impl MemoryClient { priority, created_at, updated_at, - document_id, ..Default::default() }) .await - .map(|_| { - log::info!("[memory] insert_memory: success (namespace={namespace}, title={title:?})"); - }) .map_err(|e| { - log::warn!("[memory] insert_memory: SDK error — kind={:?} msg={e}", classify_insert_error(&e)); + log::warn!( + "[memory] insert_memory: SDK error — kind={:?} msg={e}", + classify_insert_error(&e) + ); format!("Memory insert failed: {e}") - }); - match &result { - Ok(()) => log::info!("[memory] store_skill_sync: exit — ok (namespace={namespace})"), - Err(e) => log::warn!("[memory] store_skill_sync: exit — error (namespace={namespace}): {e}"), + })?; + + log::info!( + "[memory] insert_memory: accepted (namespace={namespace}, status={:?}, job_id={:?})", + insert_resp.data.status, + insert_resp.data.job_id + ); + + // If the API returned a job_id, poll until the job completes. + if let Some(job_id) = insert_resp.data.job_id { + log::info!("[memory] ingestion job queued (job_id={job_id}), polling every 30s..."); + + loop { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + + match self.inner.ingestion_job_status(&job_id).await { + Ok(status_resp) => { + let state = status_resp + .data + .state + .as_deref() + .unwrap_or("unknown"); + + log::info!( + "[memory] ingestion job status: job_id={job_id}, state={state}, \ + attempts={:?}, completed_at={:?}", + status_resp.data.attempts, + status_resp.data.completed_at + ); + + match state { + "completed" => { + log::info!( + "[memory] ingestion job completed (job_id={job_id}, namespace={namespace})" + ); + break; + } + "failed" | "error" => { + let err_msg = status_resp + .data + .error + .unwrap_or_else(|| format!("job state={state}")); + log::warn!( + "[memory] ingestion job failed: job_id={job_id}, error={err_msg}" + ); + log::warn!( + "[memory] store_skill_sync: exit — ingestion failed (namespace={namespace})" + ); + return Err(format!("Ingestion job failed: {err_msg}")); + } + _ => { + // pending / processing / queued — keep waiting + log::info!( + "[memory] ingestion job still in progress (state={state}), waiting 30s..." + ); + } + } + } + Err(e) => { + log::warn!( + "[memory] ingestion job status poll error (job_id={job_id}): {e} — retrying in 30s" + ); + } + } + } + } else { + log::info!("[memory] no job_id returned — insert assumed synchronous, proceeding"); } - result + + log::info!("[memory] store_skill_sync: exit — ok (namespace={namespace})"); + Ok(()) } /// Query relevant context for a skill integration (RAG). pub async fn query_skill_context( &self, skill_id: &str, - integration_id: &str, + _integration_id: &str, query: &str, max_chunks: u32, ) -> Result { - let namespace = format!("skill:{skill_id}:{integration_id}"); + let namespace = skill_id.to_string(); log::info!("[memory] query_skill_context: entry (namespace={namespace}, max_chunks={max_chunks}, query={query:?})"); log::debug!( "[memory] query_skill_context: payload → namespace={namespace} | max_chunks={max_chunks} | query={query}" @@ -137,10 +212,10 @@ impl MemoryClient { pub async fn recall_skill_context( &self, skill_id: &str, - integration_id: &str, + _integration_id: &str, max_chunks: u32, ) -> Result, String> { - let namespace = format!("skill:{skill_id}:{integration_id}"); + let namespace = skill_id.to_string(); log::info!( "[memory] recall_skill_context: entry (namespace={namespace}, max_chunks={max_chunks})" ); @@ -187,7 +262,7 @@ impl MemoryClient { } } -fn classify_insert_error(e: &tinyhumansai::TinyHumanError) -> &'static str { +fn classify_insert_error(e: &tinyhumansai::TinyHumansError) -> &'static str { let msg = e.to_string(); if msg.contains("dns") || msg.contains("resolve") || msg.contains("lookup") { "dns_failure" @@ -253,6 +328,7 @@ mod tests { None, None, None, + None, ) .await; diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index ae71c264d..257a913fd 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -583,50 +583,7 @@ async fn handle_message( }).await; log::info!("[skill:{}] OAuth credential set and persisted to store", skill_id); let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); - let result = handle_js_call(rt, ctx, "onOAuthComplete", ¶ms_str).await; - - // Fire-and-forget: persist published ops state to TinyHumans memory. - // Skills publish data via state.set()/setPartial() into ops_state.data, - // not as the return value of onOAuthComplete() (which is typically undefined). - let state_snapshot = ops_state.read().data.clone(); - if !state_snapshot.is_empty() { - if let Some(client) = memory_client_opt.clone() { - let skill = skill_id.to_string(); - let integration_id = params - .get("integrationId") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - let content = serde_json::to_string_pretty( - &serde_json::Value::Object(state_snapshot), - ) - .unwrap_or_else(|_| "{}".to_string()); - let title = format!("{} OAuth sync — {}", skill, integration_id); - tokio::spawn(async move { - if let Err(e) = client - .store_skill_sync( - &skill, - &integration_id, - &title, - &content, - None, - None, - None, - None, - None, - None, - ) - .await - { - log::warn!("[memory] store_skill_sync failed: {e}"); - } else { - log::info!("[memory] Stored sync for {}:{}", skill, integration_id); - } - }); - } - } - - result + handle_js_call(rt, ctx, "onOAuthComplete", ¶ms_str).await } "skill/ping" => { handle_js_call(rt, ctx, "onPing", "{}").await 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/AppRoutes.tsx b/src/AppRoutes.tsx index 4f475e9bf..fe41326bc 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -8,11 +8,11 @@ import Conversations from './pages/Conversations'; import Home from './pages/Home'; import Intelligence from './pages/Intelligence'; import Invites from './pages/Invites'; -import Skills from './pages/Skills'; import Login from './pages/Login'; import Mnemonic from './pages/Mnemonic'; import Onboarding from './pages/onboarding/Onboarding'; import Settings from './pages/Settings'; +import Skills from './pages/Skills'; import Welcome from './pages/Welcome'; import { selectHasEncryptionKey, selectIsOnboarded } from './store/authSelectors'; import { useAppSelector } from './store/hooks'; 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/pages/Login.tsx b/src/pages/Login.tsx index 5a44ec23a..566df89a0 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,69 +1,39 @@ -import { useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import DownloadScreen from '../components/DownloadScreen'; +import OAuthLoginSection from '../components/oauth/OAuthLoginSection'; +import TypewriterGreeting from '../components/TypewriterGreeting'; -import { consumeLoginToken } from '../services/api/authApi'; -import { setToken } from '../store/authSlice'; -import { useAppDispatch } from '../store/hooks'; -import { syncMemoryClientToken } from '../utils/tauriCommands'; +interface LoginProps { + isWeb?: boolean; +} -const Login = () => { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const dispatch = useAppDispatch(); - const [consumeError, setConsumeError] = useState(null); - - // Handle login token from URL (e.g. from Telegram bot or OAuth provider callback) - // Consume the token with the backend and store the returned JWT - useEffect(() => { - const loginToken = searchParams.get('token'); - if (!loginToken) return; - - let cancelled = false; - - (async () => { - setConsumeError(null); - try { - const jwtToken = await consumeLoginToken(loginToken); - if (cancelled) return; - - dispatch(setToken(jwtToken)); - console.info('[memory] Login: dispatching syncMemoryClientToken after setToken'); - await syncMemoryClientToken(jwtToken); - navigate('/onboarding/', { replace: true }); - } catch (err) { - if (!cancelled) { - setConsumeError(err instanceof Error ? err.message : 'Login failed'); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [searchParams, dispatch, navigate]); - - if (consumeError) { - return ( -
-
-
-

{consumeError}

-

- Please try logging in again with your preferred method. -

-
-
-
- ); - } +const Login = ({ isWeb }: LoginProps) => { + const greetings = ['Hello HAL9000! 👋', "Let's cook! 🔥", 'The A-Team is here! 👊']; return (
-
-
-
-

Completing login...

+ {/* Main content */} +
+ {/* Welcome card */} +
+ {/* Greeting */} + + +

+ Welcome to AlphaHuman. Your Telegram assistant here to get you 10x more done in your + journey. +

+ +

Are you ready for this?

+ + {/* Show OAuth login options in Tauri app, download screen on web */} + {!isWeb && ( +
+ +
+ )}
+ + {isWeb && }
); diff --git a/src/pages/Skills.tsx b/src/pages/Skills.tsx index ae8da3a73..f6f14f9df 100644 --- a/src/pages/Skills.tsx +++ b/src/pages/Skills.tsx @@ -14,9 +14,9 @@ import SkillSetupModal from '../components/skills/SkillSetupModal'; import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks'; import { skillManager } from '../lib/skills/manager'; import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types'; -import { deriveSkillSyncUiState } from './skillsSyncUi'; import { useAppSelector } from '../store/hooks'; import { IS_DEV } from '../utils/config'; +import { deriveSkillSyncUiState } from './skillsSyncUi'; /** Format large numbers: 1200 → "1.2K", 1200000 → "1.2M" */ function formatNumber(n: number): string { @@ -53,7 +53,10 @@ function SkillCard({ skill, onSetup }: SkillCardProps) { | (SkillHostConnectionState & Record) | undefined; const [manualSyncing, setManualSyncing] = useState(false); - const syncUi = useMemo(() => deriveSkillSyncUiState(skill.id, skillState), [skill.id, skillState]); + const syncUi = useMemo( + () => deriveSkillSyncUiState(skill.id, skillState), + [skill.id, skillState] + ); const isSyncing = manualSyncing || syncUi.isSyncing; const handleSync = async (e: React.MouseEvent) => { @@ -212,10 +215,11 @@ export default function Skills() { } const manifests = await invoke>>('runtime_discover_skills'); + const ALLOWED_SKILLS = new Set(['gmail', 'notion']); const validManifests = manifests.filter(m => { const id = m.id as string; if (id.includes('_')) return false; - return true; + return ALLOWED_SKILLS.has(id); }); const processed: SkillListEntry[] = validManifests @@ -300,11 +304,7 @@ export default function Skills() { ) : (
{sortedSkillsList.map(skill => ( - openSkillSetup(skill)} - /> + openSkillSetup(skill)} /> ))}
)} @@ -326,7 +326,6 @@ export default function Skills() { }} /> )} -
); } 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 7dc660019..b73180694 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" @@ -1294,7 +1299,7 @@ dependencies: postcss-selector-parser "6.0.10" -"@tauri-apps/api@2.10.1": +"@tauri-apps/api@^2.10.0": version "2.10.1" resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.10.1.tgz#57c1bae6114ec33d977eb2b50dfefc25fa84fc93" integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw== @@ -1359,9 +1364,9 @@ resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz#d58c9f8af835b7e4fc30e201e979342c70bea426" integrity sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw== -"@tauri-apps/cli@^2": +"@tauri-apps/cli@2.9.6": version "2.9.6" - resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.9.6.tgz#f15ae8e03bf48308055c15ab25b439bed9906bc9" integrity sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw== optionalDependencies: "@tauri-apps/cli-darwin-arm64" "2.9.6" @@ -1461,6 +1466,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" @@ -1661,11 +1671,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" @@ -1681,6 +1709,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" @@ -2109,6 +2142,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" @@ -4127,6 +4165,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" @@ -5594,6 +5637,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" @@ -7500,7 +7548,6 @@ stringify-entities@^4.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 version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7689,6 +7736,11 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +three@^0.183.2: + version "0.183.2" + resolved "https://registry.yarnpkg.com/three/-/three-0.183.2.tgz#606e3195bf210ef8d1eaaca2ab8c59d92d2bbc18" + integrity sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ== + timers-browserify@^2.0.4: version "2.0.12" resolved "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz" @@ -8346,7 +8398,6 @@ workerpool@^6.5.1: integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==