diff --git a/skills b/skills index c389a2be6..ff2534fc4 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit c389a2be6da0dbfbb9d61f56acfcb358384a9a4e +Subproject commit ff2534fc4dae2589f9c0d5de892e0c3c30d3d2bf diff --git a/src-tauri/src/memory/mod.rs b/src-tauri/src/memory/mod.rs index f5084fcd5..106588100 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, RecallMemoryParams, - TinyHumanConfig, TinyHumanMemoryClient, + DeleteMemoryParams, InsertMemoryParams, Priority, QueryMemoryParams, RecallMemoryParams, + SourceType, TinyHumanConfig, TinyHumanMemoryClient, }; /// Shared, cloneable handle to the memory client. @@ -59,6 +59,12 @@ impl MemoryClient { integration_id: &str, title: &str, content: &str, + source_type: Option, + metadata: Option, + priority: Option, + created_at: Option, + updated_at: Option, + document_id: Option, ) -> Result<(), String> { let namespace = format!("skill:{skill_id}:{integration_id}"); log::info!("[memory] store_skill_sync: entry (namespace={namespace}, title={title:?}, content_len={})", content.len()); @@ -72,6 +78,12 @@ impl MemoryClient { title: title.to_string(), content: content.to_string(), namespace: namespace.clone(), + source_type, + metadata, + priority, + created_at, + updated_at, + document_id, ..Default::default() }) .await @@ -236,6 +248,11 @@ mod tests { integration_id, "Gmail OAuth sync โ€” test@alphahuman.dev", &serde_json::to_string_pretty(&dummy_content).unwrap(), + None, + None, + None, + None, + None, ) .await; diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index aa43971b4..ae71c264d 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -178,6 +178,7 @@ impl QjsSkillInstance { let skill_context = qjs_ops::SkillContext { skill_id: skill_id.clone(), data_dir: data_dir.clone(), + app_handle: _deps.app_handle.clone(), }; if let Err(e) = qjs_ops::register_ops( @@ -603,7 +604,18 @@ async fn handle_message( 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) + .store_skill_sync( + &skill, + &integration_id, + &title, + &content, + None, + None, + None, + None, + None, + None, + ) .await { log::warn!("[memory] store_skill_sync failed: {e}"); @@ -639,7 +651,18 @@ async fn handle_message( let title = format!("{} periodic sync", skill); tokio::spawn(async move { if let Err(e) = client - .store_skill_sync(&skill, "default", &title, &content) + .store_skill_sync( + &skill, + "default", + &title, + &content, + None, + None, + None, + None, + None, + None, + ) .await { log::warn!("[memory] store_skill_sync failed: {e}"); diff --git a/src-tauri/src/services/quickjs_libs/bootstrap.js b/src-tauri/src/services/quickjs_libs/bootstrap.js index 839abca3a..64e56b915 100644 --- a/src-tauri/src/services/quickjs_libs/bootstrap.js +++ b/src-tauri/src/services/quickjs_libs/bootstrap.js @@ -741,6 +741,26 @@ globalThis.platform = { }, }; +// ============================================================================ +// Memory Bridge (for skills to send memory payloads to backend) +// ============================================================================ +globalThis.memory = { + /** + * Insert a memory payload through the native memory bridge. + * Provider is inferred from the current skill ID on the Rust side. + * @param {object} metadata - Memory payload metadata. + * @returns {boolean} + */ + insert: function (metadata) { + if (!metadata || typeof metadata !== 'object') { + throw new Error('memory.insert requires an object payload'); + } + + __ops.memory_insert(JSON.stringify(metadata)); + return true; + }, +}; + // ============================================================================ // State Bridge API (for skills to publish state) // ============================================================================ diff --git a/src-tauri/src/services/quickjs_libs/qjs_ops/ops_state.rs b/src-tauri/src/services/quickjs_libs/qjs_ops/ops_state.rs index ddf1b892a..1b532abcc 100644 --- a/src-tauri/src/services/quickjs_libs/qjs_ops/ops_state.rs +++ b/src-tauri/src/services/quickjs_libs/qjs_ops/ops_state.rs @@ -2,10 +2,26 @@ use parking_lot::RwLock; use rquickjs::{Ctx, Function, Object}; +use serde::Deserialize; use std::sync::Arc; +use tauri::Manager; +use tinyhumansai::{Priority, SourceType}; use super::types::{js_err, SkillContext, SkillState}; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsMemoryInsertInput { + title: String, + content: String, + source_type: Option, + metadata: Option, + priority: Option, + created_at: Option, + updated_at: Option, + document_id: Option, +} + pub fn register<'js>( ctx: &Ctx<'js>, ops: &Object<'js>, @@ -72,7 +88,7 @@ pub fn register<'js>( } { - let sc = skill_context; + let sc = skill_context.clone(); ops.set("data_write", Function::new(ctx.clone(), move |filename: String, content: String| -> rquickjs::Result<()> { let path = sc.data_dir.join(&filename); @@ -81,5 +97,87 @@ pub fn register<'js>( ))?; } + // ======================================================================== + // Memory Bridge (1) + // ======================================================================== + + { + let sc = skill_context; + ops.set("memory_insert", Function::new(ctx.clone(), + move |metadata_json: String| -> rquickjs::Result<()> { + let input: JsMemoryInsertInput = + serde_json::from_str(&metadata_json).map_err(|e| js_err(e.to_string()))?; + if input.title.trim().is_empty() { + return Err(js_err("memory.insert requires a non-empty title")); + } + if input.content.trim().is_empty() { + return Err(js_err("memory.insert requires non-empty content")); + } + + let app_handle = sc + .app_handle + .clone() + .ok_or_else(|| js_err("App handle not available for memory insert"))?; + + let memory_state = app_handle + .try_state::() + .ok_or_else(|| js_err("Memory state not available"))?; + + let client_opt = memory_state + .0 + .lock() + .map_err(|_| js_err("Failed to lock memory state"))? + .clone(); + + let client = client_opt.ok_or_else(|| js_err("Memory client is not initialized"))?; + let skill_id = sc.skill_id.clone(); + let integration_id = sc.skill_id.clone(); + let source_type = match input.source_type.as_deref() { + Some("doc") => Some(SourceType::Doc), + Some("chat") => Some(SourceType::Chat), + Some("email") => Some(SourceType::Email), + Some(_) => return Err(js_err("sourceType must be one of: doc, chat, email")), + None => None, + }; + let priority = match input.priority.as_deref() { + Some("high") => Some(Priority::High), + Some("medium") => Some(Priority::Medium), + Some("low") => Some(Priority::Low), + Some(_) => return Err(js_err("priority must be one of: high, medium, low")), + None => None, + }; + let metadata = input.metadata.unwrap_or_else(|| serde_json::json!({})); + + tokio::spawn(async move { + if let Err(e) = client + .store_skill_sync( + &skill_id, + &integration_id, + &input.title, + &input.content, + source_type, + Some(metadata), + priority, + input.created_at, + input.updated_at, + input.document_id, + ) + .await + { + log::warn!("[quickjs] memory_insert failed for '{}': {}", integration_id, e); + } else { + log::info!( + "[quickjs] memory_insert stored '{}': title='{}'", + integration_id, + input.title + ); + } + }); + + Ok(()) + }, + ))?; + } + Ok(()) } diff --git a/src-tauri/src/services/quickjs_libs/qjs_ops/types.rs b/src-tauri/src/services/quickjs_libs/qjs_ops/types.rs index 3113f53f1..b82cc8b35 100644 --- a/src-tauri/src/services/quickjs_libs/qjs_ops/types.rs +++ b/src-tauri/src/services/quickjs_libs/qjs_ops/types.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::time::{Duration, Instant}; +use tauri::AppHandle; // ============================================================================ // Timer State @@ -76,6 +77,7 @@ pub fn poll_timers(timer_state: &RwLock) -> (Vec, Option, } // ============================================================================ diff --git a/src/pages/Skills.tsx b/src/pages/Skills.tsx index a61127e8a..ae8da3a73 100644 --- a/src/pages/Skills.tsx +++ b/src/pages/Skills.tsx @@ -14,6 +14,7 @@ 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'; @@ -43,7 +44,6 @@ function statusDotClass(status: SkillConnectionStatus): string { interface SkillCardProps { skill: SkillListEntry; onSetup: () => void; - onSync: () => void; } function SkillCard({ skill, onSetup }: SkillCardProps) { @@ -52,17 +52,19 @@ function SkillCard({ skill, onSetup }: SkillCardProps) { const skillState = useAppSelector(state => state.skills.skillStates[skill.id]) as | (SkillHostConnectionState & Record) | undefined; - const [syncing, setSyncing] = useState(false); + const [manualSyncing, setManualSyncing] = useState(false); + const syncUi = useMemo(() => deriveSkillSyncUiState(skill.id, skillState), [skill.id, skillState]); + const isSyncing = manualSyncing || syncUi.isSyncing; const handleSync = async (e: React.MouseEvent) => { e.stopPropagation(); - setSyncing(true); + setManualSyncing(true); try { await skillManager.triggerSync(skill.id); } catch (err) { console.error(`Sync failed for ${skill.id}:`, err); } finally { - setSyncing(false); + setManualSyncing(false); } }; @@ -98,6 +100,26 @@ function SkillCard({ skill, onSetup }: SkillCardProps) { {subtitleParts.length > 0 && (

