From fa03a0b919f1b8c1a6be16f171d7ef17e6bb9916 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Sat, 21 Mar 2026 08:54:57 +0530 Subject: [PATCH 1/6] Refactor testing scripts in package.json and update dependencies - Simplified test scripts in package.json by removing specific config references for vitest. - Updated @tauri-apps/api dependency version to 2.10.1. - Removed unused dependencies from yarn.lock and updated Cargo.toml and Cargo.lock for tinyhumansai to version 0.1.4. - Enhanced memory management in Conversations and Login components by ensuring async token synchronization. - Introduced recall_memory command in Tauri for improved context retrieval from memory. --- package.json | 14 +-- src-tauri/Cargo.lock | 4 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/memory.rs | 34 ++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/memory/mod.rs | 82 +++++++++++-- src-tauri/src/runtime/qjs_skill_instance.rs | 47 +++++--- src/App.tsx | 4 +- src/pages/Conversations.tsx | 120 +++++++++++++------- src/pages/Login.tsx | 2 +- src/utils/tauriCommands.ts | 11 +- yarn.lock | 60 +--------- 12 files changed, 248 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index 745bb7a0a..66397acbb 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,11 @@ "macos:dev": "yarn macos:build:debug && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'", "android:dev": "tauri android dev", "android:build": "tauri android build", - "test": "vitest run --config test/vitest.config.ts", - "test:unit": "vitest run --config test/vitest.config.ts", - "test:unit:watch": "vitest --config test/vitest.config.ts --watch", - "test:watch": "vitest --config test/vitest.config.ts --watch", - "test:coverage": "vitest run --config test/vitest.config.ts --coverage", + "test": "vitest run", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path src-tauri/Cargo.toml", "test:e2e:build": "bash scripts/e2e-build.sh", "test:e2e:login": "bash scripts/e2e-login.sh", @@ -48,13 +48,12 @@ "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "2.10.1", "@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", @@ -71,7 +70,6 @@ "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" }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 675dc0b21..c171f5d00 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8405,9 +8405,9 @@ dependencies = [ [[package]] name = "tinyhumansai" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a17392a3521fbef245da18fcef9e37bca8d97d9795da973a398e5d64d1b8af" +checksum = "f7a88dba7fe194a507d0351c5f8b5cd8518fb30f8307dd788333f9715f51c44e" dependencies = [ "reqwest", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d5e519eaa..8c10bf938 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -131,7 +131,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" +tinyhumansai = "0.1.4" # Optional integrations matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] } diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs index a3cbecb87..c83f6a9e5 100644 --- a/src-tauri/src/commands/memory.rs +++ b/src-tauri/src/commands/memory.rs @@ -27,6 +27,40 @@ pub async fn init_memory_client( Ok(()) } +/// Recall context from the TinyHumans Master memory node for a skill integration. +/// Returns the recalled context string (or null if the server had nothing to return). +#[tauri::command] +pub async fn recall_memory( + skill_id: String, + integration_id: String, + max_chunks: Option, + state: tauri::State<'_, MemoryState>, +) -> Result, String> { + log::info!( + "[memory] recall_memory: entry (skill_id={skill_id}, integration_id={integration_id}, max_chunks={max_chunks:?})" + ); + let client = state.0.lock().map_err(|e| e.to_string())?.clone(); + match client { + Some(c) => { + let result = c + .recall_skill_context(&skill_id, &integration_id, max_chunks.unwrap_or(10)) + .await; + match &result { + Ok(ctx) => log::info!( + "[memory] recall_memory: exit — ok (has_context={})", + ctx.is_some() + ), + Err(e) => log::warn!("[memory] recall_memory: exit — error: {e}"), + } + result + } + None => { + log::warn!("[memory] recall_memory: exit — client not initialised (no JWT set)"); + Err("Memory layer not configured — JWT token not yet set".into()) + } + } +} + /// Query the TinyHumans memory for a skill integration. /// Returns the RAG context string to inject into AI prompts. #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce2f1a39e..ed8f6c7e3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -797,6 +797,7 @@ pub fn run() { // Memory commands (TinyHumans Neocortex) init_memory_client, memory_query, + recall_memory, ] } #[cfg(not(desktop))] @@ -916,6 +917,7 @@ pub fn run() { // Memory commands (TinyHumans Neocortex) init_memory_client, memory_query, + recall_memory, ] } }) diff --git a/src-tauri/src/memory/mod.rs b/src-tauri/src/memory/mod.rs index 96e4fd7ea..f5084fcd5 100644 --- a/src-tauri/src/memory/mod.rs +++ b/src-tauri/src/memory/mod.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use tinyhumansai::{ - DeleteMemoryParams, InsertMemoryParams, QueryMemoryParams, TinyHumanConfig, - TinyHumanMemoryClient, + DeleteMemoryParams, InsertMemoryParams, QueryMemoryParams, RecallMemoryParams, + TinyHumanConfig, TinyHumanMemoryClient, }; /// Shared, cloneable handle to the memory client. @@ -21,15 +21,23 @@ impl MemoryClient { /// Construct from a JWT token (sourced from `authSlice.token` in the Redux store). /// Returns `None` if the token is empty or client construction fails. pub fn from_token(jwt_token: String) -> Option { - log::debug!("[memory] from_token: entry (token_len={})", jwt_token.trim().len()); + log::info!("[memory] from_token: entry (token_len={})", jwt_token.trim().len()); if jwt_token.trim().is_empty() { log::warn!("[memory] from_token: exit — token is empty, returning None"); return None; } - let base_url = std::env::var("TINYHUMANS_BASE_URL") - .unwrap_or_else(|_| "https://api.tinyhumans.ai".to_string()); - log::debug!("[memory] from_token: constructing client (base_url={base_url})"); - let config = TinyHumanConfig::new(jwt_token).with_base_url(base_url); + let config = match std::env::var("ALPHAHUMAN_BASE_URL") + .or_else(|_| std::env::var("TINYHUMANS_BASE_URL")) + { + Ok(base_url) => { + log::info!("[memory] from_token: constructing client (base_url={base_url})"); + TinyHumanConfig::new(jwt_token).with_base_url(base_url) + } + Err(_) => { + log::warn!("[memory] from_token: constructing client (base_url=)"); + TinyHumanConfig::new(jwt_token) + } + }; match TinyHumanMemoryClient::new(config) { Ok(inner) => { log::info!("[memory] from_token: exit — client created successfully"); @@ -58,6 +66,7 @@ impl MemoryClient { "[memory] store_skill_sync: payload → namespace={namespace} | title={title} | content={}", content ); + log::info!("[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?})"); let result = self.inner .insert_memory(InsertMemoryParams { title: title.to_string(), @@ -66,8 +75,13 @@ impl MemoryClient { ..Default::default() }) .await - .map(|_| ()) - .map_err(|e| format!("Memory insert failed: {e}")); + .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)); + 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}"), @@ -93,7 +107,7 @@ impl MemoryClient { .query_memory(QueryMemoryParams { query: query.to_string(), namespace: Some(namespace.clone()), - max_chunks: Some(max_chunks), + max_chunks: Some(f64::from(max_chunks)), ..Default::default() }) .await @@ -106,6 +120,37 @@ impl MemoryClient { Ok(response) } + /// Recall context from the Master memory node for a given namespace. + /// Returns the synthesised `response` string, or `None` if the server returned nothing. + pub async fn recall_skill_context( + &self, + skill_id: &str, + integration_id: &str, + max_chunks: u32, + ) -> Result, String> { + let namespace = format!("skill:{skill_id}:{integration_id}"); + log::info!( + "[memory] recall_skill_context: entry (namespace={namespace}, max_chunks={max_chunks})" + ); + let res = self + .inner + .recall_memory(RecallMemoryParams { + namespace: Some(namespace.clone()), + max_chunks: Some(f64::from(max_chunks)), + }) + .await + .map_err(|e| { + log::warn!("[memory] recall_skill_context: exit — error (namespace={namespace}): {e}"); + format!("Memory recall failed: {e}") + })?; + let response = res.data.response; + log::info!( + "[memory] recall_skill_context: exit — ok (namespace={namespace}, has_response={})", + response.is_some() + ); + Ok(response) + } + /// Delete all memories for a skill integration (e.g. on OAuth revoke). pub async fn clear_skill_memory( &self, @@ -130,6 +175,23 @@ impl MemoryClient { } } +fn classify_insert_error(e: &tinyhumansai::TinyHumanError) -> &'static str { + let msg = e.to_string(); + if msg.contains("dns") || msg.contains("resolve") || msg.contains("lookup") { + "dns_failure" + } else if msg.contains("tls") || msg.contains("certificate") || msg.contains("ssl") { + "tls_failure" + } else if msg.contains("Connection refused") || msg.contains("connection refused") { + "connection_refused" + } else if msg.contains("timed out") || msg.contains("deadline") { + "timeout" + } else if msg.contains("error sending request") { + "transport_error" + } else { + "other" + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index 87ee22422..aa43971b4 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -330,7 +330,7 @@ async fn run_event_loop( // messages (events, pings, etc.) but queue any new CallTool. match rx.try_recv() { Ok(msg) => { - let should_stop = handle_message(rt, ctx, msg, state, skill_id, &mut pending_tool, app_handle).await; + let should_stop = handle_message(rt, ctx, msg, state, skill_id, &mut pending_tool, app_handle, ops_state).await; if should_stop { break; } @@ -447,6 +447,7 @@ async fn handle_message( skill_id: &str, pending_tool: &mut Option, app_handle: Option<&tauri::AppHandle>, + ops_state: &Arc>, ) -> bool { match msg { SkillMessage::CallTool { tool_name, arguments, reply } => { @@ -553,12 +554,13 @@ async fn handle_message( } } SkillMessage::Rpc { method, params, reply } => { - // Resolve the optional memory client once for this RPC call - let memory_client_opt: Option = - app_handle.and_then(|ah| { - ah.try_state::>() - .and_then(|s: tauri::State<'_, Option>| s.inner().clone()) - }); + // Resolve the optional memory client once for this RPC call. + // State is registered as MemoryState(Mutex>), not + // Option directly, so we must use the newtype wrapper. + let memory_client_opt = app_handle.and_then(|ah| { + ah.try_state::() + .and_then(|s| s.0.lock().ok().and_then(|g| g.clone())) + }); let result = match method.as_str() { "oauth/complete" => { @@ -582,8 +584,11 @@ async fn handle_message( 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 returned payload to TinyHumans memory - if let Ok(ref payload) = result { + // 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 @@ -591,8 +596,10 @@ async fn handle_message( .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - let content = serde_json::to_string_pretty(payload) - .unwrap_or_else(|_| payload.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 @@ -614,13 +621,21 @@ async fn handle_message( } "skill/sync" => { let result = handle_js_call(rt, ctx, "onSync", "{}").await; - - // Fire-and-forget: persist sync payload to TinyHumans memory - if let Ok(ref payload) = result { + // 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 onSync() (which is typically undefined). + let state_snapshot = ops_state.read().data.clone(); + log::info!( + "[memory] store_skill_sync: payload → state_snapshot={:?}", + state_snapshot + ); + if !state_snapshot.is_empty() { if let Some(client) = memory_client_opt.clone() { let skill = skill_id.to_string(); - let content = serde_json::to_string_pretty(payload) - .unwrap_or_else(|_| payload.to_string()); + let content = serde_json::to_string_pretty( + &serde_json::Value::Object(state_snapshot), + ) + .unwrap_or_else(|_| "{}".to_string()); let title = format!("{} periodic sync", skill); tokio::spawn(async move { if let Err(e) = client diff --git a/src/App.tsx b/src/App.tsx index 69b99fb0e..58cbcf7d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,10 +27,10 @@ function App() { { + onBeforeLift={async () => { const token = store.getState().auth.token; console.info('[memory] PersistGate onBeforeLift: token_present=%s', !!token); - if (token) void syncMemoryClientToken(token); + if (token) await syncMemoryClientToken(token); }}> diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 297ea8c6c..e9be7aadc 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -12,13 +12,13 @@ import { useNavigate, useParams } from 'react-router-dom'; import { injectAll } from '../lib/ai/injector'; import type { Message } from '../lib/ai/providers/interface'; import { skillManager } from '../lib/skills/manager'; +import { creditsApi, type TeamUsage } from '../services/api/creditsApi'; import { type ChatMessage, inferenceApi, type ModelInfo, type Tool, } from '../services/api/inferenceApi'; -import { type TeamUsage, creditsApi } from '../services/api/creditsApi'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import type { NotionPageSummary, NotionSummary, NotionUserProfile } from '../store/notionSlice'; import { @@ -388,6 +388,23 @@ const Conversations = () => { // Continue with original message } + // Prepend recalled memory context from TinyHumans Master node + try { + const { invoke } = await import('@tauri-apps/api/core'); + const recalledContext = await invoke('recall_memory', { + skillId: 'conversations', + integrationId: selectedThreadId, + maxChunks: 10, + }); + if (recalledContext) { + processedUserContent = `[MEMORY_CONTEXT]\n${recalledContext}\n[/MEMORY_CONTEXT]\n\n${processedUserContent}`; + console.log('✅ Memory recall injected into prompt'); + } + } catch (recallError) { + console.warn('⚠️ Memory recall skipped:', recallError); + // Non-fatal — continue without recalled context + } + // Prepend Notion workspace context if connected const notionContext = buildNotionContext( notionProfile, @@ -1077,46 +1094,73 @@ const Conversations = () => { )}
{/* Budget indicator — circular */} - {(isLoadingBudget || teamUsage) && (() => { - const size = 22; - const r = 9; - const circ = 2 * Math.PI * r; - const pct = teamUsage - ? Math.min(1, teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) - : 0; - const dash = pct * circ; - return ( -
- - - {teamUsage ? ( + {(isLoadingBudget || teamUsage) && + (() => { + const size = 22; + const r = 9; + const circ = 2 * Math.PI * r; + const pct = teamUsage + ? Math.min(1, teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) + : 0; + const dash = pct * circ; + return ( +
+ - ) : ( - + {teamUsage ? ( + + ) : ( + + )} + + {teamUsage && ( + + ${teamUsage.remainingUsd.toFixed(2)} + )} - - {teamUsage && ( - - ${teamUsage.remainingUsd.toFixed(2)} - - )} -
- ); - })()} +
+ ); + })()}
{sendError && (
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 39494b1a7..5a44ec23a 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -28,7 +28,7 @@ const Login = () => { dispatch(setToken(jwtToken)); console.info('[memory] Login: dispatching syncMemoryClientToken after setToken'); - void syncMemoryClientToken(jwtToken); + await syncMemoryClientToken(jwtToken); navigate('/onboarding/', { replace: true }); } catch (err) { if (!cancelled) { diff --git a/src/utils/tauriCommands.ts b/src/utils/tauriCommands.ts index 09cfae7f6..a8b91b80b 100644 --- a/src/utils/tauriCommands.ts +++ b/src/utils/tauriCommands.ts @@ -169,13 +169,20 @@ export async function setWindowTitle(title: string): Promise { * Redux Persist rehydration. */ export async function syncMemoryClientToken(token: string): Promise { - console.debug('[memory] syncMemoryClientToken: entry (token_present=%s, is_tauri=%s)', !!token, isTauri()); + console.debug( + '[memory] syncMemoryClientToken: entry (token_present=%s, is_tauri=%s)', + !!token, + isTauri() + ); if (!isTauri() || !token) { console.debug('[memory] syncMemoryClientToken: exit — skipped (not Tauri or empty token)'); return; } try { - console.debug('[memory] syncMemoryClientToken: payload → init_memory_client { jwtToken: }', token.length); + console.debug( + '[memory] syncMemoryClientToken: payload → init_memory_client { jwtToken: }', + token.length + ); await invoke('init_memory_client', { jwtToken: token }); console.info('[memory] syncMemoryClientToken: exit — ok'); } catch (err) { diff --git a/yarn.lock b/yarn.lock index 81cb0f24c..7dc660019 100644 --- a/yarn.lock +++ b/yarn.lock @@ -319,11 +319,6 @@ 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" @@ -1299,7 +1294,12 @@ dependencies: postcss-selector-parser "6.0.10" -"@tauri-apps/api@^2", "@tauri-apps/api@^2.8.0": +"@tauri-apps/api@2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.10.1.tgz#57c1bae6114ec33d977eb2b50dfefc25fa84fc93" + integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw== + +"@tauri-apps/api@^2.8.0": version "2.9.1" resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz" integrity sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw== @@ -1461,11 +1461,6 @@ 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" @@ -1666,29 +1661,11 @@ 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" @@ -1704,11 +1681,6 @@ 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" @@ -2137,11 +2109,6 @@ 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" @@ -4160,11 +4127,6 @@ 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" @@ -5632,11 +5594,6 @@ 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" @@ -7732,11 +7689,6 @@ 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" From a373e5de3cbf02d0a448a62e012e35d1c9cbedcc Mon Sep 17 00:00:00 2001 From: cyrus Date: Mon, 23 Mar 2026 23:48:43 +0530 Subject: [PATCH 2/6] feat: migrate conversation orchestration to Rust-side Tauri commands - Added `chat_send` and `chat_cancel` Tauri commands for backend-driven conversation loops. - Moved agentic loop logic from frontend to Rust, optimizing performance and reducing frontend responsibilities. - Implemented event protocol (`chat:tool_call`, `chat:tool_result`, `chat:done`, `chat:error`) to communicate loop progress to the frontend. - Introduced AI config loading, OpenClaw context building, and cancellation support for chat requests. --- package.json | 2 + src-tauri/src/commands/chat.rs | 1096 ++++++++++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 12 + src-tauri/tauri.conf.json | 3 +- src/pages/Conversations.tsx | 267 ++++++-- src/services/chatService.ts | 130 ++++ yarn.lock | 86 ++- 8 files changed, 1544 insertions(+), 54 deletions(-) create mode 100644 src-tauri/src/commands/chat.rs create mode 100644 src/services/chatService.ts diff --git a/package.json b/package.json index 66397acbb..5c93ec2ef 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,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", @@ -70,6 +71,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" }, 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 ed8f6c7e3..df9bd3d5a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,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, @@ -648,6 +649,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()); @@ -798,6 +804,9 @@ pub fn run() { init_memory_client, memory_query, recall_memory, + // Chat commands (agentic conversation loop) + chat_send, + chat_cancel, ] } #[cfg(not(desktop))] @@ -918,6 +927,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 2423e82bb..c2c91c617 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 7dc660019..f216642f8 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" @@ -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" @@ -7400,7 +7448,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== @@ -7499,8 +7556,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== @@ -7689,6 +7752,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" @@ -8345,8 +8413,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 +8431,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" From b25434de04724d992b246e68da8af7e9e47b97cb Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 25 Mar 2026 11:49:08 +0530 Subject: [PATCH 3/6] Update dependencies and refactor Login component - Downgraded @tauri-apps/api to version 2.9.1 and updated @tauri-apps/cli to 2.9.6 in package.json. - Added three.js library with version 0.183.2. - Modified Login component to accept isWeb prop and refactored its structure for improved user experience. - Updated build.rs for local builds and adjusted memory client references in memory module. - Updated Welcome component to navigate to login based on isWeb prop. --- package.json | 8 ++-- skills | 2 +- src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 2 +- src-tauri/build.rs | 61 ++++++++++++------------- src-tauri/src/memory/mod.rs | 12 ++--- src/AppRoutes.tsx | 2 +- src/pages/Login.tsx | 90 +++++++++++++------------------------ src/pages/Welcome.tsx | 11 +++-- yarn.lock | 16 +++---- 10 files changed, 93 insertions(+), 114 deletions(-) diff --git a/package.json b/package.json index 66397acbb..ba0e692c7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@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.9.1", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.2", @@ -70,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" }, @@ -77,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", @@ -113,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 0bb1cef55..14217d3ae 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 0bb1cef5573e568c2c122a03e8fbc382abd58ffb +Subproject commit 14217d3aee6b92f7cb6d4c891095de8947ba1397 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c171f5d00..600d2ddaf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8406,9 +8406,8 @@ dependencies = [ [[package]] name = "tinyhumansai" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7a88dba7fe194a507d0351c5f8b5cd8518fb30f8307dd788333f9715f51c44e" dependencies = [ + "log", "reqwest", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8c10bf938..f4270b4b8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -131,7 +131,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 = { path = "../../neocortex/packages/sdk-rust" } # 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/memory/mod.rs b/src-tauri/src/memory/mod.rs index f5084fcd5..ca22482b6 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, QueryMemoryParams, RecallMemoryParams, - TinyHumanConfig, TinyHumanMemoryClient, + 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 }) @@ -66,9 +67,10 @@ impl MemoryClient { "[memory] store_skill_sync: payload → namespace={namespace} | title={title} | content={}", content ); - log::info!("[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?})"); + log::info!("[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?}), content_len={}", content.len()); let result = self.inner .insert_memory(InsertMemoryParams { + document_id: Some(Uuid::new_v4().to_string()), title: title.to_string(), content: content.to_string(), namespace: namespace.clone(), @@ -175,7 +177,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" diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index a1790e197..51be35665 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -80,7 +80,7 @@ const AppRoutes = () => { path="/login" element={ - + } /> diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 5a44ec23a..e29c76570 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 WelcomeProps { + 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 }: WelcomeProps) => { + 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/Welcome.tsx b/src/pages/Welcome.tsx index dc161a89d..1b12b2308 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -1,3 +1,5 @@ +import { useNavigate } from 'react-router-dom'; + import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas'; interface WelcomeProps { @@ -5,6 +7,8 @@ interface WelcomeProps { } const Welcome = ({ isWeb }: WelcomeProps) => { + const navigate = useNavigate(); + return (
@@ -21,14 +25,15 @@ const Welcome = ({ isWeb }: WelcomeProps) => {

- A focused command center for AI-driven execution. Minimal surfaces, hard edges, and - no visual noise. + A focused command center for AI-driven execution. Minimal surfaces, hard edges, and no + visual noise.

); } diff --git a/yarn.lock b/yarn.lock index 3f777e24f..b73180694 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1299,7 +1299,12 @@ dependencies: postcss-selector-parser "6.0.10" -"@tauri-apps/api@2.9.1", "@tauri-apps/api@^2.8.0": +"@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== + +"@tauri-apps/api@^2.8.0": version "2.9.1" resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz" integrity sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw== @@ -7443,16 +7448,7 @@ 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": - 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: +"string-width-cjs@npm:string-width@^4.2.0", 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== @@ -7551,14 +7547,7 @@ 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": - 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: +"strip-ansi-cjs@npm:strip-ansi@^6.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== @@ -8408,7 +8397,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-cjs@npm:wrap-ansi@^7.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== @@ -8426,15 +8415,6 @@ 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" From 8fd50dfc2762948324ac94e8b97cb2d1d499d611 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 25 Mar 2026 20:53:18 +0530 Subject: [PATCH 6/6] Update tinyhumansai dependency to version 0.1.5 in Cargo.toml and Cargo.lock; adjust memory client query_skill_context method to simplify namespace handling by removing integration_id parameter. --- src-tauri/Cargo.lock | 5 +++-- src-tauri/Cargo.toml | 2 +- src-tauri/src/memory/mod.rs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) 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/src/memory/mod.rs b/src-tauri/src/memory/mod.rs index 6a625c9cf..f07385d9e 100644 --- a/src-tauri/src/memory/mod.rs +++ b/src-tauri/src/memory/mod.rs @@ -180,11 +180,11 @@ impl MemoryClient { 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}"