mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Fix/monday patches (#7)
* Update Welcome page to integrate OAuth provider and adjust branding - Added OAuthProviderButton to the Welcome component for Google authentication. - Updated the branding from AlphaHuman to OpenHuman in the FeaturesStep component. - Removed unnecessary Lottie animation from the Onboarding page for a cleaner layout. - Changed the backend URL to point to the new API endpoint for TinyHumans. * Refactor routing and sidebar components for improved clarity - Commented out the Mnemonic route and related logic in AppRoutes for future consideration. - Updated MiniSidebar to comment out the Invite Friends section. - Removed unused navigation logic and upgrade call-to-action from the Home page. - Adjusted Onboarding navigation to redirect to Home instead of Mnemonic. * Comment out unused settings menu items for future consideration in SettingsHome component * Add core process and RPC functionality for Alphahuman - Introduced new binaries: `alphahuman-core` and `alphahuman-cli` for core process management and command-line interaction. - Implemented `CoreProcessHandle` for managing the lifecycle of the core process. - Added `core_rpc` module for handling RPC requests and responses. - Created `core_server` module to manage core server logic and routing. - Updated `alphahuman` commands to utilize the new RPC structure for health checks, security policies, and configuration management. - Refactored existing code to streamline interactions with the core process and improve overall architecture. * Update dependencies and enhance CLI functionality - Added `clap` for command-line argument parsing in the `alphahuman-cli`. - Updated `Cargo.lock` and `Cargo.toml` to include `clap` and its features. - Refactored `alphahuman-cli` to utilize structured command handling with subcommands. - Improved core process management and logging in `core_process.rs`. - Enhanced routing and error handling in `core_server.rs` with new root and not found handlers. - Updated various commands in `alphahuman.rs` to ensure core process is running before executing RPC calls. * Enhance service management and CLI functionality - Added new functions for daemon program arguments and command line construction in the service module. - Updated macOS and Linux installation functions to dynamically generate program arguments and command lines. - Introduced a new `Reinstall` command in the CLI for easier service management. - Refactored service command handling to utilize local service functions for improved clarity and maintainability. * Refactor daemon references to agent in components and hooks - Updated terminology from "Daemon" to "Agent" in MiniSidebar, DaemonHealthPanel, and TauriCommandsPanel for consistency. - Modified useDaemonHealth hook to probe agent status and handle agent lifecycle management. - Introduced new agent server status interface and function in Tauri commands for improved status checking. - Adjusted related comments and error handling to reflect the changes in terminology. * Refactor Home component and enhance macOS service management - Updated Home component to navigate to in-app conversations instead of opening a Telegram bot link. - Improved macOS service management by implementing modern lifecycle commands and adding compatibility fallbacks for service control. - Introduced new utility functions for handling macOS GUI domain and service targets. * Refactor Home component to enhance navigation - Removed SkillsGrid component and replaced it with a button that navigates to the Skills page. - Improved layout with additional margin for better spacing in the Home component. * Refactor SkillProvider and enhance memory bridge functionality - Removed Gmail and Notion state synchronization functions from SkillProvider to streamline the component. - Introduced a new memory bridge in the QuickJS library for skills to send memory payloads to the backend. - Updated SkillContext to include an app handle for better integration with memory operations. - Enhanced state management in the Rust backend to support memory insertion from skills. * Enhance memory insertion functionality and update QuickJS integration - Added support for additional parameters in the `store_skill_sync` method, including `source_type`, `metadata`, `priority`, `created_at`, `updated_at`, and `document_id`. - Refactored the `memory_insert` operation to accept a structured input, improving validation and error handling for title and content. - Updated the JavaScript memory bridge to simplify the insertion process by removing the provider parameter and directly using metadata. - Enhanced logging for successful memory insertions to improve traceability. * Update subproject reference in skills module * Implement skill synchronization UI enhancements - Introduced a new `skillsSyncUi.ts` module to manage synchronization UI state for skills. - Updated `Skills.tsx` to utilize the new synchronization state, providing visual feedback during sync operations. - Refactored sync handling in `SkillCard` to improve user experience with progress indicators and messages. - Added unit tests for the synchronization UI state logic to ensure correct behavior across various scenarios.
This commit is contained in:
+1
-1
Submodule skills updated: c389a2be6d...ff2534fc4d
@@ -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<SourceType>,
|
||||
metadata: Option<serde_json::Value>,
|
||||
priority: Option<Priority>,
|
||||
created_at: Option<f64>,
|
||||
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());
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<String>,
|
||||
metadata: Option<serde_json::Value>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<f64>,
|
||||
updated_at: Option<f64>,
|
||||
document_id: Option<String>,
|
||||
}
|
||||
|
||||
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::<crate::commands::memory::MemoryState>()
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -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<TimerState>) -> (Vec<u32>, Option<Durati
|
||||
pub struct SkillContext {
|
||||
pub skill_id: String,
|
||||
pub data_dir: PathBuf,
|
||||
pub app_handle: Option<AppHandle>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+29
-10
@@ -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<string, unknown>)
|
||||
| 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 && (
|
||||
<p className="text-[11px] text-stone-500 truncate mt-0.5">{subtitleParts.join(' · ')}</p>
|
||||
)}
|
||||
{isSyncing && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-800">
|
||||
{syncUi.progressPercent != null ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-primary-400 transition-all duration-300"
|
||||
style={{ width: `${syncUi.progressPercent}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-1/2 rounded-full bg-primary-400/80 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{syncUi.progressMessage && (
|
||||
<p className="text-[11px] text-primary-300 truncate mt-1">{syncUi.progressMessage}</p>
|
||||
)}
|
||||
{syncUi.metricsText && (
|
||||
<p className="text-[11px] text-stone-500 truncate mt-0.5">{syncUi.metricsText}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -106,12 +128,12 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
|
||||
<>
|
||||
{/* Sync */}
|
||||
<button
|
||||
onClick={syncing ? undefined : handleSync}
|
||||
disabled={syncing}
|
||||
onClick={isSyncing ? undefined : handleSync}
|
||||
disabled={isSyncing}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-stone-400 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-40"
|
||||
title="Sync">
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`}
|
||||
className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
@@ -282,9 +304,6 @@ export default function Skills() {
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onSetup={() => openSkillSetup(skill)}
|
||||
onSync={() => {
|
||||
skillManager.triggerSync(skill.id).catch(console.error);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> | undefined,
|
||||
dispatch: ReturnType<typeof useAppDispatch>
|
||||
): 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<string, unknown> | undefined,
|
||||
dispatch: ReturnType<typeof useAppDispatch>
|
||||
): 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<string, unknown> | 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<string, unknown> | 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<string, unknown> } | 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;
|
||||
|
||||
Reference in New Issue
Block a user