{subtitleParts.join(' ยท ')}

)} + {isSyncing && ( +
+
+ {syncUi.progressPercent != null ? ( +
+ ) : ( +
+ )} +
+ {syncUi.progressMessage && ( +

{syncUi.progressMessage}

+ )} + {syncUi.metricsText && ( +

{syncUi.metricsText}

+ )} +
+ )}
{/* Actions */} @@ -106,12 +128,12 @@ function SkillCard({ skill, onSetup }: SkillCardProps) { <> {/* Sync */}
diff --git a/src/pages/__tests__/skillsSyncUi.test.ts b/src/pages/__tests__/skillsSyncUi.test.ts new file mode 100644 index 000000000..70260f53b --- /dev/null +++ b/src/pages/__tests__/skillsSyncUi.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveSkillSyncUiState } from '../skillsSyncUi'; + +describe('deriveSkillSyncUiState', () => { + it('uses explicit progress and message for gmail', () => { + const result = deriveSkillSyncUiState('gmail', { + syncInProgress: true, + syncProgress: 42, + syncProgressMessage: 'Fetching page 2...', + totalEmails: 120, + newEmailsCount: 5, + }); + + expect(result.isSyncing).toBe(true); + expect(result.progressPercent).toBe(42); + expect(result.progressMessage).toBe('Fetching page 2...'); + expect(result.metricsText).toContain('120 emails'); + expect(result.metricsText).toContain('5 new emails'); + }); + + it('falls back to indeterminate mode and default message when no numeric progress', () => { + const result = deriveSkillSyncUiState('notion', { + syncInProgress: true, + totalPages: 34, + pagesWithSummary: 12, + summariesPending: 4, + }); + + expect(result.isSyncing).toBe(true); + expect(result.progressPercent).toBeNull(); + expect(result.progressMessage).toBe('Syncing Notion documents...'); + expect(result.metricsText).toContain('34 pages'); + expect(result.metricsText).toContain('12 pages summarized'); + }); + + it('returns no sync UI when sync is idle', () => { + const result = deriveSkillSyncUiState('google-drive', { + syncInProgress: false, + syncProgress: 80, + syncProgressMessage: 'Syncing', + totalDocuments: 11, + }); + + expect(result.isSyncing).toBe(false); + expect(result.progressPercent).toBeNull(); + expect(result.progressMessage).toBeNull(); + expect(result.metricsText).toBeNull(); + }); + + it('clamps out-of-range progress values', () => { + const high = deriveSkillSyncUiState('gmail', { syncInProgress: true, syncProgress: 150 }); + const low = deriveSkillSyncUiState('gmail', { syncInProgress: true, syncProgress: -20 }); + + expect(high.progressPercent).toBe(100); + expect(low.progressPercent).toBe(0); + }); +}); diff --git a/src/pages/skillsSyncUi.ts b/src/pages/skillsSyncUi.ts new file mode 100644 index 000000000..9d2eacf49 --- /dev/null +++ b/src/pages/skillsSyncUi.ts @@ -0,0 +1,118 @@ +import type { SkillHostConnectionState } from '../lib/skills/types'; + +export interface SkillSyncUiState { + isSyncing: boolean; + progressPercent: number | null; + progressMessage: string | null; + metricsText: string | null; +} + +type SkillStateRecord = SkillHostConnectionState & Record; + +function readNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function readBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + +function clampPercent(value: number): number { + if (value < 0) return 0; + if (value > 100) return 100; + return value; +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +function buildMetricsText(state: SkillStateRecord): string | null { + const values = { + newEmailsCount: readNumber(state.newEmailsCount), + totalEmails: readNumber(state.totalEmails), + totalDocuments: readNumber(state.totalDocuments), + totalPages: readNumber(state.totalPages), + pagesWithSummary: readNumber(state.pagesWithSummary), + summariesPending: readNumber(state.summariesPending), + totalFiles: readNumber(state.totalFiles), + itemsDone: readNumber(state.itemsDone), + itemsTotal: readNumber(state.itemsTotal), + }; + + const parts: string[] = []; + if (values.newEmailsCount != null) parts.push(`${formatNumber(values.newEmailsCount)} new emails`); + if (values.totalEmails != null) parts.push(`${formatNumber(values.totalEmails)} emails`); + if (values.totalDocuments != null) parts.push(`${formatNumber(values.totalDocuments)} docs`); + if (values.totalPages != null) parts.push(`${formatNumber(values.totalPages)} pages`); + if (values.pagesWithSummary != null) + parts.push(`${formatNumber(values.pagesWithSummary)} pages summarized`); + if (values.summariesPending != null) + parts.push(`${formatNumber(values.summariesPending)} summaries pending`); + if (values.totalFiles != null) parts.push(`${formatNumber(values.totalFiles)} files`); + if (values.itemsDone != null && values.itemsTotal != null && values.itemsTotal > 0) { + parts.push(`${formatNumber(values.itemsDone)}/${formatNumber(values.itemsTotal)} items`); + } + + if (parts.length === 0) return null; + return parts.slice(0, 3).join(' ยท '); +} + +function defaultProgressMessage(skillId: string): string { + if (skillId === 'gmail') return 'Syncing emails...'; + if (skillId === 'google-drive') return 'Syncing documents...'; + if (skillId === 'notion') return 'Syncing Notion documents...'; + return 'Syncing...'; +} + +export function deriveSkillSyncUiState( + skillId: string, + skillState: SkillStateRecord | undefined +): SkillSyncUiState { + if (!skillState) { + return { + isSyncing: false, + progressPercent: null, + progressMessage: null, + metricsText: null, + }; + } + + const isSyncing = readBoolean(skillState.syncInProgress) === true; + + const explicitProgress = + readNumber(skillState.syncProgress) ?? + readNumber(skillState.progressPercent) ?? + readNumber(skillState.progress); + + const itemDone = readNumber(skillState.itemsDone); + const itemTotal = readNumber(skillState.itemsTotal); + const ratioProgress = + explicitProgress == null && itemDone != null && itemTotal != null && itemTotal > 0 + ? (itemDone / itemTotal) * 100 + : null; + + const progressPercent = + explicitProgress != null + ? clampPercent(explicitProgress) + : ratioProgress != null + ? clampPercent(ratioProgress) + : null; + + const progressMessageRaw = + typeof skillState.syncProgressMessage === 'string' ? skillState.syncProgressMessage.trim() : ''; + + return { + isSyncing, + progressPercent: isSyncing ? progressPercent : null, + progressMessage: isSyncing ? progressMessageRaw || defaultProgressMessage(skillId) : null, + metricsText: isSyncing ? buildMetricsText(skillState) : null, + }; +} diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 3ba94c333..d0905853a 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -8,32 +8,10 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { type ReactNode, useEffect, useRef } from 'react'; -import { - type GmailStateForSync, - syncGmailMetadataToBackend, -} from '../lib/gmail/services/metadataSync'; -import { - type NotionStateForSync, - syncNotionMetadataToBackend, -} from '../lib/notion/services/metadataSync'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; -import { - GmailEmailBatch, - type GmailProfile, - setGmailEmails, - setGmailProfile, -} from '../store/gmailSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; -import { - type NotionPageSummary, - type NotionSummary, - type NotionUserProfile, - setNotionPages, - setNotionProfile, - setNotionSummaries, -} from '../store/notionSlice'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; @@ -87,74 +65,12 @@ function parseSkillStatePayload( return { skillId, state }; } -/** Sync profile and emails from gmail skill state into gmailSlice and send to backend via socket. */ -function syncGmailStateToSlice( - gmailState: Record | undefined, - dispatch: ReturnType -): void { - if (!gmailState || typeof gmailState !== 'object') return; - dispatch( - setGmailProfile( - gmailState.profile !== undefined && gmailState.profile != null - ? (gmailState.profile as GmailProfile) - : null - ) - ); - dispatch(setGmailEmails(gmailState.emails as GmailEmailBatch | null)); - - syncGmailMetadataToBackend(gmailState as GmailStateForSync); -} - -/** Sync profile, pages, and summaries from notion skill state into notionSlice and backend metadata. */ -function syncNotionStateToSlice( - notionState: Record | undefined, - dispatch: ReturnType -): void { - if (!notionState || typeof notionState !== 'object') return; - const profile = - notionState.profile !== undefined && notionState.profile != null - ? (notionState.profile as NotionUserProfile) - : null; - const pages = Array.isArray(notionState.pages) ? (notionState.pages as NotionPageSummary[]) : []; - const summaries = Array.isArray(notionState.summaries) - ? (notionState.summaries as NotionSummary[]) - : []; - - // Update profile in notionSlice if present - dispatch(setNotionProfile(profile)); - - if (pages.length > 0) { - dispatch(setNotionPages(pages)); - } - if (summaries.length > 0) { - dispatch(setNotionSummaries(summaries)); - } - - const stateForSync: NotionStateForSync = { profile, pages, summaries }; - syncNotionMetadataToBackend(stateForSync); -} - export default function SkillProvider({ children }: { children: ReactNode }) { const { token } = useAppSelector(state => state.auth); const skillsState = useAppSelector(state => state.skills.skills); - const skillStates = useAppSelector(state => state.skills.skillStates); const dispatch = useAppDispatch(); const initRef = useRef(false); - // Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration) - const gmailSkillState = skillStates?.gmail as Record | undefined; - useEffect(() => { - if (!gmailSkillState) return; - syncGmailStateToSlice(gmailSkillState, dispatch); - }, [gmailSkillState, dispatch]); - - // Keep notionSlice pages in sync with skills.skillStates.notion - const notionSkillState = skillStates?.notion as Record | undefined; - useEffect(() => { - if (!notionSkillState) return; - syncNotionStateToSlice(notionSkillState, dispatch); - }, [notionSkillState, dispatch]); - // Listen for skill state changes emitted from the Rust runtime event loop useEffect(() => { let unlisten: (() => void) | undefined; @@ -166,15 +82,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) { const { skillId, state: newState } = parsed; console.log('๐Ÿš€ ~ SkillProvider ~ newState:', skillId, newState); dispatch(setSkillState({ skillId, state: newState })); - - // Transfer Gmail skill state to gmail store (also synced by effect from skillStates.gmail) - if (skillId === 'gmail') { - syncGmailStateToSlice(newState, dispatch); - } - // Transfer Notion skill state to notion store (also synced by effect from skillStates.notion) - if (skillId === 'notion') { - syncNotionStateToSlice(newState, dispatch); - } }) .then(fn => { unlisten = fn; @@ -188,25 +95,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) { }; }, [dispatch]); - // Fallback: when gmail skill is ready, fetch state from backend (covers events missed before listener attached) - const gmailStatus = skillsState?.gmail?.status; - useEffect(() => { - if (gmailStatus !== 'ready') return; - const timeoutId = window.setTimeout(() => { - invoke<{ state?: Record } | null>('runtime_get_skill_state', { - skillId: 'gmail', - }) - .then(snapshot => { - if (snapshot?.state && typeof snapshot.state === 'object') { - dispatch(setSkillState({ skillId: 'gmail', state: snapshot.state })); - syncGmailStateToSlice(snapshot.state, dispatch); - } - }) - .catch(() => {}); - }, 800); - return () => window.clearTimeout(timeoutId); - }, [gmailStatus, dispatch]); - // Listen for skill runtime errors and surface them in the error notification useEffect(() => { let unlisten: (() => void) | undefined;