mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(skills): registry-based skill management + RPC state migration (#98)
* refactor(tauriCommands): streamline RPC method calls for skill management - Simplified the syntax of RPC method calls in the `tauriCommands.ts` file by removing unnecessary line breaks, enhancing code readability. - Updated descriptions in the `schemas.rs` file to maintain consistent formatting for RPC method descriptions, improving documentation clarity. * chore(release): bump OpenHuman version to 0.49.23 in Cargo.lock * feat(skills): add skill status management in desktopDeepLinkListener - Introduced a new action `setSkillStatus` to manage the skill's connection status. - Updated the OAuth deep link handling to dispatch the skill status as 'ready' upon successful connection, improving state management for skills. * feat(screen_intelligence): implement capture mode for screenshots - Added a `CaptureMode` enum to differentiate between windowed and fullscreen screenshot captures. - Enhanced the `capture_screen_image_ref_for_context` function to determine capture mode based on window bounds, with fallback to fullscreen. - Implemented logic to downscale screenshots exceeding size limits when captured in fullscreen mode. - Introduced a new `parse_foreground_output` function to parse application context from AppleScript output, improving context retrieval for screenshots. * chore(build): update Tauri configuration to remove updater artifacts creation - Modified the TAURI_CONFIG_OVERRIDE to exclude the creation of updater artifacts during the build process, streamlining the build workflow. * feat(accessibility): enhance accessibility state and add capture test functionality - Introduced new properties `captureTestResult` and `isCaptureTestRunning` to the `AccessibilityState` interface for managing capture test states. - Added `CaptureTestResult` and `CaptureTestContextInfo` interfaces to define the structure of capture test results. - Implemented `openhumanScreenIntelligenceCaptureTest` function to initiate screen intelligence capture tests, improving accessibility features. * feat(debug): add Screen Intelligence Debug Panel for enhanced diagnostics - Introduced a new `ScreenIntelligenceDebugPanel` component to display accessibility status, session information, and capture test results. - Integrated the debug panel into the existing `ScreenIntelligencePanel`, allowing users to expand and collapse the debug section. - Updated accessibility state management to include `captureTestResult` and `isCaptureTestRunning` for improved testing feedback. - Enhanced test setup to accommodate new debug functionalities. * feat(screen_intelligence): implement custom hook for screen intelligence items - Added `useScreenIntelligenceItems` hook to fetch and manage screen intelligence items from the accessibility state. - Integrated the hook into the `Intelligence` component, combining items from both memory and screen intelligence sources. - Enhanced loading state management to reflect the combined loading status of both data sources. * test(screen_intelligence): add unit tests for useScreenIntelligenceItems mapping and confidenceToPriority functions - Introduced tests for the mapping logic of AccessibilityVisionSummary to ActionableItem, ensuring correct transformation of properties. - Added tests for confidenceToPriority function to validate priority assignment based on confidence levels. - Included edge cases such as handling empty arrays, null app names, and long actionable notes truncation. * feat(mnemonic): update mnemonic handling to support variable word counts - Refactored mnemonic import logic to accommodate BIP39 phrase lengths (12, 15, 18, 21, 24 words). - Updated state initialization and validation to dynamically adjust based on the allowed word counts. - Enhanced user prompts to reflect the new word count options for recovery phrases. - Adjusted focus handling for input fields to improve user experience during phrase entry. * refactor(skills): migrate skill state management to RPC-based hooks - Replaced Redux-based skill state management with RPC-backed hooks for improved performance and reactivity. - Introduced `useSkillSnapshot` and `useAllSkillSnapshots` hooks to fetch skill states directly from the Rust core. - Updated components to utilize the new hooks, enhancing the overall architecture and reducing dependency on Redux. - Added event listeners to trigger state updates in response to skill state changes, ensuring real-time updates across the application. * fix(lint): merge duplicate tauriCommands import in accessibilitySlice test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(tests): remove eslint-disable comment from screen intelligence E2E test file --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
419ffb1731
commit
777c98ef7c
@@ -81,7 +81,7 @@ jobs:
|
||||
- name: Build Tauri app
|
||||
working-directory: app
|
||||
run: |
|
||||
TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":"never"},"plugins":{"updater":{"active":false}}}'
|
||||
TAURI_CONFIG_OVERRIDE='{"plugins":{"updater":{"active":false}}}'
|
||||
yarn tauri build -c "$TAURI_CONFIG_OVERRIDE"
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
@@ -418,49 +418,6 @@ jobs:
|
||||
MATRIX_TARGET: ${{ matrix.settings.target }}
|
||||
SIDECAR_BASE: ${{ steps.core-paths.outputs.sidecar_base }}
|
||||
|
||||
- name: Import Apple certificate for sidecar signing
|
||||
if: matrix.settings.platform == 'macos-latest'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH"
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
security import "$CERTIFICATE_PATH" \
|
||||
-P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||
-A -t cert -f pkcs12 \
|
||||
-k "$KEYCHAIN_PATH"
|
||||
|
||||
security set-key-partition-list \
|
||||
-S apple-tool:,apple: \
|
||||
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db
|
||||
|
||||
- name: Codesign sidecar binary with hardened runtime (macOS)
|
||||
if: matrix.settings.platform == 'macos-latest'
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
MATRIX_TARGET: ${{ matrix.settings.target }}
|
||||
SIDECAR_BASE: ${{ steps.core-paths.outputs.sidecar_base }}
|
||||
run: |
|
||||
SIDECAR_PATH="app/src-tauri/binaries/${SIDECAR_BASE}-${MATRIX_TARGET}"
|
||||
ENTITLEMENTS="app/src-tauri/entitlements.sidecar.plist"
|
||||
echo "Signing sidecar: $SIDECAR_PATH"
|
||||
echo "Using entitlements: $ENTITLEMENTS"
|
||||
codesign --force --options runtime --entitlements "$ENTITLEMENTS" --sign "$APPLE_SIGNING_IDENTITY" "$SIDECAR_PATH"
|
||||
codesign --verify --verbose "$SIDECAR_PATH"
|
||||
codesign --display --entitlements - "$SIDECAR_PATH"
|
||||
echo "Sidecar signed successfully"
|
||||
|
||||
- name: Resolve standalone core CLI artifact path
|
||||
id: cli-paths
|
||||
shell: bash
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "OpenHuman"
|
||||
version = "0.49.21"
|
||||
version = "0.49.23"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
fetchAccessibilityStatus,
|
||||
fetchAccessibilityVisionRecent,
|
||||
runCaptureTest,
|
||||
} from '../../store/accessibilitySlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
|
||||
const formatBytes = (bytes: number | null | undefined): string => {
|
||||
if (bytes == null) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const ScreenIntelligenceDebugPanel = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, captureTestResult, isCaptureTestRunning, recentVisionSummaries, lastError } =
|
||||
useAppSelector(state => state.accessibility);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchAccessibilityStatus());
|
||||
void dispatch(fetchAccessibilityVisionRecent(5));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleCaptureTest = useCallback(() => {
|
||||
void dispatch(runCaptureTest());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleRefreshStatus = useCallback(() => {
|
||||
void dispatch(fetchAccessibilityStatus());
|
||||
void dispatch(fetchAccessibilityVisionRecent(5));
|
||||
}, [dispatch]);
|
||||
|
||||
const permissions = status?.permissions;
|
||||
const session = status?.session;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-100">Debug & Diagnostics</h3>
|
||||
<button
|
||||
onClick={handleRefreshStatus}
|
||||
className="rounded-lg border border-stone-700 bg-stone-800/60 px-3 py-1 text-xs text-stone-300 transition-colors hover:bg-stone-700/60">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Permissions
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<PermissionDot label="Screen" value={permissions?.screen_recording} />
|
||||
<PermissionDot label="Accessibility" value={permissions?.accessibility} />
|
||||
<PermissionDot label="Input" value={permissions?.input_monitoring} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Status */}
|
||||
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">Session</h4>
|
||||
<div className="space-y-1 text-xs text-stone-300">
|
||||
<div className="flex justify-between">
|
||||
<span>Active</span>
|
||||
<span className={session?.active ? 'text-green-400' : 'text-stone-500'}>
|
||||
{session?.active ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Frames</span>
|
||||
<span>{session?.frames_in_memory ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Vision State</span>
|
||||
<span>{session?.vision_state ?? 'idle'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Vision Queue</span>
|
||||
<span>{session?.vision_queue_depth ?? 0}</span>
|
||||
</div>
|
||||
{session?.last_context && (
|
||||
<div className="flex justify-between">
|
||||
<span>Last App</span>
|
||||
<span className="max-w-[180px] truncate">{session.last_context}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capture Test */}
|
||||
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Capture Test
|
||||
</h4>
|
||||
<button
|
||||
onClick={handleCaptureTest}
|
||||
disabled={isCaptureTestRunning}
|
||||
className="mb-3 w-full rounded-lg border border-primary-600/40 bg-primary-600/20 px-3 py-2 text-sm font-medium text-primary-300 transition-colors hover:bg-primary-600/30 disabled:opacity-50">
|
||||
{isCaptureTestRunning ? 'Capturing...' : 'Test Capture'}
|
||||
</button>
|
||||
|
||||
{captureTestResult && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1 text-xs text-stone-300">
|
||||
<div className="flex justify-between">
|
||||
<span>Status</span>
|
||||
<span className={captureTestResult.ok ? 'text-green-400' : 'text-red-400'}>
|
||||
{captureTestResult.ok ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Mode</span>
|
||||
<span>{captureTestResult.capture_mode}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Time</span>
|
||||
<span>{captureTestResult.timing_ms}ms</span>
|
||||
</div>
|
||||
{captureTestResult.bytes_estimate != null && (
|
||||
<div className="flex justify-between">
|
||||
<span>Size</span>
|
||||
<span>{formatBytes(captureTestResult.bytes_estimate)}</span>
|
||||
</div>
|
||||
)}
|
||||
{captureTestResult.context && (
|
||||
<div className="flex justify-between">
|
||||
<span>App</span>
|
||||
<span className="max-w-[180px] truncate">
|
||||
{captureTestResult.context.app_name ?? 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{captureTestResult.context?.bounds_width != null && (
|
||||
<div className="flex justify-between">
|
||||
<span>Bounds</span>
|
||||
<span>
|
||||
{captureTestResult.context.bounds_width}x
|
||||
{captureTestResult.context.bounds_height} at (
|
||||
{captureTestResult.context.bounds_x},{captureTestResult.context.bounds_y})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{captureTestResult.error && (
|
||||
<div className="rounded-lg border border-red-800/50 bg-red-900/20 p-2 text-xs text-red-300">
|
||||
{captureTestResult.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{captureTestResult.image_ref && (
|
||||
<div className="overflow-hidden rounded-lg border border-stone-700">
|
||||
<img
|
||||
src={captureTestResult.image_ref}
|
||||
alt="Capture test result"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Vision Summaries */}
|
||||
{recentVisionSummaries.length > 0 && (
|
||||
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Recent Vision Summaries
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{recentVisionSummaries.map(summary => (
|
||||
<div
|
||||
key={summary.id}
|
||||
className="rounded-lg border border-stone-700/50 bg-stone-800/30 p-2 text-xs">
|
||||
<div className="flex justify-between text-stone-400">
|
||||
<span>{summary.app_name ?? 'Unknown'}</span>
|
||||
<span>
|
||||
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
|
||||
{(summary.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-stone-200">{summary.actionable_notes}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{lastError && (
|
||||
<div className="rounded-lg border border-red-800/50 bg-red-900/20 p-2 text-xs text-red-300">
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PermissionDot = ({ label, value }: { label: string; value?: string }) => {
|
||||
const color =
|
||||
value === 'granted' ? 'bg-green-500' : value === 'denied' ? 'bg-red-500' : 'bg-stone-600';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`h-2 w-2 rounded-full ${color}`} />
|
||||
<span className="text-stone-300">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenIntelligenceDebugPanel;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
|
||||
import {
|
||||
fetchAccessibilityStatus,
|
||||
fetchAccessibilityVisionRecent,
|
||||
@@ -399,6 +400,8 @@ const ScreenIntelligencePanel = () => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<DebugSection />
|
||||
|
||||
{!status?.platform_supported && (
|
||||
<div className="rounded-xl border border-amber-700/40 bg-amber-900/20 p-3 text-sm text-amber-200">
|
||||
Screen Intelligence V1 is currently supported on macOS only.
|
||||
@@ -415,4 +418,21 @@ const ScreenIntelligencePanel = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const DebugSection = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
className="flex w-full items-center justify-between text-sm font-semibold text-white">
|
||||
<span>Debug & Diagnostics</span>
|
||||
<span className="text-xs text-stone-400">{isOpen ? 'Collapse' : 'Expand'}</span>
|
||||
</button>
|
||||
{isOpen && <ScreenIntelligenceDebugPanel />}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenIntelligencePanel;
|
||||
|
||||
@@ -66,6 +66,8 @@ const createStore = () =>
|
||||
accessibility: {
|
||||
status,
|
||||
recentVisionSummaries: [],
|
||||
captureTestResult: null,
|
||||
isCaptureTestRunning: false,
|
||||
isLoading: false,
|
||||
isRequestingPermissions: false,
|
||||
isStartingSession: false,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAppSelector } from "../../store/hooks";
|
||||
import { useSkillSnapshot } from "../../lib/skills/hooks";
|
||||
import SkillSetupWizard from "./SkillSetupWizard";
|
||||
import SkillManagementPanel from "./SkillManagementPanel";
|
||||
|
||||
@@ -29,9 +29,8 @@ export default function SkillSetupModal({
|
||||
onClose,
|
||||
}: SkillSetupModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const setupComplete = useAppSelector(
|
||||
(state) => state.skills.skills[skillId]?.setupComplete,
|
||||
);
|
||||
const snap = useSkillSnapshot(skillId);
|
||||
const setupComplete = snap?.setup_complete ?? false;
|
||||
// Skills without setup hooks always go straight to manage mode.
|
||||
const [mode, setMode] = useState<"manage" | "setup">(
|
||||
!hasSetup || setupComplete ? "manage" : "setup",
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { store } from "../../store/index.ts";
|
||||
import { useAppSelector } from "../../store/hooks.ts";
|
||||
import { useSkillSnapshot } from "../../lib/skills/hooks.ts";
|
||||
import { skillManager } from "../../lib/skills/manager.ts";
|
||||
import { setSkillSetupComplete } from "../../store/skillsSlice.ts";
|
||||
import { setSetupComplete } from "../../lib/skills/skillsApi.ts";
|
||||
import { apiClient } from "../../services/apiClient.ts";
|
||||
import { openUrl } from "../../utils/openUrl.ts";
|
||||
import type { SetupStep, SetupFieldError } from "../../lib/skills/types.ts";
|
||||
@@ -43,11 +42,9 @@ export default function SkillSetupWizard({
|
||||
}: SkillSetupWizardProps) {
|
||||
const [state, setState] = useState<WizardState>({ phase: "loading" });
|
||||
|
||||
// Watch skill state for OAuth completion (skill pushes connection_status: "connected")
|
||||
const skillState = useAppSelector(
|
||||
(s) => s.skills.skillStates[skillId],
|
||||
);
|
||||
const isConnected = skillState?.connection_status === "connected";
|
||||
// Watch skill snapshot for OAuth completion via RPC-backed hook
|
||||
const snap = useSkillSnapshot(skillId);
|
||||
const isConnected = snap?.connection_status === "connected" || snap?.setup_complete === true;
|
||||
|
||||
// When skill state changes to connected during OAuth waiting, mark complete
|
||||
useEffect(() => {
|
||||
@@ -55,39 +52,44 @@ export default function SkillSetupWizard({
|
||||
(state.phase === "oauth" || state.phase === "oauth_waiting") &&
|
||||
isConnected
|
||||
) {
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
|
||||
setSetupComplete(skillId, true).catch(() => {});
|
||||
setState({ phase: "complete", message: "Successfully connected!" });
|
||||
}
|
||||
}, [isConnected, state.phase, skillId]);
|
||||
|
||||
// Start the skill (if not running) then start the setup flow on mount
|
||||
// Start the setup flow on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function initSetup() {
|
||||
try {
|
||||
console.log("[SkillSetupWizard] initSetup", skillId);
|
||||
const manifest = store.getState().skills.skills[skillId]?.manifest;
|
||||
console.log("[SkillSetupWizard] manifest", manifest);
|
||||
if (!manifest) {
|
||||
|
||||
// Find the available skill entry from the registry for OAuth config
|
||||
const { listAvailable } = await import("../../lib/skills/skillsApi.ts");
|
||||
const available = await listAvailable();
|
||||
const entry = available.find(e => e.id === skillId);
|
||||
|
||||
if (!entry) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
phase: "error",
|
||||
message: "Skill not found. Try refreshing the page.",
|
||||
message: "Skill not found in registry. Try refreshing the page.",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const setup = entry.setup as { required?: boolean; oauth?: OAuthConfig } | null | undefined;
|
||||
|
||||
// If the skill has OAuth config, show OAuth login directly
|
||||
// (no need to start the QuickJS runtime for OAuth initiation)
|
||||
if (manifest.setup?.oauth) {
|
||||
if (setup?.oauth) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
phase: "oauth",
|
||||
oauth: {
|
||||
provider: manifest.setup.oauth.provider,
|
||||
scopes: manifest.setup.oauth.scopes,
|
||||
provider: setup.oauth.provider,
|
||||
scopes: setup.oauth.scopes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -95,29 +97,21 @@ export default function SkillSetupWizard({
|
||||
}
|
||||
|
||||
// Non-OAuth skills need the runtime running for setup steps
|
||||
if (!skillManager.isSkillRunning(skillId)) {
|
||||
console.log("[SkillSetupWizard] starting skill", skillId);
|
||||
try {
|
||||
await skillManager.startSkill(manifest);
|
||||
console.log("[SkillSetupWizard] skill started", skillId);
|
||||
} catch (startErr) {
|
||||
console.warn("[SkillSetupWizard] runtime start failed, may not be available:", startErr);
|
||||
try {
|
||||
const { startSkill } = await import("../../lib/skills/skillsApi.ts");
|
||||
await startSkill(skillId);
|
||||
console.log("[SkillSetupWizard] skill started via RPC", skillId);
|
||||
} catch (startErr) {
|
||||
console.warn("[SkillSetupWizard] runtime start failed:", startErr);
|
||||
if (!cancelled) {
|
||||
const msg = startErr instanceof Error ? startErr.message : String(startErr);
|
||||
setState({ phase: "error", message: msg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!skillManager.isSkillRunning(skillId)) {
|
||||
console.log("[SkillSetupWizard] skill not running", skillId);
|
||||
const status = skillManager.getSkillStatus(skillId);
|
||||
console.log("[SkillSetupWizard] status", status);
|
||||
const errMsg =
|
||||
status === "error"
|
||||
? store.getState().skills.skills[skillId]?.error ?? "Skill failed to start"
|
||||
: "Skill failed to start. Check the console for errors.";
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
console.log("[SkillSetupWizard] starting setup", skillId);
|
||||
const firstStep = await skillManager.startSetup(skillId);
|
||||
console.log("[SkillSetupWizard] setup started", skillId, firstStep);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AccessibilityVisionSummary } from '../../utils/tauriCommands';
|
||||
|
||||
// Test the mapping logic directly (extracted from the hook for testability)
|
||||
function confidenceToPriority(confidence: number): 'critical' | 'important' | 'normal' {
|
||||
if (confidence > 0.9) return 'critical';
|
||||
if (confidence > 0.7) return 'important';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function mapSummaryToItem(summary: AccessibilityVisionSummary) {
|
||||
return {
|
||||
id: `si-${summary.id}`,
|
||||
title: summary.actionable_notes.slice(0, 120),
|
||||
description: [summary.ui_state, summary.key_text].filter(Boolean).join(' - '),
|
||||
source: 'ai_insight' as const,
|
||||
priority: confidenceToPriority(summary.confidence),
|
||||
status: 'active' as const,
|
||||
createdAt: new Date(summary.captured_at_ms),
|
||||
updatedAt: new Date(summary.captured_at_ms),
|
||||
actionable: true,
|
||||
sourceLabel: summary.app_name ?? 'Screen Intelligence',
|
||||
};
|
||||
}
|
||||
|
||||
const makeSummary = (
|
||||
overrides: Partial<AccessibilityVisionSummary> = {}
|
||||
): AccessibilityVisionSummary => ({
|
||||
id: 'vision-123',
|
||||
captured_at_ms: 1700000000000,
|
||||
app_name: 'Safari',
|
||||
window_title: 'GitHub',
|
||||
ui_state: 'editor open',
|
||||
key_text: 'fn main()',
|
||||
actionable_notes: 'Consider adding tests',
|
||||
confidence: 0.85,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useScreenIntelligenceItems mapping', () => {
|
||||
it('maps VisionSummary to ActionableItem correctly', () => {
|
||||
const summary = makeSummary();
|
||||
const item = mapSummaryToItem(summary);
|
||||
|
||||
expect(item.id).toBe('si-vision-123');
|
||||
expect(item.title).toBe('Consider adding tests');
|
||||
expect(item.description).toBe('editor open - fn main()');
|
||||
expect(item.source).toBe('ai_insight');
|
||||
expect(item.priority).toBe('important');
|
||||
expect(item.status).toBe('active');
|
||||
expect(item.sourceLabel).toBe('Safari');
|
||||
expect(item.actionable).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const items: AccessibilityVisionSummary[] = [];
|
||||
const mapped = items.map(mapSummaryToItem);
|
||||
expect(mapped).toEqual([]);
|
||||
});
|
||||
|
||||
it('derives critical priority from high confidence', () => {
|
||||
const item = mapSummaryToItem(makeSummary({ confidence: 0.95 }));
|
||||
expect(item.priority).toBe('critical');
|
||||
});
|
||||
|
||||
it('derives normal priority from low confidence', () => {
|
||||
const item = mapSummaryToItem(makeSummary({ confidence: 0.5 }));
|
||||
expect(item.priority).toBe('normal');
|
||||
});
|
||||
|
||||
it('derives important priority from medium confidence', () => {
|
||||
const item = mapSummaryToItem(makeSummary({ confidence: 0.8 }));
|
||||
expect(item.priority).toBe('important');
|
||||
});
|
||||
|
||||
it('uses Screen Intelligence as default sourceLabel when app_name is null', () => {
|
||||
const item = mapSummaryToItem(makeSummary({ app_name: null }));
|
||||
expect(item.sourceLabel).toBe('Screen Intelligence');
|
||||
});
|
||||
|
||||
it('filters empty strings from description parts', () => {
|
||||
const item = mapSummaryToItem(makeSummary({ ui_state: '', key_text: 'some text' }));
|
||||
expect(item.description).toBe('some text');
|
||||
});
|
||||
|
||||
it('truncates long actionable_notes in title', () => {
|
||||
const longNotes = 'A'.repeat(200);
|
||||
const item = mapSummaryToItem(makeSummary({ actionable_notes: longNotes }));
|
||||
expect(item.title.length).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidenceToPriority', () => {
|
||||
it('returns critical for > 0.9', () => {
|
||||
expect(confidenceToPriority(0.91)).toBe('critical');
|
||||
expect(confidenceToPriority(1.0)).toBe('critical');
|
||||
});
|
||||
|
||||
it('returns important for > 0.7 and <= 0.9', () => {
|
||||
expect(confidenceToPriority(0.71)).toBe('important');
|
||||
expect(confidenceToPriority(0.9)).toBe('important');
|
||||
});
|
||||
|
||||
it('returns normal for <= 0.7', () => {
|
||||
expect(confidenceToPriority(0.7)).toBe('normal');
|
||||
expect(confidenceToPriority(0.0)).toBe('normal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { fetchAccessibilityVisionRecent } from '../store/accessibilitySlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import type { ActionableItem, ActionableItemPriority } from '../types/intelligence';
|
||||
|
||||
function confidenceToPriority(confidence: number): ActionableItemPriority {
|
||||
if (confidence > 0.9) return 'critical';
|
||||
if (confidence > 0.7) return 'important';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
export function useScreenIntelligenceItems() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { recentVisionSummaries, isLoadingVision } = useAppSelector(state => state.accessibility);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchAccessibilityVisionRecent(20));
|
||||
}, [dispatch]);
|
||||
|
||||
const items: ActionableItem[] = useMemo(() => {
|
||||
return recentVisionSummaries.map(summary => ({
|
||||
id: `si-${summary.id}`,
|
||||
title: summary.actionable_notes.slice(0, 120),
|
||||
description: [summary.ui_state, summary.key_text].filter(Boolean).join(' - '),
|
||||
source: 'ai_insight' as const,
|
||||
priority: confidenceToPriority(summary.confidence),
|
||||
status: 'active' as const,
|
||||
createdAt: new Date(summary.captured_at_ms),
|
||||
updatedAt: new Date(summary.captured_at_ms),
|
||||
actionable: true,
|
||||
sourceLabel: summary.app_name ?? 'Screen Intelligence',
|
||||
}));
|
||||
}, [recentVisionSummaries]);
|
||||
|
||||
return { items, loading: isLoadingVision };
|
||||
}
|
||||
+151
-111
@@ -1,115 +1,166 @@
|
||||
/**
|
||||
* React hooks for consuming skill state from Redux.
|
||||
* React hooks for consuming skill state via RPC.
|
||||
*
|
||||
* All state is fetched from the Rust core sidecar and cached locally.
|
||||
* Tauri events trigger re-fetches for instant reactivity.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAppSelector } from "../../store/hooks";
|
||||
import type {
|
||||
SkillConnectionStatus,
|
||||
SkillHostConnectionState,
|
||||
} from "./types";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { SkillConnectionStatus, SkillHostConnectionState } from './types';
|
||||
import { onSkillStateChange } from './skillEvents';
|
||||
import {
|
||||
getAllSnapshots,
|
||||
getSkillSnapshot,
|
||||
listAvailable,
|
||||
type AvailableSkillEntryRpc,
|
||||
type SkillSnapshotRpc,
|
||||
} from './skillsApi';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy pure function kept for compatibility (used by sync.ts, skillsSyncUi)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derive a unified connection status from the skill's lifecycle status
|
||||
* and its self-reported connection/auth state.
|
||||
*/
|
||||
export function deriveConnectionStatus(
|
||||
lifecycleStatus: string | undefined,
|
||||
setupComplete: boolean | undefined,
|
||||
skillState: Record<string, unknown> | undefined,
|
||||
): SkillConnectionStatus {
|
||||
// Skill not registered, not started, or shutting down
|
||||
if (!lifecycleStatus || lifecycleStatus === "installed" || lifecycleStatus === "stopping") {
|
||||
return "offline";
|
||||
if (!lifecycleStatus || lifecycleStatus === 'installed' || lifecycleStatus === 'stopping') {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// Process-level errors (failed to spawn, etc.)
|
||||
if (lifecycleStatus === "error") {
|
||||
return "error";
|
||||
if (lifecycleStatus === 'error') return 'error';
|
||||
if (lifecycleStatus === 'setup_required' || lifecycleStatus === 'setup_in_progress') {
|
||||
return 'setup_required';
|
||||
}
|
||||
if (lifecycleStatus === 'starting') return 'connecting';
|
||||
|
||||
// Setup required
|
||||
if (
|
||||
lifecycleStatus === "setup_required" ||
|
||||
lifecycleStatus === "setup_in_progress"
|
||||
) {
|
||||
return "setup_required";
|
||||
}
|
||||
|
||||
// Still starting up
|
||||
if (lifecycleStatus === "starting") {
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
// Process is running or ready — use the skill's self-reported state
|
||||
const hostState = skillState as SkillHostConnectionState | undefined;
|
||||
const connStatus = hostState?.connection_status;
|
||||
const authStatus = hostState?.auth_status;
|
||||
|
||||
// If the skill hasn't pushed any state, or pushed state without standard
|
||||
// connection_status / auth_status fields, fall back to lifecycle + setupComplete.
|
||||
if (!connStatus && !authStatus) {
|
||||
if (setupComplete && (lifecycleStatus === "ready" || lifecycleStatus === "running")) {
|
||||
return "connected";
|
||||
if (setupComplete && (lifecycleStatus === 'ready' || lifecycleStatus === 'running')) {
|
||||
return 'connected';
|
||||
}
|
||||
if (!hostState) {
|
||||
return "connecting";
|
||||
}
|
||||
// Skill pushed custom state but no connection fields — treat as connecting
|
||||
return "connecting";
|
||||
if (!hostState) return 'connecting';
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
// Check for errors first
|
||||
if (connStatus === "error" || authStatus === "error") {
|
||||
return "error";
|
||||
if (connStatus === 'error' || authStatus === 'error') return 'error';
|
||||
if (connStatus === 'connecting' || authStatus === 'authenticating') return 'connecting';
|
||||
if (connStatus === 'connected') {
|
||||
if (!authStatus || authStatus === 'authenticated') return 'connected';
|
||||
if (authStatus === 'not_authenticated') return 'not_authenticated';
|
||||
}
|
||||
if (connStatus === 'disconnected') {
|
||||
return setupComplete ? 'disconnected' : 'setup_required';
|
||||
}
|
||||
|
||||
// Connecting or authenticating
|
||||
if (connStatus === "connecting" || authStatus === "authenticating") {
|
||||
return "connecting";
|
||||
}
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
// Connected — check auth if the skill uses it
|
||||
if (connStatus === "connected") {
|
||||
if (!authStatus || authStatus === "authenticated") {
|
||||
return "connected";
|
||||
// ---------------------------------------------------------------------------
|
||||
// RPC-backed hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fetch a single skill snapshot, re-fetching on skill events. */
|
||||
export function useSkillSnapshot(skillId: string | undefined): SkillSnapshotRpc | null {
|
||||
const [snap, setSnap] = useState<SkillSnapshotRpc | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!skillId) return;
|
||||
try {
|
||||
const s = await getSkillSnapshot(skillId);
|
||||
if (mountedRef.current) setSnap(s);
|
||||
} catch {
|
||||
// Skill may not be running yet — that's OK
|
||||
}
|
||||
if (authStatus === "not_authenticated") {
|
||||
return "not_authenticated";
|
||||
}
|
||||
}
|
||||
}, [skillId]);
|
||||
|
||||
// Disconnected from service
|
||||
if (connStatus === "disconnected") {
|
||||
if (setupComplete) {
|
||||
return "disconnected";
|
||||
}
|
||||
return "setup_required";
|
||||
}
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
refresh();
|
||||
const unsub = onSkillStateChange((changedId) => {
|
||||
if (!changedId || changedId === skillId) refresh();
|
||||
});
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
unsub();
|
||||
};
|
||||
}, [skillId, refresh]);
|
||||
|
||||
// Fallback
|
||||
return "connecting";
|
||||
return snap;
|
||||
}
|
||||
|
||||
/** Fetch all running skill snapshots, re-fetching on skill events. */
|
||||
export function useAllSkillSnapshots(): SkillSnapshotRpc[] {
|
||||
const [snaps, setSnaps] = useState<SkillSnapshotRpc[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const s = await getAllSnapshots();
|
||||
if (mountedRef.current) setSnaps(s);
|
||||
} catch {
|
||||
// Core not ready yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
refresh();
|
||||
const unsub = onSkillStateChange(() => refresh());
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
unsub();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return snaps;
|
||||
}
|
||||
|
||||
/** Fetch available skills from registry. */
|
||||
export function useAvailableSkills(): {
|
||||
skills: AvailableSkillEntryRpc[];
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const [skills, setSkills] = useState<AvailableSkillEntryRpc[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const s = await listAvailable();
|
||||
if (mountedRef.current) setSkills(s);
|
||||
} catch {
|
||||
// Registry not reachable
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
refresh();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return { skills, loading, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unified connection status for a skill.
|
||||
*
|
||||
* Combines the skill's lifecycle status (process running, setup needed, etc.)
|
||||
* with its self-reported connection/auth state (pushed via state/set reverse RPC).
|
||||
* Returns the connection status for a skill.
|
||||
* Reads from the Rust-derived `connection_status` field in the snapshot.
|
||||
*/
|
||||
export function useSkillConnectionStatus(
|
||||
skillId: string,
|
||||
): SkillConnectionStatus {
|
||||
const skill = useAppSelector((state) => state.skills.skills[skillId]);
|
||||
const skillState = useAppSelector(
|
||||
(state) => state.skills.skillStates[skillId],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
deriveConnectionStatus(skill?.status, skill?.setupComplete, skillState),
|
||||
[skill?.status, skill?.setupComplete, skillState],
|
||||
);
|
||||
export function useSkillConnectionStatus(skillId: string): SkillConnectionStatus {
|
||||
const snap = useSkillSnapshot(skillId);
|
||||
if (!snap) return 'offline';
|
||||
return (snap.connection_status as SkillConnectionStatus) || 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,9 +169,8 @@ export function useSkillConnectionStatus(
|
||||
export function useSkillState<T = Record<string, unknown>>(
|
||||
skillId: string,
|
||||
): T | undefined {
|
||||
return useAppSelector(
|
||||
(state) => state.skills.skillStates[skillId] as T | undefined,
|
||||
);
|
||||
const snap = useSkillSnapshot(skillId);
|
||||
return snap?.state as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,33 +181,23 @@ export function useSkillConnectionInfo(skillId: string): {
|
||||
error?: string | null;
|
||||
isInitialized: boolean;
|
||||
} {
|
||||
const skill = useAppSelector((state) => state.skills.skills[skillId]);
|
||||
const skillState = useAppSelector(
|
||||
(state) => state.skills.skillStates[skillId],
|
||||
);
|
||||
const snap = useSkillSnapshot(skillId);
|
||||
|
||||
return useMemo(() => {
|
||||
const status = deriveConnectionStatus(
|
||||
skill?.status,
|
||||
skill?.setupComplete,
|
||||
skillState,
|
||||
);
|
||||
const hostState = skillState as SkillHostConnectionState | undefined;
|
||||
if (!snap) {
|
||||
return { status: 'offline', error: null, isInitialized: false };
|
||||
}
|
||||
|
||||
let error: string | null | undefined;
|
||||
if (status === "error") {
|
||||
error =
|
||||
hostState?.connection_error ??
|
||||
hostState?.auth_error ??
|
||||
skill?.error ??
|
||||
null;
|
||||
}
|
||||
const status = (snap.connection_status as SkillConnectionStatus) || 'offline';
|
||||
const hostState = snap.state as SkillHostConnectionState | undefined;
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isInitialized: !!hostState?.is_initialized,
|
||||
};
|
||||
}, [skill?.status, skill?.setupComplete, skill?.error, skillState]);
|
||||
let error: string | null | undefined;
|
||||
if (status === 'error') {
|
||||
error = hostState?.connection_error ?? hostState?.auth_error ?? snap.error ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isInitialized: !!hostState?.is_initialized,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Skill state event bus — bridges Tauri runtime events to React hooks.
|
||||
*
|
||||
* When the Rust core emits skill state changes, listeners here trigger
|
||||
* re-fetches in the React hooks that consume skill state via RPC.
|
||||
*/
|
||||
|
||||
type Listener = (skillId?: string) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
/** Subscribe to skill state invalidation events. Returns unsubscribe fn. */
|
||||
export function onSkillStateChange(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Notify all listeners that skill state has changed. */
|
||||
export function emitSkillStateChange(skillId?: string): void {
|
||||
for (const fn of listeners) {
|
||||
fn(skillId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Setup Tauri event listeners that bridge to the skill event bus. */
|
||||
export async function setupTauriSkillEventBridge(): Promise<() => void> {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
|
||||
const unlistenStatus = await listen<{ skill_id?: string }>(
|
||||
'runtime:skill-status-changed',
|
||||
(event) => {
|
||||
emitSkillStateChange(event.payload?.skill_id);
|
||||
},
|
||||
);
|
||||
|
||||
const unlistenState = await listen<{ skill_id?: string }>(
|
||||
'runtime:skill-state-changed',
|
||||
(event) => {
|
||||
emitSkillStateChange(event.payload?.skill_id);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlistenStatus();
|
||||
unlistenState();
|
||||
};
|
||||
} catch {
|
||||
// Not in Tauri environment
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Imperative RPC wrapper for skill state — single source of truth.
|
||||
*
|
||||
* Replaces direct Redux access for skill state reads and writes.
|
||||
* All functions call the Rust core sidecar via JSON-RPC.
|
||||
*/
|
||||
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
|
||||
// Re-export types that consumers need
|
||||
export interface SkillSnapshotRpc {
|
||||
skill_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema?: unknown }>;
|
||||
error?: string | null;
|
||||
state: Record<string, unknown>;
|
||||
setup_complete: boolean;
|
||||
connection_status: string;
|
||||
}
|
||||
|
||||
export interface AvailableSkillEntryRpc {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
runtime: string;
|
||||
entry: string;
|
||||
auto_start: boolean;
|
||||
platforms?: string[] | null;
|
||||
setup?: { required?: boolean; label?: string; oauth?: { provider: string; scopes: string[]; apiBaseUrl: string } } | null;
|
||||
ignore_in_production: boolean;
|
||||
download_url: string;
|
||||
manifest_url: string;
|
||||
checksum_sha256?: string | null;
|
||||
category: string;
|
||||
installed: boolean;
|
||||
installed_version?: string | null;
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
export interface InstalledSkillInfoRpc {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
runtime: string;
|
||||
}
|
||||
|
||||
// --- Read operations ---
|
||||
|
||||
export async function getSkillSnapshot(skillId: string): Promise<SkillSnapshotRpc> {
|
||||
return callCoreRpc<SkillSnapshotRpc>({
|
||||
method: 'openhuman.skills_status',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllSnapshots(): Promise<SkillSnapshotRpc[]> {
|
||||
return callCoreRpc<SkillSnapshotRpc[]>({
|
||||
method: 'openhuman.skills_get_all_snapshots',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAvailable(): Promise<AvailableSkillEntryRpc[]> {
|
||||
return callCoreRpc<AvailableSkillEntryRpc[]>({
|
||||
method: 'openhuman.skills_list_available',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listInstalled(): Promise<InstalledSkillInfoRpc[]> {
|
||||
return callCoreRpc<InstalledSkillInfoRpc[]>({
|
||||
method: 'openhuman.skills_list_installed',
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchSkills(
|
||||
query: string,
|
||||
category?: string,
|
||||
): Promise<AvailableSkillEntryRpc[]> {
|
||||
return callCoreRpc<AvailableSkillEntryRpc[]>({
|
||||
method: 'openhuman.skills_search',
|
||||
params: { query, category },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Write operations ---
|
||||
|
||||
export async function startSkill(skillId: string): Promise<SkillSnapshotRpc> {
|
||||
return callCoreRpc<SkillSnapshotRpc>({
|
||||
method: 'openhuman.skills_start',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopSkill(skillId: string): Promise<void> {
|
||||
await callCoreRpc({ method: 'openhuman.skills_stop', params: { skill_id: skillId } });
|
||||
}
|
||||
|
||||
export async function installSkill(skillId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_install',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function uninstallSkill(skillId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_uninstall',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSetupComplete(skillId: string, complete: boolean): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_set_setup_complete',
|
||||
params: { skill_id: skillId, complete },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRegistryFresh(): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_registry_fetch',
|
||||
params: { force: true },
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useIntelligenceSocketManager,
|
||||
} from '../hooks/useIntelligenceSocket';
|
||||
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
|
||||
import { useScreenIntelligenceItems } from '../hooks/useScreenIntelligenceItems';
|
||||
import type { RootState } from '../store';
|
||||
import { setSearchFilter, setSourceFilter } from '../store/intelligenceSlice';
|
||||
import type {
|
||||
@@ -69,10 +70,16 @@ export default function Intelligence() {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const usingMemoryData = consciousItems.length > 0;
|
||||
const items: ActionableItem[] = useMemo(() => consciousItems, [consciousItems]);
|
||||
const { items: screenIntelligenceItems, loading: screenIntelligenceLoading } =
|
||||
useScreenIntelligenceItems();
|
||||
|
||||
const itemsLoading = consciousLoading;
|
||||
const usingMemoryData = consciousItems.length > 0 || screenIntelligenceItems.length > 0;
|
||||
const items: ActionableItem[] = useMemo(
|
||||
() => [...consciousItems, ...screenIntelligenceItems],
|
||||
[consciousItems, screenIntelligenceItems]
|
||||
);
|
||||
|
||||
const itemsLoading = consciousLoading || screenIntelligenceLoading;
|
||||
|
||||
// Initialize socket connection
|
||||
useEffect(() => {
|
||||
|
||||
+31
-16
@@ -9,10 +9,14 @@ import {
|
||||
deriveAesKeyFromMnemonic,
|
||||
deriveEvmAddressFromMnemonic,
|
||||
generateMnemonicPhrase,
|
||||
MNEMONIC_GENERATE_WORD_COUNT,
|
||||
validateMnemonicPhrase,
|
||||
} from '../utils/cryptoKeys';
|
||||
|
||||
const WORD_COUNT = 24;
|
||||
/** Allowed BIP39 phrase lengths for import (includes legacy 24-word backups). */
|
||||
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
|
||||
|
||||
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
|
||||
|
||||
const Mnemonic = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -29,7 +33,7 @@ const Mnemonic = () => {
|
||||
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
|
||||
|
||||
// Import mode state
|
||||
const [importWords, setImportWords] = useState<string[]>(Array(WORD_COUNT).fill(''));
|
||||
const [importWords, setImportWords] = useState<string[]>(Array(IMPORT_SLOTS_INITIAL).fill(''));
|
||||
const [importValid, setImportValid] = useState<boolean | null>(null);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
@@ -45,6 +49,7 @@ const Mnemonic = () => {
|
||||
setConfirmed(false);
|
||||
setError(null);
|
||||
setImportValid(null);
|
||||
setImportWords(Array(IMPORT_SLOTS_INITIAL).fill(''));
|
||||
}, [mode]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
@@ -66,18 +71,24 @@ const Mnemonic = () => {
|
||||
|
||||
const handleImportWordChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
// Handle paste of full mnemonic phrase
|
||||
const pastedWords = value.trim().split(/\s+/);
|
||||
const pastedWords = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (pastedWords.length > 1) {
|
||||
const fullPhraseLen = pastedWords.length;
|
||||
if (BIP39_IMPORT_LENGTHS.includes(fullPhraseLen as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setImportWords(pastedWords.map(w => w.toLowerCase()));
|
||||
setImportValid(null);
|
||||
inputRefs.current[fullPhraseLen - 1]?.focus();
|
||||
return;
|
||||
}
|
||||
const newWords = [...importWords];
|
||||
for (let i = 0; i < Math.min(pastedWords.length, WORD_COUNT - index); i++) {
|
||||
const slotCount = newWords.length;
|
||||
for (let i = 0; i < Math.min(pastedWords.length, slotCount - index); i++) {
|
||||
newWords[index + i] = pastedWords[i].toLowerCase();
|
||||
}
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
// Focus the next empty field or the last filled field
|
||||
const nextEmpty = newWords.findIndex(w => !w);
|
||||
const focusIndex = nextEmpty === -1 ? WORD_COUNT - 1 : nextEmpty;
|
||||
const focusIndex = nextEmpty === -1 ? slotCount - 1 : nextEmpty;
|
||||
inputRefs.current[focusIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
@@ -87,8 +98,7 @@ const Mnemonic = () => {
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
|
||||
// Auto-advance to next input when a word is entered
|
||||
if (value.trim() && index < WORD_COUNT - 1) {
|
||||
if (value.trim() && index < newWords.length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
},
|
||||
@@ -107,9 +117,10 @@ const Mnemonic = () => {
|
||||
const handleValidateImport = useCallback(() => {
|
||||
const phrase = importWords.join(' ').trim();
|
||||
const filledWords = importWords.filter(w => w.trim());
|
||||
const n = filledWords.length;
|
||||
|
||||
if (filledWords.length !== WORD_COUNT) {
|
||||
setError(`Please enter all ${WORD_COUNT} words.`);
|
||||
if (!BIP39_IMPORT_LENGTHS.includes(n as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setError(`Recovery phrase must be ${BIP39_IMPORT_LENGTHS.join(', ')} words (you have ${n}).`);
|
||||
setImportValid(false);
|
||||
return false;
|
||||
}
|
||||
@@ -166,7 +177,10 @@ const Mnemonic = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isImportComplete = importWords.every(w => w.trim());
|
||||
const importWordCount = importWords.filter(w => w.trim()).length;
|
||||
const isImportComplete =
|
||||
importWords.every(w => w.trim()) &&
|
||||
BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]);
|
||||
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
|
||||
|
||||
return (
|
||||
@@ -182,8 +196,9 @@ const Mnemonic = () => {
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Write down these 24 words in order and store them somewhere safe. This phrase is
|
||||
used to encrypt your data and can never be recovered if lost.
|
||||
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
|
||||
somewhere safe. This phrase is used to encrypt your data and can never be
|
||||
recovered if lost.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -263,8 +278,8 @@ const Mnemonic = () => {
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Enter your existing 24-word recovery phrase below. You can also paste the full
|
||||
phrase into the first field.
|
||||
Enter your recovery phrase below, or paste the full phrase into any field (12
|
||||
words for new backups; 24-word phrases from older versions still work).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+28
-140
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
DefaultIcon,
|
||||
@@ -6,17 +6,14 @@ import {
|
||||
SkillActionButton,
|
||||
type SkillListEntry,
|
||||
STATUS_DISPLAY,
|
||||
STATUS_PRIORITY,
|
||||
} from '../components/skills/shared';
|
||||
import SkillSetupModal from '../components/skills/SkillSetupModal';
|
||||
import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks';
|
||||
import { useAvailableSkills, useSkillConnectionStatus } from '../lib/skills/hooks';
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import { installSkill } from '../lib/skills/skillsApi';
|
||||
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
|
||||
import { callCoreRpc } from '../services/coreRpcClient';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { addSkill } from '../store/skillsSlice';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { runtimeDiscoverSkills } from '../utils/tauriCommands';
|
||||
import { deriveSkillSyncSummaryText, deriveSkillSyncUiState } from './skillsSyncUi';
|
||||
|
||||
/** Status dot color for skill connection status */
|
||||
@@ -174,13 +171,8 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
|
||||
// ─── Main Skills Page ───────────────────────────────────────────────────────
|
||||
|
||||
export default function Skills() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Skills state
|
||||
const [skillsList, setSkillsList] = useState<SkillListEntry[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
const skillsState = useAppSelector(state => state.skills.skills);
|
||||
const skillStates = useAppSelector(state => state.skills.skillStates);
|
||||
// Skills from registry via RPC
|
||||
const { skills: availableSkills, loading: skillsLoading } = useAvailableSkills();
|
||||
|
||||
// Modal state
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
@@ -189,139 +181,35 @@ export default function Skills() {
|
||||
const [activeSkillDescription, setActiveSkillDescription] = useState('');
|
||||
const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false);
|
||||
|
||||
// Load skills from registry (with fallback to runtime discover)
|
||||
useEffect(() => {
|
||||
const loadSkills = async () => {
|
||||
try {
|
||||
// Try the registry-based list first
|
||||
const response = await callCoreRpc<Record<string, unknown>[]>({
|
||||
method: 'openhuman.skills_list_available',
|
||||
});
|
||||
const entries = Array.isArray(response) ? response : [];
|
||||
|
||||
if (entries.length > 0) {
|
||||
const processed: SkillListEntry[] = entries
|
||||
.filter(e => {
|
||||
const id = e.id as string;
|
||||
if (id.includes('_')) return false;
|
||||
if (!IS_DEV && e.ignore_in_production) return false;
|
||||
return true;
|
||||
})
|
||||
.map(e => {
|
||||
const setup = e.setup as Record<string, unknown> | undefined;
|
||||
const oauthConfig = setup?.oauth as Record<string, unknown> | undefined;
|
||||
// Register manifest in Redux so SkillSetupWizard can find it
|
||||
dispatch(
|
||||
addSkill({
|
||||
manifest: {
|
||||
id: e.id as string,
|
||||
name: (e.name as string) || (e.id as string),
|
||||
version: (e.version as string) || '0.0.0',
|
||||
description: (e.description as string) || '',
|
||||
runtime: 'quickjs',
|
||||
entry: (e.entry as string) || 'index.js',
|
||||
setup: setup
|
||||
? {
|
||||
required: (setup.required as boolean) ?? false,
|
||||
label: setup.label as string | undefined,
|
||||
oauth: oauthConfig
|
||||
? {
|
||||
provider: (oauthConfig.provider as string) || '',
|
||||
scopes: (oauthConfig.scopes as string[]) || [],
|
||||
apiBaseUrl: (oauthConfig.apiBaseUrl as string) || '',
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
id: e.id as string,
|
||||
name:
|
||||
(e.name as string) ||
|
||||
(e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1),
|
||||
description: (e.description as string) || '',
|
||||
icon: SKILL_ICONS[e.id as string],
|
||||
ignoreInProduction: (e.ignore_in_production as boolean) ?? false,
|
||||
hasSetup: !!(setup && setup.required),
|
||||
};
|
||||
});
|
||||
|
||||
setSkillsList(processed);
|
||||
setSkillsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Registry unavailable, fall through to runtime discover
|
||||
}
|
||||
|
||||
// Fallback: try runtime discover (Tauri command)
|
||||
try {
|
||||
const manifests = await runtimeDiscoverSkills();
|
||||
const processed: SkillListEntry[] = manifests
|
||||
.filter(m => {
|
||||
const id = m.id as string;
|
||||
if (id.includes('_')) return false;
|
||||
return true;
|
||||
})
|
||||
.map(m => {
|
||||
const setup = m.setup as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: m.id as string,
|
||||
name:
|
||||
(m.name as string) ||
|
||||
(m.id as string).charAt(0).toUpperCase() + (m.id as string).slice(1),
|
||||
description: (m.description as string) || '',
|
||||
icon: SKILL_ICONS[m.id as string],
|
||||
ignoreInProduction: (m.ignoreInProduction as boolean) ?? false,
|
||||
hasSetup: !!(setup && setup.required),
|
||||
};
|
||||
})
|
||||
.filter(s => IS_DEV || !s.ignoreInProduction);
|
||||
|
||||
setSkillsList(processed);
|
||||
} catch {
|
||||
// Skills unavailable
|
||||
} finally {
|
||||
setSkillsLoading(false);
|
||||
}
|
||||
};
|
||||
loadSkills();
|
||||
}, [dispatch]);
|
||||
|
||||
// Sort skills by connection status
|
||||
const sortedSkillsList = useMemo(() => {
|
||||
return [...skillsList]
|
||||
.sort((a, b) => {
|
||||
const skillA = skillsState[a.id];
|
||||
const skillB = skillsState[b.id];
|
||||
const stateA = skillStates[a.id];
|
||||
const stateB = skillStates[b.id];
|
||||
|
||||
const statusA = deriveConnectionStatus(skillA?.status, skillA?.setupComplete, stateA);
|
||||
const statusB = deriveConnectionStatus(skillB?.status, skillB?.setupComplete, stateB);
|
||||
|
||||
const priorityA = STATUS_PRIORITY[statusA] ?? 999;
|
||||
const priorityB = STATUS_PRIORITY[statusB] ?? 999;
|
||||
|
||||
if (priorityA === priorityB) return a.name.localeCompare(b.name);
|
||||
return priorityA - priorityB;
|
||||
// Transform registry entries to SkillListEntry
|
||||
const skillsList: SkillListEntry[] = useMemo(() => {
|
||||
return availableSkills
|
||||
.filter(e => {
|
||||
if (e.id.includes('_')) return false;
|
||||
if (!IS_DEV && e.ignore_in_production) return false;
|
||||
return true;
|
||||
})
|
||||
.filter(s => IS_DEV || !s.ignoreInProduction);
|
||||
}, [skillsList, skillsState, skillStates]);
|
||||
.map(e => ({
|
||||
id: e.id,
|
||||
name: e.name || e.id.charAt(0).toUpperCase() + e.id.slice(1),
|
||||
description: e.description || '',
|
||||
icon: SKILL_ICONS[e.id],
|
||||
ignoreInProduction: e.ignore_in_production,
|
||||
hasSetup: !!(e.setup && e.setup.required),
|
||||
}));
|
||||
}, [availableSkills]);
|
||||
|
||||
// Sort by name (connection status sorting will use the hook per-card)
|
||||
const sortedSkillsList = useMemo(() => {
|
||||
return [...skillsList].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [skillsList]);
|
||||
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
|
||||
const openSkillSetup = async (skill: SkillListEntry) => {
|
||||
// Install skill from registry if not yet on disk
|
||||
try {
|
||||
setInstalling(skill.id);
|
||||
await callCoreRpc<{ success: boolean }>({
|
||||
method: 'openhuman.skills_install',
|
||||
params: { skill_id: skill.id },
|
||||
});
|
||||
await installSkill(skill.id);
|
||||
} catch (err) {
|
||||
console.warn(`[Skills] install failed for ${skill.id}, continuing anyway:`, err);
|
||||
} finally {
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
deriveAesKeyFromMnemonic,
|
||||
deriveEvmAddressFromMnemonic,
|
||||
generateMnemonicPhrase,
|
||||
MNEMONIC_GENERATE_WORD_COUNT,
|
||||
validateMnemonicPhrase,
|
||||
} from '../../../utils/cryptoKeys';
|
||||
|
||||
const WORD_COUNT = 24;
|
||||
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
|
||||
|
||||
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
|
||||
|
||||
interface MnemonicStepProps {
|
||||
onComplete: () => void | Promise<void>;
|
||||
@@ -28,7 +31,7 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
const mnemonic = useMemo(() => generateMnemonicPhrase(), []);
|
||||
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
|
||||
|
||||
const [importWords, setImportWords] = useState<string[]>(Array(WORD_COUNT).fill(''));
|
||||
const [importWords, setImportWords] = useState<string[]>(Array(IMPORT_SLOTS_INITIAL).fill(''));
|
||||
const [importValid, setImportValid] = useState<boolean | null>(null);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
@@ -43,6 +46,7 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
setConfirmed(false);
|
||||
setError(null);
|
||||
setImportValid(null);
|
||||
setImportWords(Array(IMPORT_SLOTS_INITIAL).fill(''));
|
||||
}, [mode]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
@@ -64,16 +68,24 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
|
||||
const handleImportWordChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
const pastedWords = value.trim().split(/\s+/);
|
||||
const pastedWords = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (pastedWords.length > 1) {
|
||||
const fullPhraseLen = pastedWords.length;
|
||||
if (BIP39_IMPORT_LENGTHS.includes(fullPhraseLen as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setImportWords(pastedWords.map(w => w.toLowerCase()));
|
||||
setImportValid(null);
|
||||
inputRefs.current[fullPhraseLen - 1]?.focus();
|
||||
return;
|
||||
}
|
||||
const newWords = [...importWords];
|
||||
for (let i = 0; i < Math.min(pastedWords.length, WORD_COUNT - index); i++) {
|
||||
const slotCount = newWords.length;
|
||||
for (let i = 0; i < Math.min(pastedWords.length, slotCount - index); i++) {
|
||||
newWords[index + i] = pastedWords[i].toLowerCase();
|
||||
}
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
const nextEmpty = newWords.findIndex(w => !w);
|
||||
const focusIndex = nextEmpty === -1 ? WORD_COUNT - 1 : nextEmpty;
|
||||
const focusIndex = nextEmpty === -1 ? slotCount - 1 : nextEmpty;
|
||||
inputRefs.current[focusIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
@@ -83,7 +95,7 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
|
||||
if (value.trim() && index < WORD_COUNT - 1) {
|
||||
if (value.trim() && index < newWords.length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
},
|
||||
@@ -102,9 +114,10 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
const handleValidateImport = useCallback(() => {
|
||||
const phrase = importWords.join(' ').trim();
|
||||
const filledWords = importWords.filter(w => w.trim());
|
||||
const n = filledWords.length;
|
||||
|
||||
if (filledWords.length !== WORD_COUNT) {
|
||||
setError(`Please enter all ${WORD_COUNT} words.`);
|
||||
if (!BIP39_IMPORT_LENGTHS.includes(n as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setError(`Recovery phrase must be ${BIP39_IMPORT_LENGTHS.join(', ')} words (you have ${n}).`);
|
||||
setImportValid(false);
|
||||
return false;
|
||||
}
|
||||
@@ -159,7 +172,10 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isImportComplete = importWords.every(w => w.trim());
|
||||
const importWordCount = importWords.filter(w => w.trim()).length;
|
||||
const isImportComplete =
|
||||
importWords.every(w => w.trim()) &&
|
||||
BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]);
|
||||
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
|
||||
|
||||
return (
|
||||
@@ -169,8 +185,8 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Write down these 24 words in order and store them somewhere safe. This phrase encrypts
|
||||
your data and can never be recovered if lost.
|
||||
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
|
||||
somewhere safe. This phrase encrypts your data and can never be recovered if lost.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -246,8 +262,8 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Enter your existing 24-word recovery phrase below. You can also paste the full phrase
|
||||
into the first field.
|
||||
Enter your recovery phrase below, or paste the full phrase into any field (12 words
|
||||
for new backups; 24-word phrases from older versions still work).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AccessibilityStatus } from '../../utils/tauriCommands';
|
||||
import type { AccessibilityStatus, CaptureTestResult } from '../../utils/tauriCommands';
|
||||
import reducer, {
|
||||
clearAccessibilityError,
|
||||
fetchAccessibilityStatus,
|
||||
runCaptureTest,
|
||||
setAccessibilityStatus,
|
||||
startAccessibilitySession,
|
||||
stopAccessibilitySession,
|
||||
@@ -100,4 +101,41 @@ describe('accessibilitySlice', () => {
|
||||
const cleared = reducer(errored, clearAccessibilityError());
|
||||
expect(cleared.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('tracks capture test lifecycle', () => {
|
||||
const pending = reducer(undefined, { type: runCaptureTest.pending.type });
|
||||
expect(pending.isCaptureTestRunning).toBe(true);
|
||||
expect(pending.captureTestResult).toBeNull();
|
||||
|
||||
const testResult: CaptureTestResult = {
|
||||
ok: true,
|
||||
capture_mode: 'windowed',
|
||||
context: {
|
||||
app_name: 'Safari',
|
||||
window_title: 'GitHub',
|
||||
bounds_x: 0,
|
||||
bounds_y: 0,
|
||||
bounds_width: 1400,
|
||||
bounds_height: 900,
|
||||
},
|
||||
image_ref: 'data:image/png;base64,abc',
|
||||
bytes_estimate: 12345,
|
||||
error: null,
|
||||
timing_ms: 150,
|
||||
};
|
||||
|
||||
const fulfilled = reducer(pending, runCaptureTest.fulfilled(testResult, 'req-3', undefined));
|
||||
expect(fulfilled.isCaptureTestRunning).toBe(false);
|
||||
expect(fulfilled.captureTestResult?.ok).toBe(true);
|
||||
expect(fulfilled.captureTestResult?.capture_mode).toBe('windowed');
|
||||
});
|
||||
|
||||
it('handles capture test failure', () => {
|
||||
const rejected = reducer(undefined, {
|
||||
type: runCaptureTest.rejected.type,
|
||||
payload: 'capture failed',
|
||||
});
|
||||
expect(rejected.isCaptureTestRunning).toBe(false);
|
||||
expect(rejected.lastError).toBe('capture failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AccessibilitySessionStatus,
|
||||
type AccessibilityStatus,
|
||||
type AccessibilityVisionSummary,
|
||||
type CaptureTestResult,
|
||||
openhumanAccessibilityInputAction,
|
||||
openhumanAccessibilityRequestPermission,
|
||||
openhumanAccessibilityRequestPermissions,
|
||||
@@ -14,11 +15,14 @@ import {
|
||||
openhumanAccessibilityStopSession,
|
||||
openhumanAccessibilityVisionFlush,
|
||||
openhumanAccessibilityVisionRecent,
|
||||
openhumanScreenIntelligenceCaptureTest,
|
||||
} from '../utils/tauriCommands';
|
||||
|
||||
interface AccessibilityState {
|
||||
status: AccessibilityStatus | null;
|
||||
recentVisionSummaries: AccessibilityVisionSummary[];
|
||||
captureTestResult: CaptureTestResult | null;
|
||||
isCaptureTestRunning: boolean;
|
||||
isLoading: boolean;
|
||||
isRequestingPermissions: boolean;
|
||||
isStartingSession: boolean;
|
||||
@@ -31,6 +35,8 @@ interface AccessibilityState {
|
||||
const initialState: AccessibilityState = {
|
||||
status: null,
|
||||
recentVisionSummaries: [],
|
||||
captureTestResult: null,
|
||||
isCaptureTestRunning: false,
|
||||
isLoading: false,
|
||||
isRequestingPermissions: false,
|
||||
isStartingSession: false,
|
||||
@@ -156,6 +162,18 @@ export const flushAccessibilityVision = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const runCaptureTest = createAsyncThunk(
|
||||
'accessibility/captureTest',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await openhumanScreenIntelligenceCaptureTest();
|
||||
return response.result;
|
||||
} catch (error) {
|
||||
return rejectWithValue(extractError(error, 'Failed to run capture test'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const accessibilitySlice = createSlice({
|
||||
name: 'accessibility',
|
||||
initialState,
|
||||
@@ -269,6 +287,19 @@ const accessibilitySlice = createSlice({
|
||||
.addCase(flushAccessibilityVision.rejected, (state, action) => {
|
||||
state.isFlushingVision = false;
|
||||
state.lastError = (action.payload as string) ?? 'Failed to flush accessibility vision';
|
||||
})
|
||||
.addCase(runCaptureTest.pending, state => {
|
||||
state.isCaptureTestRunning = true;
|
||||
state.captureTestResult = null;
|
||||
state.lastError = null;
|
||||
})
|
||||
.addCase(runCaptureTest.fulfilled, (state, action) => {
|
||||
state.isCaptureTestRunning = false;
|
||||
state.captureTestResult = action.payload;
|
||||
})
|
||||
.addCase(runCaptureTest.rejected, (state, action) => {
|
||||
state.isCaptureTestRunning = false;
|
||||
state.lastError = (action.payload as string) ?? 'Failed to run capture test';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,11 +7,14 @@ import { HDKey } from '@scure/bip32';
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
||||
|
||||
/** Word count for newly generated recovery phrases (128-bit entropy, BIP39). */
|
||||
export const MNEMONIC_GENERATE_WORD_COUNT = 12;
|
||||
|
||||
/**
|
||||
* Generate a 24-word BIP39 mnemonic phrase (256-bit entropy).
|
||||
* Generate a 12-word BIP39 mnemonic phrase (128-bit entropy).
|
||||
*/
|
||||
export function generateMnemonicPhrase(): string {
|
||||
return generateMnemonic(wordlist, 256);
|
||||
return generateMnemonic(wordlist, 128);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,37 +3,11 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi';
|
||||
import { emitSkillStateChange } from '../lib/skills/skillEvents';
|
||||
import { setSetupComplete as rpcSetSetupComplete } from '../lib/skills/skillsApi';
|
||||
import { consumeLoginToken } from '../services/api/authApi';
|
||||
import { store } from '../store';
|
||||
import { setToken } from '../store/authSlice';
|
||||
import { setSkillSetupComplete, setSkillState } from '../store/skillsSlice';
|
||||
import {
|
||||
decryptIntegrationTokens,
|
||||
hexToBase64,
|
||||
type IntegrationTokensPayload,
|
||||
} from './integrationTokensCrypto';
|
||||
|
||||
function getCurrentUserId(): string | null {
|
||||
const state = store.getState();
|
||||
const explicitId = state.user.user?._id;
|
||||
if (explicitId) return explicitId;
|
||||
|
||||
const token = state.auth.token;
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padLen = (4 - (payloadBase64.length % 4)) % 4;
|
||||
const padded = padLen ? payloadBase64 + '='.repeat(padLen) : payloadBase64;
|
||||
const payloadJson = atob(padded);
|
||||
const payload = JSON.parse(payloadJson);
|
||||
return payload.tgUserId || payload.userId || payload.sub || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const focusMainWindow = async () => {
|
||||
try {
|
||||
@@ -128,82 +102,23 @@ const handleOAuthDeepLink = async (parsed: URL) => {
|
||||
|
||||
// Always mark the skill as connected first — the OAuth completed on the backend.
|
||||
// Token handoff is best-effort; the backend stores credentials server-side regardless.
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
|
||||
store.dispatch(
|
||||
setSkillState({
|
||||
skillId,
|
||||
state: {
|
||||
...(store.getState().skills.skillStates[skillId] ?? {}),
|
||||
connection_status: 'connected',
|
||||
integrationId,
|
||||
},
|
||||
})
|
||||
await rpcSetSetupComplete(skillId, true).catch(err =>
|
||||
console.warn('[DeepLink] Failed to persist setup_complete via RPC:', err)
|
||||
);
|
||||
// Notify hooks to re-fetch
|
||||
emitSkillStateChange(skillId);
|
||||
|
||||
// Best-effort: try to fetch and store encrypted tokens locally
|
||||
// Best-effort: notify skill runtime of OAuth completion and trigger sync
|
||||
try {
|
||||
const userId = getCurrentUserId();
|
||||
const state = store.getState();
|
||||
const encryptionKeyHex = userId ? state.auth.encryptionKeyByUser[userId] : undefined;
|
||||
|
||||
if (userId && encryptionKeyHex && typeof encryptionKeyHex === 'string') {
|
||||
const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, '');
|
||||
if (trimmedHex && trimmedHex.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(trimmedHex)) {
|
||||
const keyForBackend = hexToBase64(trimmedHex);
|
||||
if (keyForBackend) {
|
||||
const response = await fetchIntegrationTokens(integrationId, keyForBackend);
|
||||
if (response.success && response.data?.encrypted) {
|
||||
store.dispatch(
|
||||
setSkillState({
|
||||
skillId,
|
||||
state: {
|
||||
...(store.getState().skills.skillStates[skillId] ?? {}),
|
||||
oauthTokens: {
|
||||
...(store.getState().skills.skillStates[skillId]?.oauthTokens as
|
||||
| Record<string, { encrypted: string }>
|
||||
| undefined),
|
||||
[integrationId]: { encrypted: response.data.encrypted },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Pass decrypted access token to skill runtime if running
|
||||
let extraCredential: { accessToken?: string } | undefined;
|
||||
try {
|
||||
const decryptedJson = await decryptIntegrationTokens(
|
||||
response.data.encrypted,
|
||||
trimmedHex
|
||||
);
|
||||
const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload;
|
||||
if (payload.accessToken) {
|
||||
extraCredential = { accessToken: payload.accessToken };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[DeepLink] Could not decrypt integration token:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
await skillManager.notifyOAuthComplete(
|
||||
skillId,
|
||||
integrationId,
|
||||
undefined,
|
||||
extraCredential
|
||||
);
|
||||
await skillManager.triggerSync(skillId);
|
||||
} catch (runtimeErr) {
|
||||
console.warn('[DeepLink] Runtime notify skipped (skill not running):', runtimeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[DeepLink] Skipping token handoff: no encryption key available');
|
||||
}
|
||||
} catch (err) {
|
||||
// Token handoff failed but skill is already marked connected above
|
||||
console.warn('[DeepLink] Token handoff failed (skill still connected):', err);
|
||||
await skillManager.notifyOAuthComplete(skillId, integrationId);
|
||||
await skillManager.triggerSync(skillId);
|
||||
} catch (runtimeErr) {
|
||||
// Skill runtime may not be running — that's OK, setup is already persisted
|
||||
console.warn('[DeepLink] Runtime notify skipped (skill not running):', runtimeErr);
|
||||
}
|
||||
|
||||
// Re-emit so hooks pick up the latest state
|
||||
emitSkillStateChange(skillId);
|
||||
} else if (path === 'error') {
|
||||
const error = parsed.searchParams.get('error') ?? 'Unknown error';
|
||||
const provider = parsed.searchParams.get('provider') ?? 'unknown';
|
||||
|
||||
@@ -692,6 +692,25 @@ export interface AccessibilityVisionFlushResult {
|
||||
summary: AccessibilityVisionSummary | null;
|
||||
}
|
||||
|
||||
export interface CaptureTestContextInfo {
|
||||
app_name: string | null;
|
||||
window_title: string | null;
|
||||
bounds_x: number | null;
|
||||
bounds_y: number | null;
|
||||
bounds_width: number | null;
|
||||
bounds_height: number | null;
|
||||
}
|
||||
|
||||
export interface CaptureTestResult {
|
||||
ok: boolean;
|
||||
capture_mode: string;
|
||||
context: CaptureTestContextInfo | null;
|
||||
image_ref: string | null;
|
||||
bytes_estimate: number | null;
|
||||
error: string | null;
|
||||
timing_ms: number;
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
config: Record<string, unknown>;
|
||||
workspace_dir: string;
|
||||
@@ -1579,6 +1598,18 @@ export async function openhumanAccessibilityVisionFlush(): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanScreenIntelligenceCaptureTest(): Promise<
|
||||
CommandResponse<CaptureTestResult>
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<CaptureTestResult>>({
|
||||
method: 'openhuman.screen_intelligence_capture_test',
|
||||
serviceManaged: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanAutocompleteStatus(): Promise<CommandResponse<AutocompleteStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
@@ -1698,15 +1729,11 @@ export async function openhumanAutocompleteClearHistory(): Promise<
|
||||
}
|
||||
|
||||
export async function runtimeListSkills(): Promise<SkillSnapshot[]> {
|
||||
return await callCoreRpc<SkillSnapshot[]>({
|
||||
method: 'openhuman.skills_list',
|
||||
});
|
||||
return await callCoreRpc<SkillSnapshot[]>({ method: 'openhuman.skills_list' });
|
||||
}
|
||||
|
||||
export async function runtimeDiscoverSkills(): Promise<RuntimeDiscoveredSkill[]> {
|
||||
return await callCoreRpc<RuntimeDiscoveredSkill[]>({
|
||||
method: 'openhuman.skills_discover',
|
||||
});
|
||||
return await callCoreRpc<RuntimeDiscoveredSkill[]>({ method: 'openhuman.skills_discover' });
|
||||
}
|
||||
|
||||
export async function runtimeStartSkill(skillId: string): Promise<SkillSnapshot> {
|
||||
@@ -1717,10 +1744,7 @@ export async function runtimeStartSkill(skillId: string): Promise<SkillSnapshot>
|
||||
}
|
||||
|
||||
export async function runtimeStopSkill(skillId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_stop',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
await callCoreRpc({ method: 'openhuman.skills_stop', params: { skill_id: skillId } });
|
||||
}
|
||||
|
||||
export async function runtimeRpc<T = unknown>(
|
||||
@@ -1793,17 +1817,11 @@ export async function runtimeIsSkillEnabled(skillId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function runtimeEnableSkill(skillId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_enable',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
await callCoreRpc({ method: 'openhuman.skills_enable', params: { skill_id: skillId } });
|
||||
}
|
||||
|
||||
export async function runtimeDisableSkill(skillId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_disable',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
await callCoreRpc({ method: 'openhuman.skills_disable', params: { skill_id: skillId } });
|
||||
}
|
||||
|
||||
export async function runtimeSkillDataStats(skillId: string): Promise<RuntimeSkillDataStats> {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* E2E test: Screen Intelligence settings and Intelligence page.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. App launches and has an accessibility tree
|
||||
* 2. Settings navigation works (screen intelligence panel)
|
||||
* 3. Intelligence page loads without errors
|
||||
*
|
||||
* Note: Screen capture will fail gracefully without macOS permissions in CI.
|
||||
* The tests verify the UI renders correctly and handles errors, not that
|
||||
* actual screenshots are taken.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import { startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
describe('Screen Intelligence', () => {
|
||||
before(async () => {
|
||||
startMockServer();
|
||||
await waitForApp();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
stopMockServer();
|
||||
});
|
||||
|
||||
it('app launches with accessibility tree', async () => {
|
||||
const elements = await browser.$$('//*');
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('app has a menu bar', async () => {
|
||||
const menuBar = await browser.$('//XCUIElementTypeMenuBar');
|
||||
expect(await menuBar.isExisting()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ todo
|
||||
[] allow users to choose which version of LLM model they'd like to choose based on their CPU. better ram and gpu means higher parameter model can be used. - mithil
|
||||
[x] in the client side app, make console.log follow a logger style logging where there's a namespace for every logger (like python) - steve
|
||||
[ ] - currently we bundle tauri in the openhumany rust core but that shouldn't really have to be there. it can be completely removed.
|
||||
[] allow skills to be debuggged from the UI (we shuold try to call various tools or see state from the UI itself)
|
||||
|
||||
--- e2e tests to write up
|
||||
|
||||
|
||||
@@ -430,9 +430,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
||||
|
||||
let _ = self
|
||||
.send(&SendMessage::new(
|
||||
format!(
|
||||
"🔐 This bot requires operator approval.\n\nAsk the operator to approve the pairing in the web UI, then send your message again."
|
||||
),
|
||||
"🔐 This bot requires operator approval.\n\nAsk the operator to approve the pairing in the web UI, then send your message again.".to_string(),
|
||||
&chat_id,
|
||||
))
|
||||
.await;
|
||||
|
||||
@@ -15,6 +15,22 @@ pub(crate) fn now_ms() -> i64 {
|
||||
Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
/// Capture mode used for a screenshot.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CaptureMode {
|
||||
Windowed,
|
||||
Fullscreen,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CaptureMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CaptureMode::Windowed => write!(f, "windowed"),
|
||||
CaptureMode::Fullscreen => write!(f, "fullscreen"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn capture_screen_image_ref_for_context(
|
||||
context: Option<&AppContext>,
|
||||
) -> Result<String, String> {
|
||||
@@ -25,36 +41,112 @@ pub(crate) fn capture_screen_image_ref_for_context(
|
||||
Uuid::new_v4()
|
||||
));
|
||||
|
||||
let bounds = context
|
||||
.and_then(|ctx| ctx.bounds.clone())
|
||||
.ok_or_else(|| "active window bounds unavailable".to_string())?;
|
||||
if bounds.width <= 0 || bounds.height <= 0 {
|
||||
return Err("active window bounds are invalid".to_string());
|
||||
let bounds = context.and_then(|ctx| ctx.bounds.clone());
|
||||
|
||||
// Determine capture mode: windowed if we have valid bounds, fullscreen otherwise.
|
||||
let capture_mode = match &bounds {
|
||||
Some(b) if b.width > 0 && b.height > 0 => CaptureMode::Windowed,
|
||||
Some(b) => {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] invalid bounds ({}x{}), falling back to fullscreen",
|
||||
b.width,
|
||||
b.height
|
||||
);
|
||||
CaptureMode::Fullscreen
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] no window bounds available, falling back to fullscreen"
|
||||
);
|
||||
CaptureMode::Fullscreen
|
||||
}
|
||||
};
|
||||
|
||||
let mut cmd = std::process::Command::new("screencapture");
|
||||
cmd.arg("-x").arg("-t").arg("png");
|
||||
|
||||
if capture_mode == CaptureMode::Windowed {
|
||||
let b = bounds.as_ref().unwrap();
|
||||
let rect = format!("{},{},{},{}", b.x, b.y, b.width, b.height);
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] capture mode=windowed rect={rect} app={:?}",
|
||||
context.and_then(|c| c.app_name.as_deref())
|
||||
);
|
||||
cmd.arg("-R").arg(&rect);
|
||||
} else {
|
||||
tracing::debug!("[screen_intelligence] capture mode=fullscreen (primary display)");
|
||||
}
|
||||
|
||||
let rect = format!(
|
||||
"{},{},{},{}",
|
||||
bounds.x, bounds.y, bounds.width, bounds.height
|
||||
);
|
||||
cmd.arg(&tmp_path);
|
||||
|
||||
let status = std::process::Command::new("screencapture")
|
||||
.arg("-x")
|
||||
.arg("-t")
|
||||
.arg("png")
|
||||
.arg("-R")
|
||||
.arg(&rect)
|
||||
.arg(&tmp_path)
|
||||
let status = cmd
|
||||
.status()
|
||||
.map_err(|e| format!("screencapture failed to start: {e}"))?;
|
||||
if !status.success() {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] screencapture exited with status: {:?}",
|
||||
status.code()
|
||||
);
|
||||
return Err("screencapture returned non-zero status".to_string());
|
||||
}
|
||||
|
||||
let bytes =
|
||||
std::fs::read(&tmp_path).map_err(|e| format!("failed to read screenshot: {e}"))?;
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] captured {} bytes (mode={})",
|
||||
bytes.len(),
|
||||
capture_mode
|
||||
);
|
||||
|
||||
// If fullscreen capture is too large, try downscaling with sips.
|
||||
if bytes.len() > MAX_SCREENSHOT_BYTES && capture_mode == CaptureMode::Fullscreen {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] fullscreen capture {} bytes exceeds limit, downscaling with sips",
|
||||
bytes.len()
|
||||
);
|
||||
let sips_status = std::process::Command::new("sips")
|
||||
.arg("--resampleWidth")
|
||||
.arg("1280")
|
||||
.arg(&tmp_path)
|
||||
.status();
|
||||
match sips_status {
|
||||
Ok(s) if s.success() => {
|
||||
let resized = std::fs::read(&tmp_path)
|
||||
.map_err(|e| format!("failed to read resized screenshot: {e}"))?;
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
tracing::debug!("[screen_intelligence] resized to {} bytes", resized.len());
|
||||
if resized.len() > MAX_SCREENSHOT_BYTES {
|
||||
return Err(
|
||||
"captured screenshot exceeds size limit after downscale".to_string()
|
||||
);
|
||||
}
|
||||
let encoded = BASE64_STANDARD.encode(resized);
|
||||
return Ok(format!("data:image/png;base64,{encoded}"));
|
||||
}
|
||||
Ok(s) => {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] sips failed with status: {:?}",
|
||||
s.code()
|
||||
);
|
||||
return Err(
|
||||
"captured screenshot exceeds size limit and downscale failed".to_string(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
tracing::debug!("[screen_intelligence] sips not available: {e}");
|
||||
return Err("captured screenshot exceeds size limit".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
|
||||
if bytes.len() > MAX_SCREENSHOT_BYTES {
|
||||
return Err("captured screenshot exceeds size limit".to_string());
|
||||
}
|
||||
|
||||
let encoded = BASE64_STANDARD.encode(bytes);
|
||||
return Ok(format!("data:image/png;base64,{encoded}"));
|
||||
}
|
||||
@@ -66,6 +158,38 @@ pub(crate) fn capture_screen_image_ref_for_context(
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the raw stdout from the AppleScript foreground-context query.
|
||||
///
|
||||
/// Expected format: 6 lines — app_name, window_title, x, y, width, height.
|
||||
/// This is a pure function, fully testable without macOS.
|
||||
pub(crate) fn parse_foreground_output(stdout: &str) -> Option<AppContext> {
|
||||
let mut lines = stdout.lines();
|
||||
let app = lines.next().map(|s| s.trim().to_string());
|
||||
let title = lines.next().map(|s| s.trim().to_string());
|
||||
let x = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
let y = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
let width = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
let height = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
|
||||
let bounds = match (x, y, width, height) {
|
||||
(Some(x), Some(y), Some(width), Some(height)) if width > 0 && height > 0 => {
|
||||
Some(WindowBounds {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Some(AppContext {
|
||||
app_name: app.filter(|s| !s.is_empty()),
|
||||
window_title: title.filter(|s| !s.is_empty()),
|
||||
bounds,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn foreground_context() -> Option<AppContext> {
|
||||
let script = r#"
|
||||
@@ -101,35 +225,23 @@ pub(crate) fn foreground_context() -> Option<AppContext> {
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] osascript failed: status={:?} stderr={}",
|
||||
output.status.code(),
|
||||
stderr.trim()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let mut lines = text.lines();
|
||||
let app = lines.next().map(|s| s.trim().to_string());
|
||||
let title = lines.next().map(|s| s.trim().to_string());
|
||||
let x = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
let y = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
let width = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
let height = lines.next().and_then(|s| s.trim().parse::<i32>().ok());
|
||||
|
||||
let bounds = match (x, y, width, height) {
|
||||
(Some(x), Some(y), Some(width), Some(height)) if width > 0 && height > 0 => {
|
||||
Some(WindowBounds {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Some(AppContext {
|
||||
app_name: app.filter(|s| !s.is_empty()),
|
||||
window_title: title.filter(|s| !s.is_empty()),
|
||||
bounds,
|
||||
})
|
||||
let result = parse_foreground_output(&text);
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] foreground_context: app={:?} bounds_present={}",
|
||||
result.as_ref().and_then(|c| c.app_name.as_deref()),
|
||||
result.as_ref().map(|c| c.bounds.is_some()).unwrap_or(false)
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -20,11 +20,11 @@ use super::permissions::{
|
||||
open_macos_privacy_pane, request_accessibility_access, request_screen_recording_access,
|
||||
};
|
||||
use super::types::{
|
||||
AccessibilityFeatures, AccessibilityStatus, AutocompleteCommitParams, AutocompleteCommitResult,
|
||||
AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureFrame, CaptureImageRefResult,
|
||||
CaptureNowResult, InputActionParams, InputActionResult, PermissionKind, PermissionState,
|
||||
PermissionStatus, SessionStatus, StartSessionParams, VisionFlushResult, VisionRecentResult,
|
||||
VisionSummary,
|
||||
AccessibilityFeatures, AccessibilityStatus, AppContextInfo, AutocompleteCommitParams,
|
||||
AutocompleteCommitResult, AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureFrame,
|
||||
CaptureImageRefResult, CaptureNowResult, CaptureTestResult, InputActionParams,
|
||||
InputActionResult, PermissionKind, PermissionState, PermissionStatus, SessionStatus,
|
||||
StartSessionParams, VisionFlushResult, VisionRecentResult, VisionSummary,
|
||||
};
|
||||
|
||||
struct SessionRuntime {
|
||||
@@ -641,8 +641,65 @@ impl AccessibilityEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// Standalone capture test — works without an active session.
|
||||
/// Returns rich diagnostics for the debug UI.
|
||||
pub async fn capture_test(&self) -> CaptureTestResult {
|
||||
let start = std::time::Instant::now();
|
||||
let context = foreground_context();
|
||||
|
||||
let context_info = context.as_ref().map(|c| AppContextInfo {
|
||||
app_name: c.app_name.clone(),
|
||||
window_title: c.window_title.clone(),
|
||||
bounds_x: c.bounds.as_ref().map(|b| b.x),
|
||||
bounds_y: c.bounds.as_ref().map(|b| b.y),
|
||||
bounds_width: c.bounds.as_ref().map(|b| b.width),
|
||||
bounds_height: c.bounds.as_ref().map(|b| b.height),
|
||||
});
|
||||
|
||||
let has_bounds = context
|
||||
.as_ref()
|
||||
.and_then(|c| c.bounds.as_ref())
|
||||
.map(|b| b.width > 0 && b.height > 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let capture_mode = if has_bounds {
|
||||
"windowed".to_string()
|
||||
} else if context.is_some() {
|
||||
"fullscreen".to_string()
|
||||
} else {
|
||||
"fullscreen".to_string()
|
||||
};
|
||||
|
||||
match capture_screen_image_ref_for_context(context.as_ref()) {
|
||||
Ok(image_ref) => {
|
||||
let bytes_estimate = image_ref
|
||||
.strip_prefix("data:image/png;base64,")
|
||||
.map(|payload| payload.len() * 3 / 4);
|
||||
CaptureTestResult {
|
||||
ok: true,
|
||||
capture_mode,
|
||||
context: context_info,
|
||||
image_ref: Some(image_ref),
|
||||
bytes_estimate,
|
||||
error: None,
|
||||
timing_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
Err(err) => CaptureTestResult {
|
||||
ok: false,
|
||||
capture_mode,
|
||||
context: context_info,
|
||||
image_ref: None,
|
||||
bytes_estimate: None,
|
||||
error: Some(err),
|
||||
timing_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_capture_worker(self: Arc<Self>) {
|
||||
let mut tick = time::interval(Duration::from_millis(250));
|
||||
tracing::debug!("[screen_intelligence] capture worker started");
|
||||
|
||||
loop {
|
||||
tick.tick().await;
|
||||
@@ -651,10 +708,16 @@ impl AccessibilityEngine {
|
||||
let state = self.inner.lock().await;
|
||||
match &state.session {
|
||||
Some(session) => now_ms() >= session.expires_at_ms,
|
||||
None => return,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] capture worker: no session, exiting"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if should_stop {
|
||||
tracing::debug!("[screen_intelligence] capture worker: TTL expired, stopping");
|
||||
self.stop_session_internal("ttl_expired".to_string()).await;
|
||||
return;
|
||||
}
|
||||
@@ -678,6 +741,10 @@ impl AccessibilityEngine {
|
||||
.map(|ctx| self.should_capture_context(ctx, &config))
|
||||
.unwrap_or(false);
|
||||
if !is_allowed {
|
||||
tracing::trace!(
|
||||
"[screen_intelligence] capture skipped: context blocked by denylist (app={:?})",
|
||||
context.as_ref().and_then(|c| c.app_name.as_deref())
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -699,12 +766,21 @@ impl AccessibilityEngine {
|
||||
"baseline"
|
||||
};
|
||||
|
||||
let capture_result = capture_screen_image_ref_for_context(context.as_ref());
|
||||
if let Err(ref e) = capture_result {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] capture failed (reason={}): {}",
|
||||
reason,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
let frame = CaptureFrame {
|
||||
captured_at_ms: now,
|
||||
reason: reason.to_string(),
|
||||
app_name: context.as_ref().and_then(|c| c.app_name.clone()),
|
||||
window_title: context.as_ref().and_then(|c| c.window_title.clone()),
|
||||
image_ref: capture_screen_image_ref_for_context(context.as_ref()).ok(),
|
||||
image_ref: capture_result.ok(),
|
||||
};
|
||||
push_ephemeral_frame(&mut session.frames, frame.clone());
|
||||
session.last_capture_at_ms = Some(now);
|
||||
@@ -727,12 +803,20 @@ impl AccessibilityEngine {
|
||||
self: Arc<Self>,
|
||||
mut rx: tokio::sync::mpsc::UnboundedReceiver<CaptureFrame>,
|
||||
) {
|
||||
tracing::debug!("[screen_intelligence] vision worker started");
|
||||
|
||||
while let Some(frame) = rx.recv().await {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] vision worker: received frame (app={:?}, reason={})",
|
||||
frame.app_name,
|
||||
frame.reason
|
||||
);
|
||||
{
|
||||
let mut state = self.inner.lock().await;
|
||||
if let Some(session) = state.session.as_mut() {
|
||||
session.vision_state = "processing".to_string();
|
||||
} else {
|
||||
tracing::debug!("[screen_intelligence] vision worker: no session, exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -746,6 +830,11 @@ impl AccessibilityEngine {
|
||||
session.vision_queue_depth = session.vision_queue_depth.saturating_sub(1);
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
tracing::debug!(
|
||||
"[screen_intelligence] vision analysis complete: confidence={:.2} notes={}",
|
||||
summary.confidence,
|
||||
&summary.actionable_notes[..summary.actionable_notes.len().min(80)]
|
||||
);
|
||||
push_ephemeral_vision_summary(&mut session.vision_summaries, summary.clone());
|
||||
session.last_vision_at_ms = Some(summary.captured_at_ms);
|
||||
session.last_vision_summary = Some(summary.actionable_notes.clone());
|
||||
@@ -756,6 +845,7 @@ impl AccessibilityEngine {
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!("[screen_intelligence] vision analysis failed: {err}");
|
||||
session.vision_state = "error".to_string();
|
||||
state.last_error = Some(err);
|
||||
}
|
||||
@@ -801,14 +891,18 @@ impl AccessibilityEngine {
|
||||
state.last_event = Some(format!("session_stopped:{reason}"));
|
||||
}
|
||||
|
||||
fn rule_matches_context(&self, ctx: &AppContext, rules: &[String]) -> bool {
|
||||
pub(crate) fn rule_matches_context(&self, ctx: &AppContext, rules: &[String]) -> bool {
|
||||
let compound = ctx.as_compound_text();
|
||||
rules
|
||||
.iter()
|
||||
.any(|d| !d.trim().is_empty() && compound.contains(&d.to_lowercase()))
|
||||
}
|
||||
|
||||
fn should_capture_context(&self, ctx: &AppContext, config: &ScreenIntelligenceConfig) -> bool {
|
||||
pub(crate) fn should_capture_context(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
config: &ScreenIntelligenceConfig,
|
||||
) -> bool {
|
||||
let blacklisted = self.rule_matches_context(ctx, &config.denylist);
|
||||
let whitelisted = self.rule_matches_context(ctx, &config.allowlist);
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::screen_intelligence::{
|
||||
self, AccessibilityStatus, CaptureImageRefResult, CaptureNowResult, InputActionParams,
|
||||
InputActionResult, PermissionRequestParams, PermissionState, PermissionStatus, SessionStatus,
|
||||
StartSessionParams, StopSessionParams, VisionFlushResult, VisionRecentResult,
|
||||
self, AccessibilityStatus, CaptureImageRefResult, CaptureNowResult, CaptureTestResult,
|
||||
InputActionParams, InputActionResult, PermissionRequestParams, PermissionState,
|
||||
PermissionStatus, SessionStatus, StartSessionParams, StopSessionParams, VisionFlushResult,
|
||||
VisionRecentResult,
|
||||
};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
@@ -176,3 +177,11 @@ pub async fn accessibility_vision_flush() -> Result<RpcOutcome<VisionFlushResult
|
||||
"screen intelligence vision flush completed",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn accessibility_capture_test() -> Result<RpcOutcome<CaptureTestResult>, String> {
|
||||
let result: CaptureTestResult = screen_intelligence::global_engine().capture_test().await;
|
||||
Ok(RpcOutcome::single_log(
|
||||
result,
|
||||
"screen intelligence capture test completed",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("input_action"),
|
||||
schemas("vision_recent"),
|
||||
schemas("vision_flush"),
|
||||
schemas("capture_test"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -71,6 +72,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("vision_flush"),
|
||||
handler: handle_vision_flush,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("capture_test"),
|
||||
handler: handle_capture_test,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -179,6 +184,16 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("result", "Vision flush payload.")],
|
||||
},
|
||||
"capture_test" => ControllerSchema {
|
||||
namespace: "screen_intelligence",
|
||||
function: "capture_test",
|
||||
description: "Standalone capture test with diagnostics (no session required).",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output(
|
||||
"result",
|
||||
"Capture test result with diagnostics.",
|
||||
)],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "screen_intelligence",
|
||||
function: "unknown",
|
||||
@@ -276,6 +291,12 @@ fn handle_vision_flush(_params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_capture_test(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
to_json(crate::openhuman::screen_intelligence::rpc::accessibility_capture_test().await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,243 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
use super::capture::parse_foreground_output;
|
||||
use super::context::AppContext;
|
||||
use super::engine::{global_engine, AccessibilityEngine, EngineState};
|
||||
use super::helpers::validate_input_action;
|
||||
use super::types::{InputActionParams, StartSessionParams};
|
||||
use super::helpers::{
|
||||
generate_suggestions, parse_vision_summary_output, truncate_tail, validate_input_action,
|
||||
};
|
||||
use super::types::{CaptureFrame, InputActionParams, StartSessionParams};
|
||||
use crate::openhuman::config::ScreenIntelligenceConfig;
|
||||
|
||||
// ── parse_foreground_output ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_valid_6_lines() {
|
||||
let stdout = "Safari\nGitHub - Pull Requests\n100\n200\n1400\n900\n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert_eq!(ctx.app_name.as_deref(), Some("Safari"));
|
||||
assert_eq!(ctx.window_title.as_deref(), Some("GitHub - Pull Requests"));
|
||||
let bounds = ctx.bounds.unwrap();
|
||||
assert_eq!(
|
||||
(bounds.x, bounds.y, bounds.width, bounds.height),
|
||||
(100, 200, 1400, 900)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_missing_bounds() {
|
||||
let stdout = "Finder\nDesktop\n\n\n\n\n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert_eq!(ctx.app_name.as_deref(), Some("Finder"));
|
||||
assert_eq!(ctx.window_title.as_deref(), Some("Desktop"));
|
||||
assert!(ctx.bounds.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_empty_app_name() {
|
||||
let stdout = "\nSome Window\n0\n0\n800\n600\n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert!(ctx.app_name.is_none());
|
||||
assert_eq!(ctx.window_title.as_deref(), Some("Some Window"));
|
||||
assert!(ctx.bounds.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_non_numeric_coords() {
|
||||
let stdout = "Terminal\nzsh\nabc\ndef\nghi\njkl\n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert_eq!(ctx.app_name.as_deref(), Some("Terminal"));
|
||||
assert!(ctx.bounds.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_extra_whitespace() {
|
||||
let stdout = " Code \n main.rs \n 50 \n 75 \n 1200 \n 800 \n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert_eq!(ctx.app_name.as_deref(), Some("Code"));
|
||||
assert_eq!(ctx.window_title.as_deref(), Some("main.rs"));
|
||||
let bounds = ctx.bounds.unwrap();
|
||||
assert_eq!(
|
||||
(bounds.x, bounds.y, bounds.width, bounds.height),
|
||||
(50, 75, 1200, 800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_single_line() {
|
||||
let stdout = "OnlyAppName\n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert_eq!(ctx.app_name.as_deref(), Some("OnlyAppName"));
|
||||
assert!(ctx.window_title.is_none());
|
||||
assert!(ctx.bounds.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_zero_size_bounds() {
|
||||
let stdout = "App\nWindow\n100\n200\n0\n0\n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert!(ctx.bounds.is_none(), "zero-size bounds should be None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_foreground_output_negative_size() {
|
||||
let stdout = "App\nWindow\n100\n200\n-1\n600\n";
|
||||
let ctx = parse_foreground_output(stdout).unwrap();
|
||||
assert!(
|
||||
ctx.bounds.is_none(),
|
||||
"negative width should yield None bounds"
|
||||
);
|
||||
}
|
||||
|
||||
// ── parse_vision_summary_output ─────────────────────────────────────────
|
||||
|
||||
fn test_frame() -> CaptureFrame {
|
||||
CaptureFrame {
|
||||
captured_at_ms: 1700000000000,
|
||||
reason: "test".to_string(),
|
||||
app_name: Some("TestApp".to_string()),
|
||||
window_title: Some("TestWindow".to_string()),
|
||||
image_ref: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vision_valid_json() {
|
||||
let raw = r#"{"ui_state": "editor open", "key_text": "fn main()", "actionable_notes": "Consider adding tests", "confidence": 0.85}"#;
|
||||
let summary = parse_vision_summary_output(test_frame(), raw);
|
||||
assert_eq!(summary.ui_state, "editor open");
|
||||
assert_eq!(summary.key_text, "fn main()");
|
||||
assert_eq!(summary.actionable_notes, "Consider adding tests");
|
||||
assert!((summary.confidence - 0.85).abs() < 0.01);
|
||||
assert_eq!(summary.app_name.as_deref(), Some("TestApp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vision_malformed_json_falls_back() {
|
||||
let raw = "this is not json at all";
|
||||
let summary = parse_vision_summary_output(test_frame(), raw);
|
||||
assert_eq!(summary.ui_state, "UI state unavailable");
|
||||
assert!(summary.actionable_notes.contains("this is not json at all"));
|
||||
assert!((summary.confidence - 0.66).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vision_missing_fields() {
|
||||
let raw = r#"{"ui_state": "active"}"#;
|
||||
let summary = parse_vision_summary_output(test_frame(), raw);
|
||||
assert_eq!(summary.ui_state, "active");
|
||||
assert_eq!(summary.key_text, "");
|
||||
assert!((summary.confidence - 0.66).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vision_confidence_clamping() {
|
||||
let raw = r#"{"confidence": 1.5}"#;
|
||||
let summary = parse_vision_summary_output(test_frame(), raw);
|
||||
assert!((summary.confidence - 1.0).abs() < 0.01);
|
||||
|
||||
let raw2 = r#"{"confidence": -0.5}"#;
|
||||
let summary2 = parse_vision_summary_output(test_frame(), raw2);
|
||||
assert!((summary2.confidence - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vision_empty_strings_use_fallback() {
|
||||
let raw = r#"{"ui_state": "", "actionable_notes": ""}"#;
|
||||
let summary = parse_vision_summary_output(test_frame(), raw);
|
||||
assert_eq!(summary.ui_state, "UI state unavailable");
|
||||
// actionable_notes falls back to truncated raw when empty
|
||||
assert!(!summary.actionable_notes.is_empty());
|
||||
}
|
||||
|
||||
// ── should_capture_context / rule_matches_context ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn denylist_blocks_matching_context() {
|
||||
let engine = AccessibilityEngine {
|
||||
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
|
||||
};
|
||||
let config = ScreenIntelligenceConfig {
|
||||
denylist: vec!["1password".to_string(), "keychain".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = AppContext {
|
||||
app_name: Some("1Password 8".to_string()),
|
||||
window_title: Some("Vault".to_string()),
|
||||
bounds: None,
|
||||
};
|
||||
assert!(
|
||||
!engine.should_capture_context(&ctx, &config),
|
||||
"1password should be blocked by denylist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denylist_allows_non_matching_context() {
|
||||
let engine = AccessibilityEngine {
|
||||
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
|
||||
};
|
||||
let config = ScreenIntelligenceConfig {
|
||||
denylist: vec!["1password".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = AppContext {
|
||||
app_name: Some("Safari".to_string()),
|
||||
window_title: Some("GitHub".to_string()),
|
||||
bounds: None,
|
||||
};
|
||||
assert!(engine.should_capture_context(&ctx, &config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitelist_only_mode_blocks_unlisted() {
|
||||
let engine = AccessibilityEngine {
|
||||
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
|
||||
};
|
||||
let config = ScreenIntelligenceConfig {
|
||||
policy_mode: "whitelist_only".to_string(),
|
||||
allowlist: vec!["code".to_string()],
|
||||
denylist: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = AppContext {
|
||||
app_name: Some("Safari".to_string()),
|
||||
window_title: Some("Web".to_string()),
|
||||
bounds: None,
|
||||
};
|
||||
assert!(
|
||||
!engine.should_capture_context(&ctx, &config),
|
||||
"whitelist_only should block unlisted apps"
|
||||
);
|
||||
|
||||
let ctx_allowed = AppContext {
|
||||
app_name: Some("Visual Studio Code".to_string()),
|
||||
window_title: Some("main.rs".to_string()),
|
||||
bounds: None,
|
||||
};
|
||||
assert!(engine.should_capture_context(&ctx_allowed, &config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denylist_matching_is_case_insensitive() {
|
||||
let engine = AccessibilityEngine {
|
||||
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
|
||||
};
|
||||
let config = ScreenIntelligenceConfig {
|
||||
denylist: vec!["Keychain".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = AppContext {
|
||||
app_name: Some("KEYCHAIN Access".to_string()),
|
||||
window_title: None,
|
||||
bounds: None,
|
||||
};
|
||||
assert!(!engine.should_capture_context(&ctx, &config));
|
||||
}
|
||||
|
||||
// ── validate_input_action ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validates_coordinates_and_actions() {
|
||||
let ok = InputActionParams {
|
||||
@@ -43,6 +275,116 @@ fn validates_coordinates_and_actions() {
|
||||
assert!(validate_input_action(&unsupported).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_key_type_empty_text() {
|
||||
let params = InputActionParams {
|
||||
action: "key_type".to_string(),
|
||||
x: None,
|
||||
y: None,
|
||||
button: None,
|
||||
text: Some("".to_string()),
|
||||
key: None,
|
||||
modifiers: None,
|
||||
};
|
||||
assert!(validate_input_action(¶ms).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_key_press_whitespace_key() {
|
||||
let params = InputActionParams {
|
||||
action: "key_press".to_string(),
|
||||
x: None,
|
||||
y: None,
|
||||
button: None,
|
||||
text: None,
|
||||
key: Some(" ".to_string()),
|
||||
modifiers: None,
|
||||
};
|
||||
assert!(validate_input_action(¶ms).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_coordinates_at_boundaries() {
|
||||
// 0,0 should be valid
|
||||
let zero = InputActionParams {
|
||||
action: "mouse_move".to_string(),
|
||||
x: Some(0),
|
||||
y: Some(0),
|
||||
button: None,
|
||||
text: None,
|
||||
key: None,
|
||||
modifiers: None,
|
||||
};
|
||||
assert!(validate_input_action(&zero).is_ok());
|
||||
|
||||
// 10000,10000 should be valid
|
||||
let max = InputActionParams {
|
||||
action: "mouse_click".to_string(),
|
||||
x: Some(10000),
|
||||
y: Some(10000),
|
||||
button: None,
|
||||
text: None,
|
||||
key: None,
|
||||
modifiers: None,
|
||||
};
|
||||
assert!(validate_input_action(&max).is_ok());
|
||||
|
||||
// 10001 should be invalid
|
||||
let over = InputActionParams {
|
||||
action: "mouse_move".to_string(),
|
||||
x: Some(10001),
|
||||
y: Some(0),
|
||||
button: None,
|
||||
text: None,
|
||||
key: None,
|
||||
modifiers: None,
|
||||
};
|
||||
assert!(validate_input_action(&over).is_err());
|
||||
}
|
||||
|
||||
// ── truncate_tail ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn truncate_tail_within_limit() {
|
||||
assert_eq!(truncate_tail("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_tail_at_limit() {
|
||||
assert_eq!(truncate_tail("hello", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_tail_over_limit() {
|
||||
assert_eq!(truncate_tail("hello world", 5), "world");
|
||||
}
|
||||
|
||||
// ── generate_suggestions ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn suggestions_for_known_keywords() {
|
||||
let results = generate_suggestions("thanks", 3);
|
||||
assert!(!results.is_empty());
|
||||
assert!(results[0].value.contains("help"));
|
||||
|
||||
let results2 = generate_suggestions("let's schedule a meeting", 3);
|
||||
assert!(results2.iter().any(|s| s.value.contains("10am")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_for_empty_context() {
|
||||
let results = generate_suggestions("", 3);
|
||||
assert!(!results.is_empty(), "should return default suggestion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_max_results_clamping() {
|
||||
let results = generate_suggestions("thanks for the meeting, let's ship", 1);
|
||||
assert_eq!(results.len(), 1, "should respect max_results");
|
||||
}
|
||||
|
||||
// ── Session lifecycle tests (macOS-gated) ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_lifecycle_transitions_and_ttl_expiry() {
|
||||
let engine = Arc::new(AccessibilityEngine {
|
||||
@@ -156,3 +498,25 @@ async fn capture_scheduler_adds_baseline_frames() {
|
||||
|
||||
let _ = engine.stop_session(Some("test_end".to_string())).await;
|
||||
}
|
||||
|
||||
// ── capture_test (standalone, no session needed) ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_test_returns_diagnostics() {
|
||||
let engine = Arc::new(AccessibilityEngine {
|
||||
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
|
||||
});
|
||||
|
||||
let result = engine.capture_test().await;
|
||||
// On any platform this should not panic — it may fail gracefully.
|
||||
assert!(result.timing_ms < 30000, "capture test should not hang");
|
||||
assert!(
|
||||
result.capture_mode == "windowed" || result.capture_mode == "fullscreen",
|
||||
"capture_mode should be windowed or fullscreen"
|
||||
);
|
||||
|
||||
if !cfg!(target_os = "macos") {
|
||||
assert!(!result.ok, "should fail on non-macOS");
|
||||
assert!(result.error.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,3 +178,24 @@ pub struct AutocompleteCommitParams {
|
||||
pub struct AutocompleteCommitResult {
|
||||
pub committed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppContextInfo {
|
||||
pub app_name: Option<String>,
|
||||
pub window_title: Option<String>,
|
||||
pub bounds_x: Option<i32>,
|
||||
pub bounds_y: Option<i32>,
|
||||
pub bounds_width: Option<i32>,
|
||||
pub bounds_height: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureTestResult {
|
||||
pub ok: bool,
|
||||
pub capture_mode: String,
|
||||
pub context: Option<AppContextInfo>,
|
||||
pub image_ref: Option<String>,
|
||||
pub bytes_estimate: Option<usize>,
|
||||
pub error: Option<String>,
|
||||
pub timing_ms: u64,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Persistent skill enable/disable preferences.
|
||||
//! Persistent skill preferences (enable/disable, setup completion).
|
||||
//!
|
||||
//! Stores user preferences in `{app_data_dir}/skill-preferences.json`.
|
||||
//! These preferences override the manifest's `auto_start` field,
|
||||
@@ -13,9 +13,11 @@ use std::path::PathBuf;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillPreference {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub setup_complete: bool,
|
||||
}
|
||||
|
||||
/// Persistent store for skill enable/disable preferences.
|
||||
/// Persistent store for skill preferences.
|
||||
pub struct PreferencesStore {
|
||||
path: PathBuf,
|
||||
cache: RwLock<HashMap<String, SkillPreference>>,
|
||||
@@ -42,7 +44,6 @@ impl PreferencesStore {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Start empty if file doesn't exist or is corrupt
|
||||
*self.cache.write() = HashMap::new();
|
||||
}
|
||||
|
||||
@@ -50,7 +51,6 @@ impl PreferencesStore {
|
||||
fn save(&self) {
|
||||
let cache = self.cache.read();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&*cache) {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
@@ -60,6 +60,30 @@ impl PreferencesStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_default(&self, skill_id: &str) -> SkillPreference {
|
||||
self.cache
|
||||
.read()
|
||||
.get(skill_id)
|
||||
.cloned()
|
||||
.unwrap_or(SkillPreference {
|
||||
enabled: false,
|
||||
setup_complete: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn update<F: FnOnce(&mut SkillPreference)>(&self, skill_id: &str, f: F) {
|
||||
let mut cache = self.cache.write();
|
||||
let pref = cache
|
||||
.entry(skill_id.to_string())
|
||||
.or_insert(SkillPreference {
|
||||
enabled: false,
|
||||
setup_complete: false,
|
||||
});
|
||||
f(pref);
|
||||
drop(cache);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// Check if a skill has a user preference set. Returns `None` if no preference exists.
|
||||
pub fn is_enabled(&self, skill_id: &str) -> Option<bool> {
|
||||
self.cache.read().get(skill_id).map(|p| p.enabled)
|
||||
@@ -67,10 +91,22 @@ impl PreferencesStore {
|
||||
|
||||
/// Set the enabled preference for a skill. Persists immediately.
|
||||
pub fn set_enabled(&self, skill_id: &str, enabled: bool) {
|
||||
self.cache
|
||||
.write()
|
||||
.insert(skill_id.to_string(), SkillPreference { enabled });
|
||||
self.save();
|
||||
self.update(skill_id, |p| p.enabled = enabled);
|
||||
}
|
||||
|
||||
/// Get whether a skill's setup has been completed.
|
||||
pub fn is_setup_complete(&self, skill_id: &str) -> bool {
|
||||
self.get_or_default(skill_id).setup_complete
|
||||
}
|
||||
|
||||
/// Set the setup completion flag for a skill. Persists immediately.
|
||||
pub fn set_setup_complete(&self, skill_id: &str, complete: bool) {
|
||||
self.update(skill_id, |p| p.setup_complete = complete);
|
||||
log::info!(
|
||||
"[preferences] setup_complete for '{}' set to {}",
|
||||
skill_id,
|
||||
complete
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all preferences as a map.
|
||||
@@ -79,7 +115,6 @@ impl PreferencesStore {
|
||||
}
|
||||
|
||||
/// Resolve whether a skill should start, considering user preference and manifest default.
|
||||
/// User preference always wins; falls back to `manifest_auto_start` if no preference set.
|
||||
pub fn resolve_should_start(&self, skill_id: &str, manifest_auto_start: bool) -> bool {
|
||||
match self.is_enabled(skill_id) {
|
||||
Some(enabled) => enabled,
|
||||
|
||||
@@ -459,14 +459,36 @@ impl RuntimeEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all registered skills.
|
||||
/// List all registered skills, enriched with persistent preferences.
|
||||
pub fn list_skills(&self) -> Vec<SkillSnapshot> {
|
||||
self.registry.list_skills()
|
||||
let mut snapshots = self.registry.list_skills();
|
||||
for snap in &mut snapshots {
|
||||
self.enrich_snapshot(snap);
|
||||
}
|
||||
snapshots
|
||||
}
|
||||
|
||||
/// Get the state of a specific skill.
|
||||
/// Get the state of a specific skill, enriched with persistent preferences.
|
||||
pub fn get_skill_state(&self, skill_id: &str) -> Option<SkillSnapshot> {
|
||||
self.registry.get_skill(skill_id)
|
||||
self.registry.get_skill(skill_id).map(|mut snap| {
|
||||
self.enrich_snapshot(&mut snap);
|
||||
snap
|
||||
})
|
||||
}
|
||||
|
||||
/// Populate `setup_complete` from preferences and re-derive `connection_status`.
|
||||
fn enrich_snapshot(&self, snap: &mut SkillSnapshot) {
|
||||
snap.setup_complete = self.preferences.is_setup_complete(&snap.skill_id);
|
||||
snap.connection_status = crate::openhuman::skills::types::derive_connection_status(
|
||||
snap.status,
|
||||
snap.setup_complete,
|
||||
&snap.state,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the preferences store (for RPC handlers that need to set setup_complete).
|
||||
pub fn preferences(&self) -> &PreferencesStore {
|
||||
&self.preferences
|
||||
}
|
||||
|
||||
/// Call a tool on a skill.
|
||||
|
||||
@@ -31,6 +31,8 @@ impl QjsSkillInstance {
|
||||
}
|
||||
|
||||
/// Take a snapshot of the current skill state.
|
||||
/// Note: `setup_complete` and `connection_status` are populated later
|
||||
/// by RuntimeEngine::enrich_snapshot() which has access to PreferencesStore.
|
||||
pub fn snapshot(&self) -> SkillSnapshot {
|
||||
let state = self.state.read();
|
||||
SkillSnapshot {
|
||||
@@ -40,6 +42,8 @@ impl QjsSkillInstance {
|
||||
tools: state.tools.clone(),
|
||||
error: state.error.clone(),
|
||||
state: state.published_state.clone(),
|
||||
setup_complete: false,
|
||||
connection_status: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
skills_schema("enable"),
|
||||
skills_schema("disable"),
|
||||
skills_schema("is_enabled"),
|
||||
skills_schema("set_setup_complete"),
|
||||
skills_schema("get_all_snapshots"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -152,6 +154,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: skills_schema("is_enabled"),
|
||||
handler: handle_skills_is_enabled,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skills_schema("set_setup_complete"),
|
||||
handler: handle_skills_set_setup_complete,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skills_schema("get_all_snapshots"),
|
||||
handler: handle_skills_get_all_snapshots,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -408,7 +418,8 @@ fn skills_schema(function: &str) -> ControllerSchema {
|
||||
"rpc" => ControllerSchema {
|
||||
namespace: "skills",
|
||||
function: "rpc",
|
||||
description: "Send an arbitrary RPC method to a running skill (e.g. oauth/complete, skill/sync).",
|
||||
description:
|
||||
"Send an arbitrary RPC method to a running skill (e.g. oauth/complete, skill/sync).",
|
||||
inputs: vec![
|
||||
skill_id_input("The target skill ID."),
|
||||
FieldSchema {
|
||||
@@ -434,7 +445,8 @@ fn skills_schema(function: &str) -> ControllerSchema {
|
||||
"discover" => ControllerSchema {
|
||||
namespace: "skills",
|
||||
function: "discover",
|
||||
description: "Discover all available skill manifests from source and workspace directories.",
|
||||
description:
|
||||
"Discover all available skill manifests from source and workspace directories.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
@@ -549,6 +561,39 @@ fn skills_schema(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"set_setup_complete" => ControllerSchema {
|
||||
namespace: "skills",
|
||||
function: "set_setup_complete",
|
||||
description: "Persist setup completion flag for a skill.",
|
||||
inputs: vec![
|
||||
skill_id_input("The skill ID."),
|
||||
FieldSchema {
|
||||
name: "complete",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether setup is complete.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Acknowledgment.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"get_all_snapshots" => ControllerSchema {
|
||||
namespace: "skills",
|
||||
function: "get_all_snapshots",
|
||||
description:
|
||||
"Get all skill snapshots enriched with setup_complete and connection_status.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of enriched skill snapshots.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "skills",
|
||||
function: "unknown",
|
||||
@@ -667,14 +712,6 @@ struct CallToolParams {
|
||||
arguments: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SkillRpcParams {
|
||||
skill_id: String,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn handle_skills_start(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p: SkillIdParams =
|
||||
@@ -759,6 +796,14 @@ fn handle_skills_call_tool(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SkillRpcParams {
|
||||
skill_id: String,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn handle_skills_rpc(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p: SkillRpcParams =
|
||||
@@ -871,6 +916,36 @@ fn handle_skills_is_enabled(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetSetupCompleteParams {
|
||||
skill_id: String,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
fn handle_skills_set_setup_complete(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p: SetSetupCompleteParams =
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?;
|
||||
let engine = require_engine()?;
|
||||
engine
|
||||
.preferences()
|
||||
.set_setup_complete(&p.skill_id, p.complete);
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"skill_id": p.skill_id,
|
||||
"setup_complete": p.complete
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_skills_get_all_snapshots(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let engine = require_engine()?;
|
||||
let snapshots = engine.list_skills();
|
||||
serde_json::to_value(&snapshots).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -69,14 +69,7 @@ impl SkillRegistry {
|
||||
.iter()
|
||||
.map(|(skill_id, entry)| {
|
||||
let state = entry.state.read();
|
||||
SkillSnapshot {
|
||||
skill_id: skill_id.clone(),
|
||||
name: entry.config.name.clone(),
|
||||
status: state.status,
|
||||
tools: state.tools.clone(),
|
||||
error: state.error.clone(),
|
||||
state: state.published_state.clone(),
|
||||
}
|
||||
Self::build_snapshot(skill_id, entry, &state)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -86,17 +79,30 @@ impl SkillRegistry {
|
||||
let skills = self.skills.read();
|
||||
skills.get(skill_id).map(|entry| {
|
||||
let state = entry.state.read();
|
||||
SkillSnapshot {
|
||||
skill_id: skill_id.to_string(),
|
||||
name: entry.config.name.clone(),
|
||||
status: state.status,
|
||||
tools: state.tools.clone(),
|
||||
error: state.error.clone(),
|
||||
state: state.published_state.clone(),
|
||||
}
|
||||
Self::build_snapshot(skill_id, entry, &state)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_snapshot(skill_id: &str, entry: &RegistryEntry, state: &SkillState) -> SkillSnapshot {
|
||||
use crate::openhuman::skills::types::derive_connection_status;
|
||||
|
||||
// setup_complete is populated later by the caller who has access to PreferencesStore
|
||||
let setup_complete = false;
|
||||
let connection_status =
|
||||
derive_connection_status(state.status, setup_complete, &state.published_state);
|
||||
|
||||
SkillSnapshot {
|
||||
skill_id: skill_id.to_string(),
|
||||
name: entry.config.name.clone(),
|
||||
status: state.status,
|
||||
tools: state.tools.clone(),
|
||||
error: state.error.clone(),
|
||||
state: state.published_state.clone(),
|
||||
setup_complete,
|
||||
connection_status,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the status of a skill.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_status(&self, skill_id: &str) -> Option<SkillStatus> {
|
||||
|
||||
@@ -147,6 +147,68 @@ pub struct SkillSnapshot {
|
||||
/// Arbitrary state the skill has published for the frontend.
|
||||
#[serde(default)]
|
||||
pub state: HashMap<String, serde_json::Value>,
|
||||
/// Whether the skill's setup/OAuth flow has been completed (persisted).
|
||||
#[serde(default)]
|
||||
pub setup_complete: bool,
|
||||
/// Derived connection status for the frontend UI.
|
||||
#[serde(default)]
|
||||
pub connection_status: String,
|
||||
}
|
||||
|
||||
/// Derive a unified connection status string from skill state.
|
||||
/// Mirrors the logic in `app/src/lib/skills/hooks.ts:deriveConnectionStatus`.
|
||||
pub fn derive_connection_status(
|
||||
status: SkillStatus,
|
||||
setup_complete: bool,
|
||||
published_state: &HashMap<String, serde_json::Value>,
|
||||
) -> String {
|
||||
match status {
|
||||
SkillStatus::Pending | SkillStatus::Stopped | SkillStatus::Stopping => {
|
||||
return "offline".to_string()
|
||||
}
|
||||
SkillStatus::Error => return "error".to_string(),
|
||||
SkillStatus::Initializing => return "connecting".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let conn = published_state
|
||||
.get("connection_status")
|
||||
.and_then(|v| v.as_str());
|
||||
let auth = published_state.get("auth_status").and_then(|v| v.as_str());
|
||||
|
||||
// No self-reported state — derive from lifecycle + setup
|
||||
if conn.is_none() && auth.is_none() {
|
||||
if setup_complete && matches!(status, SkillStatus::Running) {
|
||||
return "connected".to_string();
|
||||
}
|
||||
if !setup_complete {
|
||||
return "setup_required".to_string();
|
||||
}
|
||||
return "connecting".to_string();
|
||||
}
|
||||
|
||||
if conn == Some("error") || auth == Some("error") {
|
||||
return "error".to_string();
|
||||
}
|
||||
if conn == Some("connecting") || auth == Some("authenticating") {
|
||||
return "connecting".to_string();
|
||||
}
|
||||
if conn == Some("connected") {
|
||||
if auth.is_none() || auth == Some("authenticated") {
|
||||
return "connected".to_string();
|
||||
}
|
||||
if auth == Some("not_authenticated") {
|
||||
return "not_authenticated".to_string();
|
||||
}
|
||||
}
|
||||
if conn == Some("disconnected") {
|
||||
if setup_complete {
|
||||
return "disconnected".to_string();
|
||||
}
|
||||
return "setup_required".to_string();
|
||||
}
|
||||
|
||||
"connecting".to_string()
|
||||
}
|
||||
|
||||
/// Configuration for a skill instance, derived from its manifest.
|
||||
|
||||
Reference in New Issue
Block a user