mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -0,0 +1,275 @@
|
||||
# Skills → Memory Layer → Agent Inference: Full Flow
|
||||
|
||||
## Overview
|
||||
|
||||
This document traces the complete data flow from skill discovery and OAuth/sync events, through
|
||||
the TinyHumans Neocortex memory layer (tinyhumansai SDK), and into the Rust-side agentic
|
||||
inference loop — showing exactly how skill data is written to and read from memory and how it
|
||||
reaches the LLM context at inference time.
|
||||
|
||||
---
|
||||
|
||||
## 1. Skill Discovery & Lifecycle (SkillProvider.tsx)
|
||||
|
||||
**File**: `src/providers/SkillProvider.tsx`
|
||||
|
||||
On app mount (when a JWT token is present) `SkillProvider` calls
|
||||
`invoke('runtime_discover_skills')` → the Rust runtime scans
|
||||
`skills/skills/{skill-id}/manifest.json` from the git submodule and returns a manifest list.
|
||||
|
||||
```
|
||||
Token present
|
||||
→ discoverSkills()
|
||||
→ invoke('runtime_discover_skills') // Rust: qjs_engine.rs:184
|
||||
→ reads skills/skills/*/manifest.json
|
||||
→ filters: is_javascript() && supports_current_platform()
|
||||
→ returns manifests[]
|
||||
→ skillManager.registerSkill(manifest) // in-memory registry
|
||||
→ for each manifest with setupComplete:
|
||||
skillManager.startSkill(manifest) // starts V8/QuickJS instance
|
||||
```
|
||||
|
||||
Two Tauri event listeners run continuously:
|
||||
|
||||
| Event | What it does |
|
||||
|---|---|
|
||||
| `skill-state-changed` | Dispatches `setSkillState` into Redux `skillsSlice.skillStates[skillId]` |
|
||||
| `runtime:skill-status-changed` | Updates `skillsSlice.skills[skillId].status`; surfaces errors |
|
||||
|
||||
---
|
||||
|
||||
## 2. Memory Client Initialisation
|
||||
|
||||
**File**: `src-tauri/src/memory/mod.rs`
|
||||
**Tauri command**: `init_memory_client` (called by frontend after auth)
|
||||
|
||||
The `MemoryClient` wraps the `TinyHumansMemoryClient` from the `tinyhumansai` Rust crate.
|
||||
It is constructed with the user's JWT (`authSlice.token`) and stored as `Arc<MemoryClient>`
|
||||
in `MemoryState` (a `Mutex<Option<MemoryClientRef>>`).
|
||||
|
||||
Base URL resolution (in priority order):
|
||||
1. `ALPHAHUMAN_BASE_URL` env var
|
||||
2. `TINYHUMANS_BASE_URL` env var
|
||||
3. SDK default
|
||||
|
||||
---
|
||||
|
||||
## 3. Skill → Memory Sync: Two Write Paths
|
||||
|
||||
Both trigger inside `src-tauri/src/runtime/qjs_skill_instance.rs`.
|
||||
|
||||
### 3a. OAuth Completion (`skill/oauth-complete`)
|
||||
|
||||
When a user connects a skill via OAuth, the JS runtime calls back with `skill/oauth-complete`.
|
||||
After the OAuth flow completes the skill's **ops state** (data published via `state.set()`) is
|
||||
snapshotted and stored to memory:
|
||||
|
||||
```
|
||||
skill/oauth-complete handler
|
||||
→ handle_js_call(rt, ctx, "onOAuthComplete", params) // runs skill JS
|
||||
→ ops_state.read().data.clone() // snapshot skill state
|
||||
→ tokio::spawn (fire-and-forget):
|
||||
MemoryClient::store_skill_sync(
|
||||
skill_id = e.g. "gmail"
|
||||
integration_id = params["integrationId"] // e.g. user email
|
||||
title = "{skill} OAuth sync — {integrationId}"
|
||||
content = JSON snapshot of ops state
|
||||
namespace = "skill:{skill_id}:{integration_id}"
|
||||
)
|
||||
→ tinyhumansai::TinyHumansMemoryClient::insert_memory(InsertMemoryParams { ... })
|
||||
→ HTTP POST to TinyHumans Neocortex API
|
||||
```
|
||||
|
||||
### 3b. Periodic Sync (`skill/sync`)
|
||||
|
||||
The runtime fires `skill/sync` events on a cron schedule. The flow is identical to OAuth
|
||||
completion but uses `integration_id = "default"` and title `"{skill} periodic sync"`:
|
||||
|
||||
```
|
||||
skill/sync handler
|
||||
→ handle_js_call(rt, ctx, "onSync", "{}")
|
||||
→ ops_state.read().data.clone()
|
||||
→ tokio::spawn:
|
||||
MemoryClient::store_skill_sync(
|
||||
skill_id = e.g. "notion"
|
||||
integration_id = "default"
|
||||
namespace = "skill:notion:default"
|
||||
content = JSON snapshot
|
||||
)
|
||||
```
|
||||
|
||||
### Namespace Pattern
|
||||
|
||||
All skill memories are stored under `skill:{skill_id}:{integration_id}`.
|
||||
Examples: `skill:gmail:user@example.com`, `skill:notion:default`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory Operations Reference
|
||||
|
||||
**File**: `src-tauri/src/memory/mod.rs`
|
||||
|
||||
| Method | SDK call | When used |
|
||||
|---|---|---|
|
||||
| `store_skill_sync(...)` | `insert_memory` | OAuth complete, periodic sync |
|
||||
| `query_skill_context(...)` | `query_memory` | RAG query — fetch relevant chunks for a user question |
|
||||
| `recall_skill_context(...)` | `recall_memory` | Recall synthesised summary from Master node |
|
||||
| `clear_skill_memory(...)` | `delete_memory` | OAuth revoke / disconnect |
|
||||
|
||||
---
|
||||
|
||||
## 5. Conversation → Inference: Two Code Paths
|
||||
|
||||
**File**: `src/pages/Conversations.tsx`
|
||||
|
||||
`useRustChat()` returns `true` when running in Tauri (desktop). This selects between two paths:
|
||||
|
||||
```
|
||||
handleSendMessage(text)
|
||||
├─ rustChat == true → Rust path (invoke chat_send)
|
||||
└─ rustChat == false → Web path (handleSendMessageWeb — TypeScript loop)
|
||||
```
|
||||
|
||||
### 5a. Rust Path (Desktop)
|
||||
|
||||
```
|
||||
chatSend({ threadId, message, model, authToken, backendUrl, messages, notionContext })
|
||||
→ invoke('chat_send') // src-tauri/src/commands/chat.rs:359
|
||||
→ spawns background task
|
||||
→ chat_send_inner(...)
|
||||
```
|
||||
|
||||
Completion events flow back over Tauri events:
|
||||
|
||||
| Event | Frontend handler |
|
||||
|---|---|
|
||||
| `chat:tool_call` | shows active tool indicator |
|
||||
| `chat:tool_result` | clears tool indicator |
|
||||
| `chat:done` | `dispatch(addInferenceResponse(...))` |
|
||||
| `chat:error` | shows error, clears loading state |
|
||||
|
||||
### 5b. Web/Fallback Path
|
||||
|
||||
Used when not running in Tauri (browser). Runs the agentic loop entirely in TypeScript:
|
||||
- Calls `inferenceApi.createChatCompletion(request)` directly
|
||||
- Executes tools via `skillManager.callTool(skillId, toolName, args)`
|
||||
- Both paths share the same 5-round `MAX_TOOL_ROUNDS` limit and `{skillId}__{toolName}` naming convention
|
||||
|
||||
---
|
||||
|
||||
## 6. Rust Agentic Loop in Detail
|
||||
|
||||
**File**: `src-tauri/src/commands/chat.rs` — `chat_send_inner()`
|
||||
|
||||
```
|
||||
Step 1: Load OpenClaw context
|
||||
→ load_openclaw_context(app)
|
||||
→ reads ai/SOUL.md, IDENTITY.md, AGENTS.md, USER.md, BOOTSTRAP.md, MEMORY.md, TOOLS.md
|
||||
→ cached in static AI_CONFIG_CACHE (cleared on restart)
|
||||
→ truncated to MAX_CONTEXT_CHARS (20,000 chars)
|
||||
|
||||
Step 2: Recall memory context
|
||||
→ MemoryClient::recall_skill_context("conversations", thread_id, 10)
|
||||
→ tinyhumansai recall_memory(namespace="skill:conversations:{thread_id}")
|
||||
→ returns synthesised summary string or None
|
||||
|
||||
Step 3: Build processed user message
|
||||
processed = user_message
|
||||
if openclaw_context → prepend as "## Project Context\n...\n\nUser message: {processed}"
|
||||
if memory_context → prepend as "[MEMORY_CONTEXT]\n{mem}\n[/MEMORY_CONTEXT]\n\n{processed}"
|
||||
if notion_context → prepend as "{notionContext}\n\n{processed}"
|
||||
|
||||
Step 4: Build messages array
|
||||
→ history (ChatMessagePayload[]) + processed user message
|
||||
|
||||
Step 5: Discover tools
|
||||
→ engine.all_tools()
|
||||
→ returns all tools from running skills
|
||||
→ namespaced: "{skill_id}__{tool_name}"
|
||||
→ formatted as OpenAI function-calling schema
|
||||
|
||||
Step 6: Agentic loop (max 5 rounds)
|
||||
for round in 0..MAX_TOOL_ROUNDS:
|
||||
POST {backend_url}/openai/v1/chat/completions
|
||||
body: { model, messages, tools, tool_choice: "auto" }
|
||||
timeout: 120s
|
||||
auth: Bearer {auth_token}
|
||||
|
||||
if finish_reason == "tool_calls":
|
||||
emit chat:tool_call
|
||||
engine.call_tool(skill_id, tool_name, args) // 60s timeout
|
||||
→ QuickJS/V8 runtime executes skill JS tool handler
|
||||
emit chat:tool_result
|
||||
append tool result to messages
|
||||
continue loop
|
||||
|
||||
else (finish_reason == "stop"):
|
||||
emit chat:done { full_response, rounds_used, token_counts }
|
||||
return Ok(())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Tool Execution: Rust → Skill JS
|
||||
|
||||
**File**: `src-tauri/src/runtime/qjs_engine.rs` — `call_tool(skill_id, tool_name, args)`
|
||||
|
||||
The Rust runtime routes `call_tool` into the running QuickJS/V8 skill instance:
|
||||
- Serialises `args` as JSON
|
||||
- Calls the JS tool handler registered by the skill
|
||||
- Returns `ToolCallResult { content: Vec<ToolContent>, is_error: bool }`
|
||||
- Text content is extracted and appended as the `tool` role message in the loop
|
||||
|
||||
---
|
||||
|
||||
## 8. End-to-End Flow Summary
|
||||
|
||||
```
|
||||
User types message (Conversations.tsx)
|
||||
│
|
||||
├─ [Web path only] invoke('recall_memory') → TinyHumans API (recall)
|
||||
│ returns synthesised context
|
||||
├─ buildNotionContext() → from Redux skillStates.notion
|
||||
│
|
||||
▼
|
||||
invoke('chat_send') [Rust/desktop path]
|
||||
│
|
||||
▼
|
||||
Rust: chat_send_inner()
|
||||
├─ load_openclaw_context() → ai/*.md files (cached)
|
||||
├─ recall_skill_context("conversations", tid) → TinyHumans API (recall)
|
||||
├─ Build prompt: [OpenClaw] + [MEMORY] + [Notion] + user_message
|
||||
├─ discover_tools() → all running skill tools
|
||||
│
|
||||
└─ Agentic loop (≤5 rounds):
|
||||
POST /openai/v1/chat/completions → Backend LLM (neocortex-mk1)
|
||||
│
|
||||
├─ tool_calls → engine.call_tool() → QuickJS/V8 skill instance
|
||||
│ └─ JS tool handler
|
||||
│ └─ returns result string
|
||||
│ └─ appended as tool message
|
||||
│
|
||||
└─ stop → emit chat:done
|
||||
└─ Frontend: dispatch(addInferenceResponse(...))
|
||||
|
||||
Separately (async, fire-and-forget):
|
||||
Skill onSync() / onOAuthComplete()
|
||||
→ MemoryClient::store_skill_sync()
|
||||
→ TinyHumans insert_memory (namespace: skill:{id}:{integrationId})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/providers/SkillProvider.tsx` | Discovery, lifecycle, Redux state sync |
|
||||
| `src/pages/Conversations.tsx` | Message send, both code paths, Notion context |
|
||||
| `src/services/chatService.ts` | `chatSend()`, `chatCancel()`, `useRustChat()` |
|
||||
| `src-tauri/src/commands/chat.rs` | Rust agentic loop, context assembly, tool dispatch |
|
||||
| `src-tauri/src/commands/memory.rs` | `recall_memory` Tauri command |
|
||||
| `src-tauri/src/memory/mod.rs` | `MemoryClient` wrapping tinyhumansai SDK |
|
||||
| `src-tauri/src/runtime/qjs_skill_instance.rs` | Skill sync → memory write triggers |
|
||||
| `src-tauri/src/runtime/qjs_engine.rs` | `discover_skills()`, `call_tool()`, `all_tools()` |
|
||||
| `skills/skills/*/manifest.json` | Skill metadata (git submodule) |
|
||||
+6
-3
@@ -47,12 +47,13 @@
|
||||
"@scure/bip32": "^2.0.1",
|
||||
"@scure/bip39": "^2.0.1",
|
||||
"@sentry/react": "^10.38.0",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/api": "^2.10.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"buffer": "^6.0.3",
|
||||
"debug": "^4.4.3",
|
||||
"immer": "^11.1.3",
|
||||
@@ -69,6 +70,7 @@
|
||||
"redux-persist": "^6.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"telegram": "^2.26.22",
|
||||
"three": "^0.183.2",
|
||||
"util": "^0.12.5",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
@@ -76,7 +78,7 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "2.9.6",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -112,5 +114,6 @@
|
||||
"vite": "^7.0.4",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||
}
|
||||
|
||||
+1
-1
Submodule skills updated: ff2534fc4d...14217d3aee
Generated
+3
-2
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+31
-30
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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::*;
|
||||
|
||||
@@ -23,6 +23,7 @@ mod unified_skills;
|
||||
mod utils;
|
||||
|
||||
use ai::*;
|
||||
use commands::chat::ChatState;
|
||||
use commands::unified_skills::{
|
||||
unified_execute_skill, unified_generate_skill, unified_list_skills, unified_self_evolve_skill,
|
||||
};
|
||||
@@ -688,6 +689,11 @@ pub fn run() {
|
||||
app.manage(commands::memory::MemoryState(std::sync::Mutex::new(None)));
|
||||
log::info!("[memory] Memory state registered — awaiting JWT from frontend");
|
||||
|
||||
// Initialize ChatState for managing in-flight conversation requests
|
||||
let chat_state = std::sync::Arc::new(ChatState::new());
|
||||
app.manage(chat_state);
|
||||
log::info!("[chat] ChatState registered");
|
||||
|
||||
// Store SocketManager as Tauri state
|
||||
app.manage(socket_mgr.clone());
|
||||
|
||||
@@ -839,6 +845,9 @@ pub fn run() {
|
||||
init_memory_client,
|
||||
memory_query,
|
||||
recall_memory,
|
||||
// Chat commands (agentic conversation loop)
|
||||
chat_send,
|
||||
chat_cancel,
|
||||
]
|
||||
}
|
||||
#[cfg(not(desktop))]
|
||||
@@ -960,6 +969,9 @@ pub fn run() {
|
||||
init_memory_client,
|
||||
memory_query,
|
||||
recall_memory,
|
||||
// Chat commands (agentic conversation loop)
|
||||
chat_send,
|
||||
chat_cancel,
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
+102
-26
@@ -7,14 +7,15 @@
|
||||
use std::sync::Arc;
|
||||
use tinyhumansai::{
|
||||
DeleteMemoryParams, InsertMemoryParams, Priority, QueryMemoryParams, RecallMemoryParams,
|
||||
SourceType, TinyHumanConfig, TinyHumanMemoryClient,
|
||||
SourceType, TinyHumanConfig, TinyHumansMemoryClient,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Shared, cloneable handle to the memory client.
|
||||
pub type MemoryClientRef = Arc<MemoryClient>;
|
||||
|
||||
pub struct MemoryClient {
|
||||
inner: TinyHumanMemoryClient,
|
||||
inner: TinyHumansMemoryClient,
|
||||
}
|
||||
|
||||
impl MemoryClient {
|
||||
@@ -38,7 +39,7 @@ impl MemoryClient {
|
||||
TinyHumanConfig::new(jwt_token)
|
||||
}
|
||||
};
|
||||
match TinyHumanMemoryClient::new(config) {
|
||||
match TinyHumansMemoryClient::new(config) {
|
||||
Ok(inner) => {
|
||||
log::info!("[memory] from_token: exit — client created successfully");
|
||||
Some(Self { inner })
|
||||
@@ -52,7 +53,9 @@ impl MemoryClient {
|
||||
|
||||
/// Store a skill data-sync result.
|
||||
///
|
||||
/// Namespace pattern: `skill:{skill_id}:{integration_id}`
|
||||
/// Inserts the document then polls `ingestion_job_status` every 30 s until
|
||||
/// the job reaches `completed` (or `failed`/`error`). Returns only after the
|
||||
/// ingestion job is confirmed complete.
|
||||
pub async fn store_skill_sync(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
@@ -66,15 +69,23 @@ impl MemoryClient {
|
||||
updated_at: Option<f64>,
|
||||
document_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let namespace = format!("skill:{skill_id}:{integration_id}");
|
||||
log::info!("[memory] store_skill_sync: entry (namespace={namespace}, title={title:?}, content_len={})", content.len());
|
||||
log::debug!(
|
||||
"[memory] store_skill_sync: payload → namespace={namespace} | title={title} | content={}",
|
||||
content
|
||||
let namespace = skill_id.to_string();
|
||||
log::info!(
|
||||
"[memory] store_skill_sync: entry (namespace={namespace}, title={title:?}, content_len={})",
|
||||
content.len()
|
||||
);
|
||||
log::info!("[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?})");
|
||||
let result = self.inner
|
||||
|
||||
let document_id_final = document_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
log::info!(
|
||||
"[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?}), content_len={}",
|
||||
content.len()
|
||||
);
|
||||
|
||||
let insert_resp = self
|
||||
.inner
|
||||
.insert_memory(InsertMemoryParams {
|
||||
document_id: Some(document_id_final),
|
||||
title: title.to_string(),
|
||||
content: content.to_string(),
|
||||
namespace: namespace.clone(),
|
||||
@@ -83,33 +94,97 @@ impl MemoryClient {
|
||||
priority,
|
||||
created_at,
|
||||
updated_at,
|
||||
document_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map(|_| {
|
||||
log::info!("[memory] insert_memory: success (namespace={namespace}, title={title:?})");
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::warn!("[memory] insert_memory: SDK error — kind={:?} msg={e}", classify_insert_error(&e));
|
||||
log::warn!(
|
||||
"[memory] insert_memory: SDK error — kind={:?} msg={e}",
|
||||
classify_insert_error(&e)
|
||||
);
|
||||
format!("Memory insert failed: {e}")
|
||||
});
|
||||
match &result {
|
||||
Ok(()) => log::info!("[memory] store_skill_sync: exit — ok (namespace={namespace})"),
|
||||
Err(e) => log::warn!("[memory] store_skill_sync: exit — error (namespace={namespace}): {e}"),
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"[memory] insert_memory: accepted (namespace={namespace}, status={:?}, job_id={:?})",
|
||||
insert_resp.data.status,
|
||||
insert_resp.data.job_id
|
||||
);
|
||||
|
||||
// If the API returned a job_id, poll until the job completes.
|
||||
if let Some(job_id) = insert_resp.data.job_id {
|
||||
log::info!("[memory] ingestion job queued (job_id={job_id}), polling every 30s...");
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
|
||||
match self.inner.ingestion_job_status(&job_id).await {
|
||||
Ok(status_resp) => {
|
||||
let state = status_resp
|
||||
.data
|
||||
.state
|
||||
.as_deref()
|
||||
.unwrap_or("unknown");
|
||||
|
||||
log::info!(
|
||||
"[memory] ingestion job status: job_id={job_id}, state={state}, \
|
||||
attempts={:?}, completed_at={:?}",
|
||||
status_resp.data.attempts,
|
||||
status_resp.data.completed_at
|
||||
);
|
||||
|
||||
match state {
|
||||
"completed" => {
|
||||
log::info!(
|
||||
"[memory] ingestion job completed (job_id={job_id}, namespace={namespace})"
|
||||
);
|
||||
break;
|
||||
}
|
||||
"failed" | "error" => {
|
||||
let err_msg = status_resp
|
||||
.data
|
||||
.error
|
||||
.unwrap_or_else(|| format!("job state={state}"));
|
||||
log::warn!(
|
||||
"[memory] ingestion job failed: job_id={job_id}, error={err_msg}"
|
||||
);
|
||||
log::warn!(
|
||||
"[memory] store_skill_sync: exit — ingestion failed (namespace={namespace})"
|
||||
);
|
||||
return Err(format!("Ingestion job failed: {err_msg}"));
|
||||
}
|
||||
_ => {
|
||||
// pending / processing / queued — keep waiting
|
||||
log::info!(
|
||||
"[memory] ingestion job still in progress (state={state}), waiting 30s..."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[memory] ingestion job status poll error (job_id={job_id}): {e} — retrying in 30s"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!("[memory] no job_id returned — insert assumed synchronous, proceeding");
|
||||
}
|
||||
result
|
||||
|
||||
log::info!("[memory] store_skill_sync: exit — ok (namespace={namespace})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query relevant context for a skill integration (RAG).
|
||||
pub async fn query_skill_context(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
integration_id: &str,
|
||||
_integration_id: &str,
|
||||
query: &str,
|
||||
max_chunks: u32,
|
||||
) -> Result<String, String> {
|
||||
let namespace = format!("skill:{skill_id}:{integration_id}");
|
||||
let namespace = skill_id.to_string();
|
||||
log::info!("[memory] query_skill_context: entry (namespace={namespace}, max_chunks={max_chunks}, query={query:?})");
|
||||
log::debug!(
|
||||
"[memory] query_skill_context: payload → namespace={namespace} | max_chunks={max_chunks} | query={query}"
|
||||
@@ -137,10 +212,10 @@ impl MemoryClient {
|
||||
pub async fn recall_skill_context(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
integration_id: &str,
|
||||
_integration_id: &str,
|
||||
max_chunks: u32,
|
||||
) -> Result<Option<String>, String> {
|
||||
let namespace = format!("skill:{skill_id}:{integration_id}");
|
||||
let namespace = skill_id.to_string();
|
||||
log::info!(
|
||||
"[memory] recall_skill_context: entry (namespace={namespace}, max_chunks={max_chunks})"
|
||||
);
|
||||
@@ -187,7 +262,7 @@ impl MemoryClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_insert_error(e: &tinyhumansai::TinyHumanError) -> &'static str {
|
||||
fn classify_insert_error(e: &tinyhumansai::TinyHumansError) -> &'static str {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("dns") || msg.contains("resolve") || msg.contains("lookup") {
|
||||
"dns_failure"
|
||||
@@ -253,6 +328,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -583,50 +583,7 @@ async fn handle_message(
|
||||
}).await;
|
||||
log::info!("[skill:{}] OAuth credential set and persisted to store", skill_id);
|
||||
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
let result = handle_js_call(rt, ctx, "onOAuthComplete", ¶ms_str).await;
|
||||
|
||||
// Fire-and-forget: persist published ops state to TinyHumans memory.
|
||||
// Skills publish data via state.set()/setPartial() into ops_state.data,
|
||||
// not as the return value of onOAuthComplete() (which is typically undefined).
|
||||
let state_snapshot = ops_state.read().data.clone();
|
||||
if !state_snapshot.is_empty() {
|
||||
if let Some(client) = memory_client_opt.clone() {
|
||||
let skill = skill_id.to_string();
|
||||
let integration_id = params
|
||||
.get("integrationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let content = serde_json::to_string_pretty(
|
||||
&serde_json::Value::Object(state_snapshot),
|
||||
)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let title = format!("{} OAuth sync — {}", skill, integration_id);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client
|
||||
.store_skill_sync(
|
||||
&skill,
|
||||
&integration_id,
|
||||
&title,
|
||||
&content,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("[memory] store_skill_sync failed: {e}");
|
||||
} else {
|
||||
log::info!("[memory] Stored sync for {}:{}", skill, integration_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
handle_js_call(rt, ctx, "onOAuthComplete", ¶ms_str).await
|
||||
}
|
||||
"skill/ping" => {
|
||||
handle_js_call(rt, ctx, "onPing", "{}").await
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"../skills/skills"
|
||||
"../skills/skills",
|
||||
"../ai"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
|
||||
+1
-1
@@ -8,11 +8,11 @@ import Conversations from './pages/Conversations';
|
||||
import Home from './pages/Home';
|
||||
import Intelligence from './pages/Intelligence';
|
||||
import Invites from './pages/Invites';
|
||||
import Skills from './pages/Skills';
|
||||
import Login from './pages/Login';
|
||||
import Mnemonic from './pages/Mnemonic';
|
||||
import Onboarding from './pages/onboarding/Onboarding';
|
||||
import Settings from './pages/Settings';
|
||||
import Skills from './pages/Skills';
|
||||
import Welcome from './pages/Welcome';
|
||||
import { selectHasEncryptionKey, selectIsOnboarded } from './store/authSelectors';
|
||||
import { useAppSelector } from './store/hooks';
|
||||
|
||||
+219
-48
@@ -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<string | null>(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<TeamUsage | null>(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<string, ThreadMessage[]> };
|
||||
};
|
||||
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<string, ThreadMessage[]> };
|
||||
};
|
||||
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<string | null>('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<never>((_, 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<string, ThreadMessage[]> };
|
||||
};
|
||||
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<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -1010,6 +1163,24 @@ const Conversations = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Tool call indicator — shown when Rust backend is executing a tool */}
|
||||
{activeToolCall && isSending && (
|
||||
<div className="flex items-center gap-2 px-1 py-1 text-xs text-stone-400">
|
||||
<span className="animate-pulse">Running tool: {activeToolCall.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Cancel button — shown when Rust backend is processing */}
|
||||
{isSending && rustChat && (
|
||||
<div className="flex justify-start px-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedThreadId) void chatCancel(selectedThreadId);
|
||||
}}
|
||||
className="text-xs text-stone-400 hover:text-stone-200 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+30
-60
@@ -1,69 +1,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import DownloadScreen from '../components/DownloadScreen';
|
||||
import OAuthLoginSection from '../components/oauth/OAuthLoginSection';
|
||||
import TypewriterGreeting from '../components/TypewriterGreeting';
|
||||
|
||||
import { consumeLoginToken } from '../services/api/authApi';
|
||||
import { setToken } from '../store/authSlice';
|
||||
import { useAppDispatch } from '../store/hooks';
|
||||
import { syncMemoryClientToken } from '../utils/tauriCommands';
|
||||
interface LoginProps {
|
||||
isWeb?: boolean;
|
||||
}
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const dispatch = useAppDispatch();
|
||||
const [consumeError, setConsumeError] = useState<string | null>(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 (
|
||||
<div className="min-h-full relative flex items-center justify-center">
|
||||
<div className="relative z-10 max-w-md w-full mx-4 text-center">
|
||||
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
|
||||
<p className="opacity-90 text-coral mb-4">{consumeError}</p>
|
||||
<p className="text-sm opacity-70">
|
||||
Please try logging in again with your preferred method.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Login = ({ isWeb }: LoginProps) => {
|
||||
const greetings = ['Hello HAL9000! 👋', "Let's cook! 🔥", 'The A-Team is here! 👊'];
|
||||
|
||||
return (
|
||||
<div className="min-h-full relative flex items-center justify-center">
|
||||
<div className="relative z-10 max-w-md w-full mx-4 text-center">
|
||||
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p className="opacity-70">Completing login...</p>
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 max-w-md w-full mx-4 space-y-6">
|
||||
{/* Welcome card */}
|
||||
<div className="glass rounded-3xl p-8 text-center animate-fade-up shadow-large">
|
||||
{/* Greeting */}
|
||||
<TypewriterGreeting greetings={greetings} />
|
||||
|
||||
<p className="opacity-70 mb-8 leading-relaxed">
|
||||
Welcome to AlphaHuman. Your Telegram assistant here to get you 10x more done in your
|
||||
journey.
|
||||
</p>
|
||||
|
||||
<p className="opacity-70 leading-relaxed">Are you ready for this?</p>
|
||||
|
||||
{/* Show OAuth login options in Tauri app, download screen on web */}
|
||||
{!isWeb && (
|
||||
<div className="mt-6">
|
||||
<OAuthLoginSection />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isWeb && <DownloadScreen />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,9 +14,9 @@ import SkillSetupModal from '../components/skills/SkillSetupModal';
|
||||
import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks';
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
|
||||
import { deriveSkillSyncUiState } from './skillsSyncUi';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { deriveSkillSyncUiState } from './skillsSyncUi';
|
||||
|
||||
/** Format large numbers: 1200 → "1.2K", 1200000 → "1.2M" */
|
||||
function formatNumber(n: number): string {
|
||||
@@ -53,7 +53,10 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
|
||||
| (SkillHostConnectionState & Record<string, unknown>)
|
||||
| undefined;
|
||||
const [manualSyncing, setManualSyncing] = useState(false);
|
||||
const syncUi = useMemo(() => deriveSkillSyncUiState(skill.id, skillState), [skill.id, skillState]);
|
||||
const syncUi = useMemo(
|
||||
() => deriveSkillSyncUiState(skill.id, skillState),
|
||||
[skill.id, skillState]
|
||||
);
|
||||
const isSyncing = manualSyncing || syncUi.isSyncing;
|
||||
|
||||
const handleSync = async (e: React.MouseEvent) => {
|
||||
@@ -212,10 +215,11 @@ export default function Skills() {
|
||||
}
|
||||
|
||||
const manifests = await invoke<Array<Record<string, unknown>>>('runtime_discover_skills');
|
||||
const ALLOWED_SKILLS = new Set(['gmail', 'notion']);
|
||||
const validManifests = manifests.filter(m => {
|
||||
const id = m.id as string;
|
||||
if (id.includes('_')) return false;
|
||||
return true;
|
||||
return ALLOWED_SKILLS.has(id);
|
||||
});
|
||||
|
||||
const processed: SkillListEntry[] = validManifests
|
||||
@@ -300,11 +304,7 @@ export default function Skills() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sortedSkillsList.map(skill => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onSetup={() => openSkillSetup(skill)}
|
||||
/>
|
||||
<SkillCard key={skill.id} skill={skill} onSetup={() => openSkillSetup(skill)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -326,7 +326,6 @@ export default function Skills() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<ChatToolCallEvent>('chat:tool_call', e => cb(e.payload)));
|
||||
}
|
||||
if (listeners.onToolResult) {
|
||||
const cb = listeners.onToolResult;
|
||||
unlisteners.push(await listen<ChatToolResultEvent>('chat:tool_result', e => cb(e.payload)));
|
||||
}
|
||||
if (listeners.onDone) {
|
||||
const cb = listeners.onDone;
|
||||
unlisteners.push(await listen<ChatDoneEvent>('chat:done', e => cb(e.payload)));
|
||||
}
|
||||
if (listeners.onError) {
|
||||
const cb = listeners.onError;
|
||||
unlisteners.push(await listen<ChatErrorEvent>('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<void> {
|
||||
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<boolean> {
|
||||
return await invoke<boolean>('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();
|
||||
}
|
||||
@@ -319,6 +319,11 @@
|
||||
resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz"
|
||||
integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==
|
||||
|
||||
"@dimforge/rapier3d-compat@~0.12.0":
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389"
|
||||
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
|
||||
|
||||
"@esbuild/aix-ppc64@0.27.2":
|
||||
version "0.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c"
|
||||
@@ -1294,7 +1299,7 @@
|
||||
dependencies:
|
||||
postcss-selector-parser "6.0.10"
|
||||
|
||||
"@tauri-apps/api@2.10.1":
|
||||
"@tauri-apps/api@^2.10.0":
|
||||
version "2.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.10.1.tgz#57c1bae6114ec33d977eb2b50dfefc25fa84fc93"
|
||||
integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==
|
||||
@@ -1359,9 +1364,9 @@
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz#d58c9f8af835b7e4fc30e201e979342c70bea426"
|
||||
integrity sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==
|
||||
|
||||
"@tauri-apps/cli@^2":
|
||||
"@tauri-apps/cli@2.9.6":
|
||||
version "2.9.6"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.9.6.tgz#f15ae8e03bf48308055c15ab25b439bed9906bc9"
|
||||
integrity sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==
|
||||
optionalDependencies:
|
||||
"@tauri-apps/cli-darwin-arm64" "2.9.6"
|
||||
@@ -1461,6 +1466,11 @@
|
||||
minimatch "^9.0.0"
|
||||
parse-imports-exports "^0.2.4"
|
||||
|
||||
"@tweenjs/tween.js@~23.1.3":
|
||||
version "23.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d"
|
||||
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
|
||||
|
||||
"@types/aria-query@^5.0.1":
|
||||
version "5.0.4"
|
||||
resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz"
|
||||
@@ -1661,11 +1671,29 @@
|
||||
resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz"
|
||||
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
|
||||
|
||||
"@types/stats.js@*":
|
||||
version "0.17.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e"
|
||||
integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==
|
||||
|
||||
"@types/statuses@^2.0.6":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz"
|
||||
integrity sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==
|
||||
|
||||
"@types/three@^0.183.1":
|
||||
version "0.183.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/three/-/three-0.183.1.tgz#d812d028b38ad68843725e3e7bd3268607cef150"
|
||||
integrity sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==
|
||||
dependencies:
|
||||
"@dimforge/rapier3d-compat" "~0.12.0"
|
||||
"@tweenjs/tween.js" "~23.1.3"
|
||||
"@types/stats.js" "*"
|
||||
"@types/webxr" ">=0.5.17"
|
||||
"@webgpu/types" "*"
|
||||
fflate "~0.8.2"
|
||||
meshoptimizer "~1.0.1"
|
||||
|
||||
"@types/unist@*", "@types/unist@^3.0.0":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz"
|
||||
@@ -1681,6 +1709,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz"
|
||||
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
|
||||
|
||||
"@types/webxr@>=0.5.17":
|
||||
version "0.5.24"
|
||||
resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44"
|
||||
integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==
|
||||
|
||||
"@types/which@^2.0.1":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz"
|
||||
@@ -2109,6 +2142,11 @@
|
||||
dependencies:
|
||||
"@wdio/logger" "9.18.0"
|
||||
|
||||
"@webgpu/types@*":
|
||||
version "0.1.69"
|
||||
resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.69.tgz#6b849bf370a1f29c78bd3aeba8e84c1150b237f2"
|
||||
integrity sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==
|
||||
|
||||
"@xmldom/xmldom@^0.9.5":
|
||||
version "0.9.8"
|
||||
resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz"
|
||||
@@ -4127,6 +4165,11 @@ fdir@^6.5.0:
|
||||
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
|
||||
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
|
||||
|
||||
fflate@~0.8.2:
|
||||
version "0.8.2"
|
||||
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea"
|
||||
integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==
|
||||
|
||||
figures@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz"
|
||||
@@ -5594,6 +5637,11 @@ merge2@^1.3.0:
|
||||
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
|
||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||
|
||||
meshoptimizer@~1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.0.1.tgz#c3ef0d509a8b84ac562493dba5a108fd67fa76dc"
|
||||
integrity sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==
|
||||
|
||||
micromark-core-commonmark@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz"
|
||||
@@ -7500,7 +7548,6 @@ stringify-entities@^4.0.0:
|
||||
character-entities-legacy "^3.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
name strip-ansi-cjs
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -7689,6 +7736,11 @@ thenify-all@^1.0.0:
|
||||
dependencies:
|
||||
any-promise "^1.0.0"
|
||||
|
||||
three@^0.183.2:
|
||||
version "0.183.2"
|
||||
resolved "https://registry.yarnpkg.com/three/-/three-0.183.2.tgz#606e3195bf210ef8d1eaaca2ab8c59d92d2bbc18"
|
||||
integrity sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==
|
||||
|
||||
timers-browserify@^2.0.4:
|
||||
version "2.0.12"
|
||||
resolved "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz"
|
||||
@@ -8346,7 +8398,6 @@ workerpool@^6.5.1:
|
||||
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
name wrap-ansi-cjs
|
||||
version "7.0.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
|
||||
Reference in New Issue
Block a user