mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
refactor(settings): restructure settings page for better UX (#494)
* refactor(settings): restructure settings page for better UX Reorganize settings into 4 clean sections (Account & Billing, Features, AI & Models, Developer Options) and extract developer-oriented options from user-facing panels into dedicated debug panels. - Merge Account & Security + Billing into Account & Billing section - Rename Automation & Channels to Features; add Tools, Voice here - Simplify AI & Skills to AI & Models (just Local AI Model) - Expand Developer Options from 4 to 10 items - Create 4 debug panels: ScreenAwarenessDebug, AutocompleteDebug, VoiceDebug, LocalModelDebug - Simplify 4 user panels by stripping dev knobs (FPS, debounce timers, test harnesses, diagnostics, etc.) - Delete AccessibilityPanel (merged into Screen Awareness) - Add "Advanced settings" link in each simplified panel - Update navigation breadcrumbs for new hierarchy * fix(settings): address PR review feedback on debug panels - Guard null result from openhumanAutocompleteDebugFocus() - Block save/start until full config loaded in AutocompletePanel - Separate poll errors from action errors in LocalModelDebugPanel - Replace useEffect setState with render-time one-shot init in ScreenAwarenessDebugPanel to avoid set-state-in-effect lint rule - Remove unused openhumanLocalAiAssetsStatus call from VoiceDebugPanel * fix(settings): update tests for simplified panel structure - Update ScreenIntelligencePanel test for renamed title, button text, and platform message - Rewrite AutocompletePanel test for simplified panel (dev options moved to AutocompleteDebugPanel)
This commit is contained in:
@@ -78,54 +78,42 @@ const SettingsHome = () => {
|
||||
const groupedMenuItems = [
|
||||
{
|
||||
id: 'account',
|
||||
title: 'Account & Security',
|
||||
description: 'Recovery phrase, team management, and linked account access',
|
||||
title: 'Account & Billing',
|
||||
description: 'Recovery phrase, team, connections, billing, and privacy',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5v14" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('account'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
title: 'Billing & Usage',
|
||||
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
|
||||
id: 'features',
|
||||
title: 'Features',
|
||||
description: 'Screen awareness, autocomplete, voice, messaging, and tools',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('billing'),
|
||||
onClick: () => navigateToSettings('features'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'automation',
|
||||
title: 'Automation & Channels',
|
||||
description: 'Accessibility, screen intelligence, messaging, autocomplete, and cron jobs',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5h18v12H3zM8 21h8m-4-4v4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('automation'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'ai-tools',
|
||||
title: 'AI & Skills',
|
||||
description: 'Local model runtime, AI configuration, and skills behavior',
|
||||
id: 'ai-models',
|
||||
title: 'AI & Models',
|
||||
description: 'Local AI model setup and downloads',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -136,13 +124,13 @@ const SettingsHome = () => {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('ai-tools'),
|
||||
onClick: () => navigateToSettings('ai-models'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'developer-options',
|
||||
title: 'Developer Options',
|
||||
description: 'Diagnostic tools, console access, webhooks, and memory inspection',
|
||||
description: 'Diagnostics, debug panels, webhooks, and memory inspection',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
export type SettingsRoute =
|
||||
| 'home'
|
||||
| 'account'
|
||||
| 'automation'
|
||||
| 'ai-tools'
|
||||
| 'features'
|
||||
| 'ai-models'
|
||||
| 'connections'
|
||||
| 'messaging'
|
||||
| 'cron-jobs'
|
||||
@@ -17,15 +17,19 @@ export type SettingsRoute =
|
||||
| 'team-members'
|
||||
| 'team-invites'
|
||||
| 'developer-options'
|
||||
| 'accessibility'
|
||||
| 'ai'
|
||||
| 'local-model'
|
||||
| 'voice'
|
||||
| 'tools'
|
||||
| 'memory-data'
|
||||
| 'memory-debug'
|
||||
| 'recovery-phrase'
|
||||
| 'webhooks-debug'
|
||||
| 'agent-chat';
|
||||
| 'agent-chat'
|
||||
| 'screen-awareness-debug'
|
||||
| 'autocomplete-debug'
|
||||
| 'voice-debug'
|
||||
| 'local-model-debug';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
@@ -69,20 +73,24 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/team/invites')) return 'team-invites';
|
||||
if (path.includes('/settings/team')) return 'team';
|
||||
if (path.includes('/settings/account')) return 'account';
|
||||
if (path.includes('/settings/automation')) return 'automation';
|
||||
if (path.includes('/settings/ai-tools')) return 'ai-tools';
|
||||
if (path.includes('/settings/features')) return 'features';
|
||||
if (path.includes('/settings/ai-models')) return 'ai-models';
|
||||
if (path.includes('/settings/connections')) return 'connections';
|
||||
if (path.includes('/settings/messaging')) return 'messaging';
|
||||
if (path.includes('/settings/cron-jobs')) return 'cron-jobs';
|
||||
if (path.includes('/settings/screen-awareness-debug')) return 'screen-awareness-debug';
|
||||
if (path.includes('/settings/screen-intelligence')) return 'screen-intelligence';
|
||||
if (path.includes('/settings/autocomplete-debug')) return 'autocomplete-debug';
|
||||
if (path.includes('/settings/autocomplete')) return 'autocomplete';
|
||||
if (path.includes('/settings/privacy')) return 'privacy';
|
||||
if (path.includes('/settings/billing')) return 'billing';
|
||||
if (path.includes('/settings/developer-options')) return 'developer-options';
|
||||
if (path.includes('/settings/accessibility')) return 'accessibility';
|
||||
if (path.includes('/settings/ai')) return 'ai';
|
||||
if (path.includes('/settings/local-model-debug')) return 'local-model-debug';
|
||||
if (path.includes('/settings/local-model')) return 'local-model';
|
||||
if (path.includes('/settings/voice-debug')) return 'voice-debug';
|
||||
if (path.includes('/settings/voice')) return 'voice';
|
||||
if (path.includes('/settings/tools')) return 'tools';
|
||||
if (path.includes('/settings/memory-data')) return 'memory-data';
|
||||
if (path.includes('/settings/memory-debug')) return 'memory-debug';
|
||||
if (path.includes('/settings/webhooks-debug')) return 'webhooks-debug';
|
||||
@@ -126,18 +134,18 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
const settingsCrumb: BreadcrumbItem = { label: 'Settings', onClick: () => navigate('/settings') };
|
||||
|
||||
const accountCrumb: BreadcrumbItem = {
|
||||
label: 'Account & Security',
|
||||
label: 'Account & Billing',
|
||||
onClick: () => navigate('/settings/account'),
|
||||
};
|
||||
|
||||
const automationCrumb: BreadcrumbItem = {
|
||||
label: 'Automation & Channels',
|
||||
onClick: () => navigate('/settings/automation'),
|
||||
const featuresCrumb: BreadcrumbItem = {
|
||||
label: 'Features',
|
||||
onClick: () => navigate('/settings/features'),
|
||||
};
|
||||
|
||||
const aiToolsCrumb: BreadcrumbItem = {
|
||||
label: 'AI & Skills',
|
||||
onClick: () => navigate('/settings/ai-tools'),
|
||||
const aiModelsCrumb: BreadcrumbItem = {
|
||||
label: 'AI & Models',
|
||||
onClick: () => navigate('/settings/ai-models'),
|
||||
};
|
||||
|
||||
const teamCrumb: BreadcrumbItem = { label: 'Team', onClick: () => navigate('/settings/team') };
|
||||
@@ -151,33 +159,29 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
switch (currentRoute) {
|
||||
// Section pages
|
||||
case 'account':
|
||||
case 'automation':
|
||||
case 'ai-tools':
|
||||
case 'features':
|
||||
case 'ai-models':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Top-level billing leaf (promoted out of Account & Security)
|
||||
case 'billing':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Leaf panels under account
|
||||
// Leaf panels under account & billing
|
||||
case 'recovery-phrase':
|
||||
case 'team':
|
||||
case 'connections':
|
||||
case 'billing':
|
||||
case 'privacy':
|
||||
return [settingsCrumb, accountCrumb];
|
||||
|
||||
// Leaf panels under automation
|
||||
case 'accessibility':
|
||||
// Leaf panels under features
|
||||
case 'screen-intelligence':
|
||||
case 'autocomplete':
|
||||
case 'messaging':
|
||||
case 'cron-jobs':
|
||||
return [settingsCrumb, automationCrumb];
|
||||
|
||||
// Leaf panels under ai-tools
|
||||
case 'voice':
|
||||
case 'messaging':
|
||||
case 'tools':
|
||||
return [settingsCrumb, featuresCrumb];
|
||||
|
||||
// Leaf panels under AI & Models
|
||||
case 'local-model':
|
||||
case 'ai':
|
||||
return [settingsCrumb, aiToolsCrumb];
|
||||
return [settingsCrumb, aiModelsCrumb];
|
||||
|
||||
// Team sub-pages
|
||||
case 'team-members':
|
||||
@@ -185,14 +189,19 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
return [settingsCrumb, accountCrumb, teamCrumb];
|
||||
|
||||
// Developer sub-pages
|
||||
case 'ai':
|
||||
case 'agent-chat':
|
||||
case 'cron-jobs':
|
||||
case 'screen-awareness-debug':
|
||||
case 'autocomplete-debug':
|
||||
case 'voice-debug':
|
||||
case 'local-model-debug':
|
||||
case 'webhooks-debug':
|
||||
case 'memory-data':
|
||||
case 'memory-debug':
|
||||
return [settingsCrumb, developerCrumb];
|
||||
|
||||
// Other leaf pages
|
||||
case 'privacy':
|
||||
case 'agent-chat':
|
||||
// Developer options section page
|
||||
case 'developer-options':
|
||||
return [settingsCrumb];
|
||||
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const formatRemaining = (remainingMs: number | null): string => {
|
||||
if (remainingMs === null || remainingMs <= 0) {
|
||||
return '00:00';
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(remainingMs / 1000);
|
||||
const mins = Math.floor(totalSeconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const secs = (totalSeconds % 60).toString().padStart(2, '0');
|
||||
return `${mins}:${secs}`;
|
||||
};
|
||||
|
||||
const PermissionBadge = ({ label, value }: { label: string; value: string }) => {
|
||||
const colorClass =
|
||||
value === 'granted'
|
||||
? 'bg-green-50 text-green-700 border-green-200'
|
||||
: value === 'denied'
|
||||
? 'bg-red-50 text-red-600 border-red-200'
|
||||
: 'bg-stone-100 text-stone-600 border-stone-200';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 p-3">
|
||||
<span className="text-sm text-stone-700">{label}</span>
|
||||
<span className={`rounded-md border px-2 py-1 text-xs uppercase tracking-wide ${colorClass}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccessibilityPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const {
|
||||
status,
|
||||
isLoading,
|
||||
isRequestingPermissions,
|
||||
isRestartingCore,
|
||||
isStartingSession,
|
||||
isStoppingSession,
|
||||
isLoadingVision,
|
||||
isFlushingVision,
|
||||
recentVisionSummaries,
|
||||
lastError,
|
||||
requestPermission,
|
||||
refreshPermissionsWithRestart,
|
||||
refreshStatus,
|
||||
refreshVision,
|
||||
startSession,
|
||||
stopSession,
|
||||
flushVision,
|
||||
} = useScreenIntelligenceState({ loadVision: true, visionLimit: 10, pollMs: 2000 });
|
||||
const [featureOverrides, setFeatureOverrides] = useState<{ screen_monitoring?: boolean }>({});
|
||||
|
||||
const anyPermissionDenied =
|
||||
status?.permissions.accessibility === 'denied' ||
|
||||
status?.permissions.input_monitoring === 'denied';
|
||||
|
||||
const screenMonitoring =
|
||||
featureOverrides.screen_monitoring ?? status?.features.screen_monitoring ?? true;
|
||||
|
||||
const remaining = useMemo(
|
||||
() => formatRemaining(status?.session.remaining_ms ?? null),
|
||||
[status?.session.remaining_ms]
|
||||
);
|
||||
|
||||
const startDisabled =
|
||||
isStartingSession ||
|
||||
isLoading ||
|
||||
!status?.platform_supported ||
|
||||
status.session.active ||
|
||||
status.permissions.accessibility !== 'granted';
|
||||
const stopDisabled = isStoppingSession || !status?.session.active;
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title="Accessibility Automation"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Permissions</h3>
|
||||
<PermissionBadge
|
||||
label="Accessibility"
|
||||
value={status?.permissions.accessibility ?? 'unknown'}
|
||||
/>
|
||||
<PermissionBadge
|
||||
label="Input Monitoring"
|
||||
value={status?.permissions.input_monitoring ?? 'unknown'}
|
||||
/>
|
||||
|
||||
{anyPermissionDenied && (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700 space-y-1">
|
||||
<p>
|
||||
After granting in System Settings, click “Restart & Refresh” below.
|
||||
</p>
|
||||
{status?.permission_check_process_path ? (
|
||||
<p className="opacity-75 text-xs">
|
||||
Enable the same app macOS lists for this path (TCC is per executable).{' '}
|
||||
<span className="font-mono break-all text-stone-600">
|
||||
{status.permission_check_process_path}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="opacity-75">
|
||||
Still stuck? Remove the old entry for this app in System Settings → Privacy, then
|
||||
click “Request” again. For dev, run{' '}
|
||||
<span className="font-mono text-xs">yarn core:stage</span> so the sidecar matches
|
||||
the staged binary name.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void requestPermission('accessibility')}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="mt-1 rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Request Accessibility'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void requestPermission('input_monitoring')}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Open Input Monitoring'}
|
||||
</button>
|
||||
|
||||
{anyPermissionDenied ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshPermissionsWithRestart()}
|
||||
disabled={isRestartingCore || isLoading}
|
||||
className="rounded-lg border border-amber-500/60 bg-amber-50 px-3 py-2 text-sm text-amber-700 disabled:opacity-50">
|
||||
{isRestartingCore ? 'Restarting core…' : 'Restart & Refresh Permissions'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isLoading || isRestartingCore}
|
||||
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700 disabled:opacity-50">
|
||||
{isLoading ? 'Refreshing…' : 'Refresh Status'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Features</h3>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Screen Monitoring</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={screenMonitoring}
|
||||
onChange={event =>
|
||||
setFeatureOverrides(current => ({
|
||||
...current,
|
||||
screen_monitoring: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Session</h3>
|
||||
<div className="text-sm text-stone-700 space-y-1">
|
||||
<div>Status: {status?.session.active ? 'Active' : 'Stopped'}</div>
|
||||
<div>Remaining: {remaining}</div>
|
||||
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
|
||||
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
|
||||
<div>Vision: {status?.session.vision_state ?? 'idle'}</div>
|
||||
<div>Vision queue: {status?.session.vision_queue_depth ?? 0}</div>
|
||||
<div>
|
||||
Last vision:{' '}
|
||||
{status?.session.last_vision_at_ms
|
||||
? new Date(status.session.last_vision_at_ms).toLocaleTimeString()
|
||||
: 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void startSession({
|
||||
consent: true,
|
||||
ttl_secs: status?.config.session_ttl_secs ?? 300,
|
||||
screen_monitoring: screenMonitoring,
|
||||
})
|
||||
}
|
||||
disabled={startDisabled}
|
||||
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
|
||||
{isStartingSession ? 'Starting…' : 'Start Session'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void stopSession('manual_stop')}
|
||||
disabled={stopDisabled}
|
||||
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
|
||||
{isStoppingSession ? 'Stopping…' : 'Stop Session'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void flushVision()}
|
||||
disabled={isFlushingVision || !status?.session.active}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
|
||||
{isFlushingVision ? 'Analyzing…' : 'Analyze Now'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Vision Summaries</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshVision(10)}
|
||||
disabled={isLoadingVision}
|
||||
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-1.5 text-xs text-stone-700 disabled:opacity-50">
|
||||
{isLoadingVision ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recentVisionSummaries.length === 0 ? (
|
||||
<div className="text-xs text-stone-500">No summaries yet.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentVisionSummaries.map(summary => (
|
||||
<div
|
||||
key={summary.id}
|
||||
className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
|
||||
<div className="text-stone-500">
|
||||
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
|
||||
{summary.app_name ?? 'Unknown App'}
|
||||
{summary.window_title ? ` · ${summary.window_title}` : ''}
|
||||
</div>
|
||||
<div className="mt-1 text-stone-800">{summary.actionable_notes}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!status?.platform_supported && (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Accessibility Automation V1 is currently supported on macOS only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastError && (
|
||||
<div className="rounded-xl border border-red-300 bg-red-50 p-3 text-sm text-red-600">
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessibilityPanel;
|
||||
@@ -0,0 +1,695 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type AcceptedCompletion,
|
||||
type AutocompleteConfig,
|
||||
type AutocompleteStatus,
|
||||
isTauri,
|
||||
openhumanAutocompleteAccept,
|
||||
openhumanAutocompleteClearHistory,
|
||||
openhumanAutocompleteCurrent,
|
||||
openhumanAutocompleteDebugFocus,
|
||||
openhumanAutocompleteHistory,
|
||||
openhumanAutocompleteSetStyle,
|
||||
openhumanAutocompleteStart,
|
||||
openhumanAutocompleteStatus,
|
||||
openhumanAutocompleteStop,
|
||||
openhumanGetConfig,
|
||||
} from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const DEFAULT_CONFIG: AutocompleteConfig = {
|
||||
enabled: false,
|
||||
debounce_ms: 120,
|
||||
max_chars: 384,
|
||||
style_preset: 'balanced',
|
||||
style_instructions: null,
|
||||
style_examples: [],
|
||||
disabled_apps: [],
|
||||
accept_with_tab: true,
|
||||
overlay_ttl_ms: 1100,
|
||||
};
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
|
||||
const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
return {
|
||||
enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_CONFIG.enabled,
|
||||
debounce_ms:
|
||||
typeof value.debounce_ms === 'number' ? value.debounce_ms : DEFAULT_CONFIG.debounce_ms,
|
||||
max_chars: typeof value.max_chars === 'number' ? value.max_chars : DEFAULT_CONFIG.max_chars,
|
||||
style_preset:
|
||||
typeof value.style_preset === 'string' ? value.style_preset : DEFAULT_CONFIG.style_preset,
|
||||
style_instructions:
|
||||
typeof value.style_instructions === 'string' ? value.style_instructions : null,
|
||||
style_examples: Array.isArray(value.style_examples)
|
||||
? value.style_examples.filter((entry): entry is string => typeof entry === 'string')
|
||||
: DEFAULT_CONFIG.style_examples,
|
||||
disabled_apps: Array.isArray(value.disabled_apps)
|
||||
? value.disabled_apps.filter((entry): entry is string => typeof entry === 'string')
|
||||
: DEFAULT_CONFIG.disabled_apps,
|
||||
accept_with_tab:
|
||||
typeof value.accept_with_tab === 'boolean'
|
||||
? value.accept_with_tab
|
||||
: DEFAULT_CONFIG.accept_with_tab,
|
||||
overlay_ttl_ms:
|
||||
typeof value.overlay_ttl_ms === 'number'
|
||||
? value.overlay_ttl_ms
|
||||
: DEFAULT_CONFIG.overlay_ttl_ms,
|
||||
};
|
||||
};
|
||||
|
||||
const AutocompleteDebugPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
// Status & loading
|
||||
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// Advanced settings form state (dev-facing fields only)
|
||||
const [debounceMs, setDebounceMs] = useState<string>(String(DEFAULT_CONFIG.debounce_ms));
|
||||
const [maxChars, setMaxChars] = useState<string>(String(DEFAULT_CONFIG.max_chars));
|
||||
const [overlayTtlMs, setOverlayTtlMs] = useState<string>(String(DEFAULT_CONFIG.overlay_ttl_ms));
|
||||
const [styleInstructions, setStyleInstructions] = useState<string>('');
|
||||
const [styleExamplesText, setStyleExamplesText] = useState<string>('');
|
||||
|
||||
// Test section
|
||||
const [contextOverride, setContextOverride] = useState<string>('');
|
||||
const [focusDebug, setFocusDebug] = useState<string>('');
|
||||
|
||||
// Live logs
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const previousStatusRef = useRef<AutocompleteStatus | null>(null);
|
||||
|
||||
// Personalization history
|
||||
const [historyEntries, setHistoryEntries] = useState<AcceptedCompletion[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [isClearingHistory, setIsClearingHistory] = useState(false);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logging helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const appendLogs = (entries: string[]) => {
|
||||
if (entries.length === 0) return;
|
||||
const now = new Date();
|
||||
const stamp = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, '0')}`;
|
||||
setLogs(current =>
|
||||
[...current, ...entries.map(entry => `${stamp} ${entry}`)].slice(-MAX_LOG_ENTRIES)
|
||||
);
|
||||
};
|
||||
|
||||
const appendUiLog = (entry: string) => {
|
||||
appendLogs([`[ui-flow] ${entry}`]);
|
||||
};
|
||||
|
||||
const trackStatusChanges = (next: AutocompleteStatus) => {
|
||||
const previous = previousStatusRef.current;
|
||||
if (!previous) {
|
||||
previousStatusRef.current = next;
|
||||
appendLogs([
|
||||
`[runtime] phase=${next.phase} running=${next.running ? 'yes' : 'no'} enabled=${next.enabled ? 'yes' : 'no'}`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntries: string[] = [];
|
||||
if (next.phase !== previous.phase) {
|
||||
nextEntries.push(`phase ${previous.phase} -> ${next.phase}`);
|
||||
}
|
||||
if ((next.last_error ?? '') !== (previous.last_error ?? '') && next.last_error) {
|
||||
nextEntries.push(`error: ${next.last_error}`);
|
||||
}
|
||||
if (
|
||||
(next.suggestion?.value ?? '') !== (previous.suggestion?.value ?? '') &&
|
||||
next.suggestion?.value
|
||||
) {
|
||||
nextEntries.push(`suggestion ready: "${next.suggestion.value}"`);
|
||||
}
|
||||
|
||||
if (nextEntries.length > 0) {
|
||||
appendLogs(nextEntries);
|
||||
}
|
||||
previousStatusRef.current = next;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const load = async () => {
|
||||
if (!isTauri()) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statusResponse, configResponse] = await Promise.all([
|
||||
openhumanAutocompleteStatus(),
|
||||
openhumanGetConfig(),
|
||||
]);
|
||||
setStatus(statusResponse.result);
|
||||
trackStatusChanges(statusResponse.result);
|
||||
appendLogs(statusResponse.logs);
|
||||
const config = parseAutocompleteConfig(
|
||||
(configResponse.result.config as Record<string, unknown> | undefined)?.autocomplete
|
||||
);
|
||||
setDebounceMs(String(config.debounce_ms));
|
||||
setMaxChars(String(config.max_chars));
|
||||
setOverlayTtlMs(String(config.overlay_ttl_ms));
|
||||
setStyleInstructions(config.style_instructions ?? '');
|
||||
setStyleExamplesText(config.style_examples.join('\n'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load autocomplete settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistory = async (): Promise<AcceptedCompletion[]> => {
|
||||
if (!isTauri()) return [];
|
||||
setIsHistoryLoading(true);
|
||||
try {
|
||||
const response = await openhumanAutocompleteHistory({ limit: 20 });
|
||||
setHistoryEntries(response.result.entries);
|
||||
return response.result.entries;
|
||||
} catch {
|
||||
// Non-critical — silently ignore
|
||||
return [];
|
||||
} finally {
|
||||
setIsHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
void loadHistory();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status polling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const refreshStatus = async (showSpinner = false) => {
|
||||
if (!isTauri()) return null;
|
||||
if (showSpinner) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
const response = await openhumanAutocompleteStatus();
|
||||
setStatus(response.result);
|
||||
trackStatusChanges(response.result);
|
||||
if (showSpinner) {
|
||||
appendLogs(response.logs);
|
||||
}
|
||||
return response.result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to refresh autocomplete status';
|
||||
appendUiLog(`refresh status failed: ${msg}`);
|
||||
setError(msg);
|
||||
return null;
|
||||
} finally {
|
||||
if (showSpinner) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, 1200);
|
||||
return () => window.clearInterval(intervalId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Runtime controls
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const start = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const debounce = Number(debounceMs);
|
||||
appendUiLog(`start requested (debounce=${String(debounce)}ms)`);
|
||||
const response = await openhumanAutocompleteStart({
|
||||
debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120,
|
||||
});
|
||||
appendLogs(response.logs);
|
||||
const latestStatus = await refreshStatus();
|
||||
if (response.result.started) {
|
||||
setMessage('Autocomplete started.');
|
||||
} else if (latestStatus?.enabled === false) {
|
||||
setMessage('Autocomplete is disabled in settings. Enable it and save first.');
|
||||
} else if (latestStatus?.running) {
|
||||
setMessage('Autocomplete is already running.');
|
||||
} else {
|
||||
setMessage('Autocomplete did not start.');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to start autocomplete';
|
||||
appendUiLog(`start failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog('stop requested');
|
||||
const response = await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
|
||||
appendLogs(response.logs);
|
||||
const latestStatus = await refreshStatus();
|
||||
setMessage('Autocomplete stopped.');
|
||||
if (latestStatus?.running) {
|
||||
appendUiLog('runtime still reports running after stop');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to stop autocomplete';
|
||||
appendUiLog(`stop failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const testCurrent = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog(
|
||||
contextOverride.trim()
|
||||
? `get suggestion requested (override chars=${String(contextOverride.trim().length)})`
|
||||
: 'get suggestion requested (focused app context)'
|
||||
);
|
||||
const response = await openhumanAutocompleteCurrent({
|
||||
context: contextOverride.trim() || undefined,
|
||||
});
|
||||
appendLogs(response.logs);
|
||||
setMessage(
|
||||
response.result.suggestion?.value
|
||||
? `Suggestion: ${response.result.suggestion.value}`
|
||||
: 'No suggestion returned.'
|
||||
);
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to fetch current suggestion';
|
||||
appendUiLog(`get suggestion failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForAcceptedHistoryEntry = async (acceptedValue?: string | null) => {
|
||||
if (!acceptedValue) {
|
||||
await loadHistory();
|
||||
return;
|
||||
}
|
||||
const normalized = acceptedValue.trim();
|
||||
if (!normalized) {
|
||||
await loadHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = 6;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const entries = await loadHistory();
|
||||
const found = entries.some(entry => entry.suggestion.trim() === normalized);
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 180));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const acceptSuggestion = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog('accept suggestion requested');
|
||||
const response = await openhumanAutocompleteAccept({
|
||||
suggestion: status?.suggestion?.value ?? undefined,
|
||||
skip_apply: true,
|
||||
});
|
||||
appendLogs(response.logs);
|
||||
if (response.result.accepted && response.result.value) {
|
||||
setMessage(`Accepted: ${response.result.value}`);
|
||||
} else {
|
||||
setMessage(response.result.reason ?? 'No suggestion was applied.');
|
||||
}
|
||||
await refreshStatus();
|
||||
await waitForAcceptedHistoryEntry(response.result.value);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to accept suggestion';
|
||||
appendUiLog(`accept failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const debugFocus = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
try {
|
||||
appendUiLog('debug focus requested');
|
||||
const response = await openhumanAutocompleteDebugFocus();
|
||||
appendLogs(response.logs);
|
||||
setFocusDebug(JSON.stringify(response.result, null, 2));
|
||||
if (response.result) {
|
||||
appendUiLog(
|
||||
`focus app=${response.result.app_name ?? 'n/a'} role=${response.result.role ?? 'n/a'} chars=${String(response.result.context.length)}`
|
||||
);
|
||||
} else {
|
||||
appendUiLog('focus debug returned no focused element');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to inspect focused element';
|
||||
appendUiLog(`debug focus failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Advanced settings save
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const saveAdvancedConfig = async () => {
|
||||
if (!isTauri()) return;
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog('saving advanced autocomplete settings');
|
||||
const debounce = Number(debounceMs);
|
||||
const max = Number(maxChars);
|
||||
const ttl = Number(overlayTtlMs);
|
||||
const response = await openhumanAutocompleteSetStyle({
|
||||
debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120,
|
||||
max_chars: Number.isFinite(max) ? Math.min(Math.max(max, 32), 1200) : 384,
|
||||
overlay_ttl_ms: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 300), 10000) : 1100,
|
||||
style_instructions: styleInstructions.trim() || undefined,
|
||||
style_examples: styleExamplesText
|
||||
.split('\n')
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
setDebounceMs(String(response.result.config.debounce_ms));
|
||||
setMaxChars(String(response.result.config.max_chars));
|
||||
setOverlayTtlMs(String(response.result.config.overlay_ttl_ms));
|
||||
setStyleInstructions(response.result.config.style_instructions ?? '');
|
||||
setStyleExamplesText(response.result.config.style_examples.join('\n'));
|
||||
appendLogs(response.logs);
|
||||
setMessage('Advanced settings saved.');
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save advanced settings';
|
||||
appendUiLog(`save advanced settings failed: ${msg}`);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// History controls
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const clearHistory = async () => {
|
||||
if (!isTauri()) return;
|
||||
setIsClearingHistory(true);
|
||||
try {
|
||||
await openhumanAutocompleteClearHistory();
|
||||
setHistoryEntries([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to clear history');
|
||||
} finally {
|
||||
setIsClearingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = () => {
|
||||
setLogs([]);
|
||||
previousStatusRef.current = status;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title="Autocomplete Debug"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Runtime section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
|
||||
<div className="text-sm text-stone-700 space-y-1">
|
||||
<div>Platform supported: {status?.platform_supported ? 'yes' : 'no'}</div>
|
||||
<div>Enabled: {status?.enabled ? 'yes' : 'no'}</div>
|
||||
<div>Running: {status?.running ? 'yes' : 'no'}</div>
|
||||
<div>Phase: {status?.phase ?? 'unknown'}</div>
|
||||
<div>Debounce: {status?.debounce_ms ?? 0}ms</div>
|
||||
<div>Model: {status?.model_id ?? 'n/a'}</div>
|
||||
<div>App: {status?.app_name ?? 'n/a'}</div>
|
||||
<div>Last error: {status?.last_error ?? 'none'}</div>
|
||||
<div>Current suggestion: {status?.suggestion?.value ?? 'none'}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshStatus(true)}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700 disabled:opacity-50">
|
||||
{isLoading ? 'Refreshing…' : 'Refresh Status'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={!status?.platform_supported || Boolean(status?.running)}
|
||||
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void stop()}
|
||||
disabled={!status?.running}
|
||||
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Test section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Test</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">Context Override (optional)</div>
|
||||
<textarea
|
||||
value={contextOverride}
|
||||
onChange={event => setContextOverride(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void testCurrent()}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600">
|
||||
Get Suggestion
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void acceptSuggestion()}
|
||||
className="rounded-lg border border-emerald-500/60 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
Accept Suggestion
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void debugFocus()}
|
||||
className="rounded-lg border border-amber-500/60 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
Debug Focus
|
||||
</button>
|
||||
</div>
|
||||
{focusDebug && (
|
||||
<pre className="max-h-48 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
|
||||
{focusDebug}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Live Logs section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Live Logs</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearLogs}
|
||||
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-1.5 text-xs text-stone-700">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-56 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
|
||||
{logs.length > 0 ? logs.join('\n') : 'No logs yet.'}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Advanced settings */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Advanced Settings</h3>
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Debounce Ms</span>
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={debounceMs}
|
||||
onChange={event => setDebounceMs(event.target.value)}
|
||||
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Max Characters</span>
|
||||
<input
|
||||
type="number"
|
||||
min={32}
|
||||
max={1200}
|
||||
step={8}
|
||||
value={maxChars}
|
||||
onChange={event => setMaxChars(event.target.value)}
|
||||
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Overlay TTL Ms</span>
|
||||
<input
|
||||
type="number"
|
||||
min={300}
|
||||
max={10000}
|
||||
step={100}
|
||||
value={overlayTtlMs}
|
||||
onChange={event => setOverlayTtlMs(event.target.value)}
|
||||
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">Style Instructions</div>
|
||||
<textarea
|
||||
value={styleInstructions}
|
||||
onChange={event => setStyleInstructions(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">Style Examples (one per line)</div>
|
||||
<textarea
|
||||
value={styleExamplesText}
|
||||
onChange={event => setStyleExamplesText(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveAdvancedConfig()}
|
||||
disabled={isSaving}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Personalization History */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Personalization History</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearHistory()}
|
||||
disabled={isClearingHistory || historyEntries.length === 0}
|
||||
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-1.5 text-xs text-red-600 disabled:opacity-40">
|
||||
{isClearingHistory ? 'Clearing…' : 'Clear History'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500">
|
||||
{isHistoryLoading
|
||||
? 'Loading…'
|
||||
: historyEntries.length === 0
|
||||
? 'No accepted completions yet. Accept suggestions with Tab to start personalising.'
|
||||
: `${String(historyEntries.length)} accepted completion${historyEntries.length === 1 ? '' : 's'} stored — used to personalise future suggestions.`}
|
||||
</p>
|
||||
{historyEntries.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto rounded-xl border border-stone-200 bg-stone-50 p-2 space-y-1">
|
||||
{historyEntries.map((entry, idx) => (
|
||||
<div
|
||||
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
|
||||
className="flex flex-col gap-0.5 rounded-lg bg-white px-2 py-1.5 text-xs border border-stone-100">
|
||||
<div className="flex items-center gap-2 text-stone-500">
|
||||
<span className="shrink-0">
|
||||
{new Date(entry.timestamp_ms).toLocaleString()}
|
||||
</span>
|
||||
{entry.app_name && (
|
||||
<span className="rounded bg-stone-100 px-1 text-stone-600">
|
||||
{entry.app_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 text-stone-700 truncate">
|
||||
<span className="shrink-0 text-stone-400">…</span>
|
||||
<span className="truncate text-stone-500">{entry.context.slice(-40)}</span>
|
||||
<span className="shrink-0 text-stone-400">→</span>
|
||||
<span className="font-medium text-primary-500 truncate">
|
||||
{entry.suggestion}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Feedback messages */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{message && <div className="text-xs text-green-700">{message}</div>}
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutocompleteDebugPanel;
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type AcceptedCompletion,
|
||||
type AutocompleteConfig,
|
||||
type AutocompleteStatus,
|
||||
isTauri,
|
||||
openhumanAutocompleteAccept,
|
||||
openhumanAutocompleteClearHistory,
|
||||
openhumanAutocompleteCurrent,
|
||||
openhumanAutocompleteDebugFocus,
|
||||
openhumanAutocompleteHistory,
|
||||
openhumanAutocompleteSetStyle,
|
||||
openhumanAutocompleteStart,
|
||||
openhumanAutocompleteStatus,
|
||||
@@ -18,8 +12,6 @@ import {
|
||||
} from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import AppFilterSection from './autocomplete/AppFilterSection';
|
||||
import CompletionStyleSection from './autocomplete/CompletionStyleSection';
|
||||
|
||||
const DEFAULT_CONFIG: AutocompleteConfig = {
|
||||
enabled: false,
|
||||
@@ -33,8 +25,6 @@ const DEFAULT_CONFIG: AutocompleteConfig = {
|
||||
overlay_ttl_ms: 1100,
|
||||
};
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
|
||||
const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return DEFAULT_CONFIG;
|
||||
@@ -67,80 +57,26 @@ const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
|
||||
};
|
||||
|
||||
const AutocompletePanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
|
||||
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const [enabled, setEnabled] = useState<boolean>(DEFAULT_CONFIG.enabled);
|
||||
const [debounceMs, setDebounceMs] = useState<string>(String(DEFAULT_CONFIG.debounce_ms));
|
||||
const [maxChars, setMaxChars] = useState<string>(String(DEFAULT_CONFIG.max_chars));
|
||||
const [stylePreset, setStylePreset] = useState<string>(DEFAULT_CONFIG.style_preset);
|
||||
const [styleInstructions, setStyleInstructions] = useState<string>('');
|
||||
const [styleExamplesText, setStyleExamplesText] = useState<string>('');
|
||||
const [disabledAppsText, setDisabledAppsText] = useState<string>(
|
||||
DEFAULT_CONFIG.disabled_apps.join('\n')
|
||||
);
|
||||
const [acceptWithTab, setAcceptWithTab] = useState<boolean>(DEFAULT_CONFIG.accept_with_tab);
|
||||
const [overlayTtlMs, setOverlayTtlMs] = useState<string>(String(DEFAULT_CONFIG.overlay_ttl_ms));
|
||||
const [contextOverride, setContextOverride] = useState<string>('');
|
||||
const [focusDebug, setFocusDebug] = useState<string>('');
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const previousStatusRef = useRef<AutocompleteStatus | null>(null);
|
||||
|
||||
// Personalization history state
|
||||
const [historyEntries, setHistoryEntries] = useState<AcceptedCompletion[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [isClearingHistory, setIsClearingHistory] = useState(false);
|
||||
|
||||
const appendLogs = (entries: string[]) => {
|
||||
if (entries.length === 0) return;
|
||||
const now = new Date();
|
||||
const stamp = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, '0')}`;
|
||||
setLogs(current =>
|
||||
[...current, ...entries.map(entry => `${stamp} ${entry}`)].slice(-MAX_LOG_ENTRIES)
|
||||
);
|
||||
};
|
||||
|
||||
const appendUiLog = (entry: string) => {
|
||||
appendLogs([`[ui-flow] ${entry}`]);
|
||||
};
|
||||
|
||||
const trackStatusChanges = (next: AutocompleteStatus) => {
|
||||
const previous = previousStatusRef.current;
|
||||
if (!previous) {
|
||||
previousStatusRef.current = next;
|
||||
appendLogs([
|
||||
`[runtime] phase=${next.phase} running=${next.running ? 'yes' : 'no'} enabled=${next.enabled ? 'yes' : 'no'}`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntries: string[] = [];
|
||||
if (next.phase !== previous.phase) {
|
||||
nextEntries.push(`phase ${previous.phase} -> ${next.phase}`);
|
||||
}
|
||||
if ((next.last_error ?? '') !== (previous.last_error ?? '') && next.last_error) {
|
||||
nextEntries.push(`error: ${next.last_error}`);
|
||||
}
|
||||
if (
|
||||
(next.suggestion?.value ?? '') !== (previous.suggestion?.value ?? '') &&
|
||||
next.suggestion?.value
|
||||
) {
|
||||
nextEntries.push(`suggestion ready: "${next.suggestion.value}"`);
|
||||
}
|
||||
|
||||
if (nextEntries.length > 0) {
|
||||
appendLogs(nextEntries);
|
||||
}
|
||||
previousStatusRef.current = next;
|
||||
};
|
||||
// Hold full config so we can pass through unchanged advanced values on save.
|
||||
// configLoaded tracks whether we've received real config from the backend.
|
||||
const fullConfigRef = useRef<AutocompleteConfig>(DEFAULT_CONFIG);
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!isTauri()) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statusResponse, configResponse] = await Promise.all([
|
||||
@@ -148,155 +84,73 @@ const AutocompletePanel = () => {
|
||||
openhumanGetConfig(),
|
||||
]);
|
||||
setStatus(statusResponse.result);
|
||||
trackStatusChanges(statusResponse.result);
|
||||
appendLogs(statusResponse.logs);
|
||||
const config = parseAutocompleteConfig(
|
||||
(configResponse.result.config as Record<string, unknown> | undefined)?.autocomplete
|
||||
);
|
||||
fullConfigRef.current = config;
|
||||
setConfigLoaded(true);
|
||||
setEnabled(config.enabled);
|
||||
setDebounceMs(String(config.debounce_ms));
|
||||
setMaxChars(String(config.max_chars));
|
||||
setStylePreset(config.style_preset);
|
||||
setStyleInstructions(config.style_instructions ?? '');
|
||||
setStyleExamplesText(config.style_examples.join('\n'));
|
||||
setDisabledAppsText(config.disabled_apps.join('\n'));
|
||||
setAcceptWithTab(config.accept_with_tab);
|
||||
setOverlayTtlMs(String(config.overlay_ttl_ms));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load autocomplete settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
void loadHistory();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadHistory = async (): Promise<AcceptedCompletion[]> => {
|
||||
if (!isTauri()) return [];
|
||||
setIsHistoryLoading(true);
|
||||
try {
|
||||
const response = await openhumanAutocompleteHistory({ limit: 20 });
|
||||
setHistoryEntries(response.result.entries);
|
||||
return response.result.entries;
|
||||
} catch {
|
||||
// Non-critical — silently ignore
|
||||
return [];
|
||||
} finally {
|
||||
setIsHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForAcceptedHistoryEntry = async (acceptedValue?: string | null) => {
|
||||
if (!acceptedValue) {
|
||||
await loadHistory();
|
||||
return;
|
||||
}
|
||||
const normalized = acceptedValue.trim();
|
||||
if (!normalized) {
|
||||
await loadHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = 6;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const entries = await loadHistory();
|
||||
const found = entries.some(entry => entry.suggestion.trim() === normalized);
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 180));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearHistory = async () => {
|
||||
const refreshStatus = async () => {
|
||||
if (!isTauri()) return;
|
||||
setIsClearingHistory(true);
|
||||
try {
|
||||
await openhumanAutocompleteClearHistory();
|
||||
setHistoryEntries([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to clear history');
|
||||
} finally {
|
||||
setIsClearingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStatus = async (showSpinner = false) => {
|
||||
if (!isTauri()) return null;
|
||||
if (showSpinner) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
const response = await openhumanAutocompleteStatus();
|
||||
setStatus(response.result);
|
||||
trackStatusChanges(response.result);
|
||||
if (showSpinner) {
|
||||
appendLogs(response.logs);
|
||||
}
|
||||
return response.result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to refresh autocomplete status';
|
||||
appendUiLog(`refresh status failed: ${msg}`);
|
||||
setError(msg);
|
||||
return null;
|
||||
} finally {
|
||||
if (showSpinner) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, 1200);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!isTauri()) return;
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog('saving autocomplete settings');
|
||||
const debounce = Number(debounceMs);
|
||||
const max = Number(maxChars);
|
||||
const ttl = Number(overlayTtlMs);
|
||||
const prev = fullConfigRef.current;
|
||||
const response = await openhumanAutocompleteSetStyle({
|
||||
enabled,
|
||||
debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120,
|
||||
max_chars: Number.isFinite(max) ? Math.min(Math.max(max, 32), 1200) : 384,
|
||||
debounce_ms: prev.debounce_ms,
|
||||
max_chars: prev.max_chars,
|
||||
style_preset: stylePreset.trim() || 'balanced',
|
||||
style_instructions: styleInstructions.trim() || undefined,
|
||||
style_examples: styleExamplesText
|
||||
.split('\n')
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean),
|
||||
style_instructions: prev.style_instructions ?? undefined,
|
||||
style_examples: prev.style_examples,
|
||||
disabled_apps: disabledAppsText
|
||||
.split('\n')
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean),
|
||||
accept_with_tab: acceptWithTab,
|
||||
overlay_ttl_ms: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 300), 10000) : 1100,
|
||||
overlay_ttl_ms: prev.overlay_ttl_ms,
|
||||
});
|
||||
|
||||
fullConfigRef.current = response.result.config;
|
||||
setEnabled(response.result.config.enabled);
|
||||
setDebounceMs(String(response.result.config.debounce_ms));
|
||||
setMaxChars(String(response.result.config.max_chars));
|
||||
setStylePreset(response.result.config.style_preset);
|
||||
setStyleInstructions(response.result.config.style_instructions ?? '');
|
||||
setStyleExamplesText(response.result.config.style_examples.join('\n'));
|
||||
setDisabledAppsText(response.result.config.disabled_apps.join('\n'));
|
||||
setAcceptWithTab(response.result.config.accept_with_tab);
|
||||
setOverlayTtlMs(String(response.result.config.overlay_ttl_ms));
|
||||
appendLogs(response.logs);
|
||||
setMessage('Autocomplete settings saved.');
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save autocomplete settings';
|
||||
appendUiLog(`save settings failed: ${msg}`);
|
||||
setError(msg);
|
||||
setError(err instanceof Error ? err.message : 'Failed to save autocomplete settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -307,26 +161,17 @@ const AutocompletePanel = () => {
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const debounce = Number(debounceMs);
|
||||
appendUiLog(`start requested (debounce=${String(debounce)}ms)`);
|
||||
const response = await openhumanAutocompleteStart({
|
||||
debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120,
|
||||
debounce_ms: fullConfigRef.current.debounce_ms,
|
||||
});
|
||||
appendLogs(response.logs);
|
||||
const latestStatus = await refreshStatus();
|
||||
await refreshStatus();
|
||||
if (response.result.started) {
|
||||
setMessage('Autocomplete started.');
|
||||
} else if (latestStatus?.enabled === false) {
|
||||
setMessage('Autocomplete is disabled in settings. Enable it and save first.');
|
||||
} else if (latestStatus?.running) {
|
||||
setMessage('Autocomplete is already running.');
|
||||
} else {
|
||||
setMessage('Autocomplete did not start.');
|
||||
setMessage('Autocomplete did not start. Check if it is enabled.');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to start autocomplete';
|
||||
appendUiLog(`start failed: ${msg}`);
|
||||
setError(msg);
|
||||
setError(err instanceof Error ? err.message : 'Failed to start autocomplete');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,159 +180,116 @@ const AutocompletePanel = () => {
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog('stop requested');
|
||||
const response = await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
|
||||
appendLogs(response.logs);
|
||||
const latestStatus = await refreshStatus();
|
||||
await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
|
||||
await refreshStatus();
|
||||
setMessage('Autocomplete stopped.');
|
||||
if (latestStatus?.running) {
|
||||
appendUiLog('runtime still reports running after stop');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to stop autocomplete';
|
||||
appendUiLog(`stop failed: ${msg}`);
|
||||
setError(msg);
|
||||
setError(err instanceof Error ? err.message : 'Failed to stop autocomplete');
|
||||
}
|
||||
};
|
||||
|
||||
const testCurrent = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog(
|
||||
contextOverride.trim()
|
||||
? `get suggestion requested (override chars=${String(contextOverride.trim().length)})`
|
||||
: 'get suggestion requested (focused app context)'
|
||||
);
|
||||
const response = await openhumanAutocompleteCurrent({
|
||||
context: contextOverride.trim() || undefined,
|
||||
});
|
||||
appendLogs(response.logs);
|
||||
setMessage(
|
||||
response.result.suggestion?.value
|
||||
? `Suggestion: ${response.result.suggestion.value}`
|
||||
: 'No suggestion returned.'
|
||||
);
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to fetch current suggestion';
|
||||
appendUiLog(`get suggestion failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptSuggestion = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
appendUiLog('accept suggestion requested');
|
||||
const response = await openhumanAutocompleteAccept({
|
||||
suggestion: status?.suggestion?.value ?? undefined,
|
||||
skip_apply: true,
|
||||
});
|
||||
appendLogs(response.logs);
|
||||
if (response.result.accepted && response.result.value) {
|
||||
setMessage(`Accepted: ${response.result.value}`);
|
||||
} else {
|
||||
setMessage(response.result.reason ?? 'No suggestion was applied.');
|
||||
}
|
||||
await refreshStatus();
|
||||
await waitForAcceptedHistoryEntry(response.result.value);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to accept suggestion';
|
||||
appendUiLog(`accept failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const debugFocus = async () => {
|
||||
if (!isTauri()) return;
|
||||
setError(null);
|
||||
try {
|
||||
appendUiLog('debug focus requested');
|
||||
const response = await openhumanAutocompleteDebugFocus();
|
||||
appendLogs(response.logs);
|
||||
setFocusDebug(JSON.stringify(response.result, null, 2));
|
||||
appendUiLog(
|
||||
`focus app=${response.result.app_name ?? 'n/a'} role=${response.result.role ?? 'n/a'} chars=${String(response.result.context.length)}`
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to inspect focused element';
|
||||
appendUiLog(`debug focus failed: ${msg}`);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, 1200);
|
||||
return () => window.clearInterval(intervalId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const clearLogs = () => {
|
||||
setLogs([]);
|
||||
previousStatusRef.current = status;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title="Inline Autocomplete"
|
||||
title="Autocomplete"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
|
||||
<AppFilterSection
|
||||
status={status}
|
||||
isLoading={isLoading}
|
||||
contextOverride={contextOverride}
|
||||
focusDebug={focusDebug}
|
||||
logs={logs}
|
||||
message={message}
|
||||
error={error}
|
||||
onSetContextOverride={setContextOverride}
|
||||
onRefreshStatus={() => void refreshStatus(true)}
|
||||
onStart={() => void start()}
|
||||
onStop={() => void stop()}
|
||||
onTestCurrent={() => void testCurrent()}
|
||||
onAcceptSuggestion={() => void acceptSuggestion()}
|
||||
onDebugFocus={() => void debugFocus()}
|
||||
onClearLogs={clearLogs}
|
||||
/>
|
||||
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Settings</h3>
|
||||
|
||||
<CompletionStyleSection
|
||||
enabled={enabled}
|
||||
debounceMs={debounceMs}
|
||||
maxChars={maxChars}
|
||||
stylePreset={stylePreset}
|
||||
styleInstructions={styleInstructions}
|
||||
styleExamplesText={styleExamplesText}
|
||||
disabledAppsText={disabledAppsText}
|
||||
acceptWithTab={acceptWithTab}
|
||||
overlayTtlMs={overlayTtlMs}
|
||||
isSaving={isSaving}
|
||||
historyEntries={historyEntries}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
isClearingHistory={isClearingHistory}
|
||||
onSetEnabled={setEnabled}
|
||||
onSetDebounceMs={setDebounceMs}
|
||||
onSetMaxChars={setMaxChars}
|
||||
onSetStylePreset={setStylePreset}
|
||||
onSetStyleInstructions={setStyleInstructions}
|
||||
onSetStyleExamplesText={setStyleExamplesText}
|
||||
onSetDisabledAppsText={setDisabledAppsText}
|
||||
onSetAcceptWithTab={setAcceptWithTab}
|
||||
onSetOverlayTtlMs={setOverlayTtlMs}
|
||||
onSaveConfig={() => void saveConfig()}
|
||||
onClearHistory={() => void clearHistory()}
|
||||
/>
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Enabled</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={event => setEnabled(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Accept With Tab</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptWithTab}
|
||||
onChange={event => setAcceptWithTab(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Style Preset</span>
|
||||
<select
|
||||
value={stylePreset}
|
||||
onChange={event => setStylePreset(event.target.value)}
|
||||
className="rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700">
|
||||
<option value="balanced">Balanced</option>
|
||||
<option value="concise">Concise</option>
|
||||
<option value="formal">Formal</option>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">
|
||||
Disabled Apps (one bundle/app token per line)
|
||||
</div>
|
||||
<textarea
|
||||
value={disabledAppsText}
|
||||
onChange={event => setDisabledAppsText(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSaving || !configLoaded}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
|
||||
{isSaving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
|
||||
<div className="text-sm text-stone-600 space-y-1">
|
||||
<div>Running: {status?.running ? 'yes' : 'no'}</div>
|
||||
<div>Enabled: {status?.enabled ? 'yes' : 'no'}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={!configLoaded || !status?.platform_supported || Boolean(status?.running)}
|
||||
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void stop()}
|
||||
disabled={!status?.running}
|
||||
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{message && <div className="text-xs text-green-700">{message}</div>}
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateToSettings('autocomplete-debug')}
|
||||
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
|
||||
Advanced settings
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,103 @@ const developerItems = [
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
|
||||
d="M12 3l1.9 3.85 4.25.62-3.08 3 .73 4.23L12 12.77 8.2 14.7l.73-4.23-3.08-3 4.25-.62L12 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'agent-chat',
|
||||
title: 'Agent Chat',
|
||||
description: 'Test agent conversation with model and temperature overrides',
|
||||
route: 'agent-chat',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 11c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 19l1.395-3.72C3.512 14.042 3 12.574 3 11c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cron-jobs',
|
||||
title: 'Cron Jobs',
|
||||
description: 'View and configure scheduled jobs for runtime skills',
|
||||
route: 'cron-jobs',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'screen-awareness-debug',
|
||||
title: 'Screen Awareness Debug',
|
||||
description: 'FPS tuning, vision model config, capture tests, and session diagnostics',
|
||||
route: 'screen-awareness-debug',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5h18v12H3zM8 21h8m-4-4v4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'autocomplete-debug',
|
||||
title: 'Autocomplete Debug',
|
||||
description: 'Timing, test harness, focus debug, activity logs, and history',
|
||||
route: 'autocomplete-debug',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 7h16M4 12h10m-10 5h7m10 0l3 3m0 0l3-3m-3 3v-8"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'voice-debug',
|
||||
title: 'Voice Debug',
|
||||
description: 'Runtime status, silence threshold, and recording diagnostics',
|
||||
route: 'voice-debug',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3a3 3 0 00-3 3v6a3 3 0 006 0V6a3 3 0 00-3-3zm-7 9a7 7 0 0014 0m-7 7v2m-4 0h8"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'local-model-debug',
|
||||
title: 'Local Model Debug',
|
||||
description: 'Ollama config, asset downloads, model tests, and diagnostics',
|
||||
route: 'local-model-debug',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatBytes,
|
||||
formatEta,
|
||||
progressFromDownloads,
|
||||
progressFromStatus,
|
||||
} from '../../../utils/localAiHelpers';
|
||||
import {
|
||||
type LocalAiAssetsStatus,
|
||||
type LocalAiDiagnostics,
|
||||
type LocalAiDownloadsProgress,
|
||||
type LocalAiEmbeddingResult,
|
||||
type LocalAiSpeechResult,
|
||||
type LocalAiStatus,
|
||||
type LocalAiSuggestion,
|
||||
type LocalAiTtsResult,
|
||||
openhumanLocalAiAssetsStatus,
|
||||
openhumanLocalAiDiagnostics,
|
||||
openhumanLocalAiDownload,
|
||||
openhumanLocalAiDownloadAllAssets,
|
||||
openhumanLocalAiDownloadAsset,
|
||||
openhumanLocalAiDownloadsProgress,
|
||||
openhumanLocalAiEmbed,
|
||||
openhumanLocalAiPrompt,
|
||||
openhumanLocalAiSetOllamaPath,
|
||||
openhumanLocalAiStatus,
|
||||
openhumanLocalAiSuggestQuestions,
|
||||
openhumanLocalAiSummarize,
|
||||
openhumanLocalAiTranscribe,
|
||||
openhumanLocalAiTts,
|
||||
openhumanLocalAiVisionPrompt,
|
||||
} from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import ModelDownloadSection from './local-model/ModelDownloadSection';
|
||||
import ModelStatusSection from './local-model/ModelStatusSection';
|
||||
|
||||
const statusTone = (state: string): string => {
|
||||
switch (state) {
|
||||
case 'ready':
|
||||
return 'text-green-600';
|
||||
case 'downloading':
|
||||
case 'installing':
|
||||
case 'loading':
|
||||
return 'text-primary-600';
|
||||
case 'degraded':
|
||||
return 'text-amber-700';
|
||||
case 'disabled':
|
||||
return 'text-stone-500';
|
||||
default:
|
||||
return 'text-stone-700';
|
||||
}
|
||||
};
|
||||
|
||||
const LocalModelDebugPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
const [status, setStatus] = useState<LocalAiStatus | null>(null);
|
||||
const [assets, setAssets] = useState<LocalAiAssetsStatus | null>(null);
|
||||
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
|
||||
const [statusError, setStatusError] = useState<string>('');
|
||||
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
|
||||
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
|
||||
const [assetDownloadBusy, setAssetDownloadBusy] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [summaryInput, setSummaryInput] = useState('');
|
||||
const [summaryOutput, setSummaryOutput] = useState('');
|
||||
const [isSummaryLoading, setIsSummaryLoading] = useState(false);
|
||||
|
||||
const [suggestInput, setSuggestInput] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<LocalAiSuggestion[]>([]);
|
||||
const [isSuggestLoading, setIsSuggestLoading] = useState(false);
|
||||
|
||||
const [promptInput, setPromptInput] = useState('');
|
||||
const [promptOutput, setPromptOutput] = useState('');
|
||||
const [isPromptLoading, setIsPromptLoading] = useState(false);
|
||||
const [promptNoThink, setPromptNoThink] = useState(true);
|
||||
const [promptError, setPromptError] = useState('');
|
||||
|
||||
const [visionPromptInput, setVisionPromptInput] = useState('');
|
||||
const [visionImageInput, setVisionImageInput] = useState('');
|
||||
const [visionOutput, setVisionOutput] = useState('');
|
||||
const [isVisionLoading, setIsVisionLoading] = useState(false);
|
||||
|
||||
const [embeddingInput, setEmbeddingInput] = useState('');
|
||||
const [embeddingOutput, setEmbeddingOutput] = useState<LocalAiEmbeddingResult | null>(null);
|
||||
const [isEmbeddingLoading, setIsEmbeddingLoading] = useState(false);
|
||||
|
||||
const [audioPathInput, setAudioPathInput] = useState('');
|
||||
const [transcribeOutput, setTranscribeOutput] = useState<LocalAiSpeechResult | null>(null);
|
||||
const [isTranscribeLoading, setIsTranscribeLoading] = useState(false);
|
||||
|
||||
const [ttsInput, setTtsInput] = useState('');
|
||||
const [ttsOutputPath, setTtsOutputPath] = useState('');
|
||||
const [ttsOutput, setTtsOutput] = useState<LocalAiTtsResult | null>(null);
|
||||
const [isTtsLoading, setIsTtsLoading] = useState(false);
|
||||
|
||||
const [diagnostics, setDiagnostics] = useState<LocalAiDiagnostics | null>(null);
|
||||
const [isDiagnosticsLoading, setIsDiagnosticsLoading] = useState(false);
|
||||
const [diagnosticsError, setDiagnosticsError] = useState('');
|
||||
|
||||
const [showErrorDetail, setShowErrorDetail] = useState(false);
|
||||
const [ollamaPathInput, setOllamaPathInput] = useState('');
|
||||
const [isSettingPath, setIsSettingPath] = useState(false);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const downloadProgress = progressFromDownloads(downloads);
|
||||
if (downloadProgress != null) return downloadProgress;
|
||||
return progressFromStatus(status);
|
||||
}, [downloads, status]);
|
||||
|
||||
const currentState = downloads?.state ?? status?.state;
|
||||
const isInstalling = currentState === 'installing';
|
||||
const isIndeterminateDownload =
|
||||
isInstalling ||
|
||||
(currentState === 'downloading' &&
|
||||
typeof downloads?.progress !== 'number' &&
|
||||
typeof status?.download_progress !== 'number');
|
||||
const isInstallError = status?.state === 'degraded' && status?.error_category === 'install';
|
||||
|
||||
const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes;
|
||||
const totalBytes = downloads?.total_bytes ?? status?.total_bytes;
|
||||
const speedBps = downloads?.speed_bps ?? status?.download_speed_bps;
|
||||
const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds;
|
||||
|
||||
const downloadedText =
|
||||
typeof downloadedBytes === 'number'
|
||||
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
|
||||
: '';
|
||||
const speedText =
|
||||
typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : '';
|
||||
const etaText = formatEta(etaSeconds);
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const [statusResponse, assetsResponse, downloadsResponse] = await Promise.all([
|
||||
openhumanLocalAiStatus(),
|
||||
openhumanLocalAiAssetsStatus(),
|
||||
openhumanLocalAiDownloadsProgress(),
|
||||
]);
|
||||
setStatus(statusResponse.result);
|
||||
setAssets(assetsResponse.result);
|
||||
setDownloads(downloadsResponse.result);
|
||||
} catch {
|
||||
// Poll failures are non-critical — don't clear action errors.
|
||||
// Status/assets/downloads retain their last known values.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
const timer = setInterval(() => {
|
||||
void loadStatus();
|
||||
}, 1500);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const triggerDownload = async (force: boolean) => {
|
||||
setIsTriggeringDownload(true);
|
||||
setStatusError('');
|
||||
setBootstrapMessage('');
|
||||
try {
|
||||
await openhumanLocalAiDownload(force);
|
||||
await openhumanLocalAiDownloadAllAssets(force);
|
||||
const freshStatus = await openhumanLocalAiStatus();
|
||||
setStatus(freshStatus.result);
|
||||
if (freshStatus.result?.state === 'ready') {
|
||||
setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Models verified');
|
||||
}
|
||||
setTimeout(() => setBootstrapMessage(''), 3000);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to trigger local model bootstrap';
|
||||
setStatusError(message);
|
||||
} finally {
|
||||
setIsTriggeringDownload(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runSummaryTest = async () => {
|
||||
if (!summaryInput.trim()) return;
|
||||
setIsSummaryLoading(true);
|
||||
setSummaryOutput('');
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiSummarize(summaryInput.trim(), 220);
|
||||
setSummaryOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Summarization test failed');
|
||||
} finally {
|
||||
setIsSummaryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runSuggestTest = async () => {
|
||||
if (!suggestInput.trim()) return;
|
||||
setIsSuggestLoading(true);
|
||||
setSuggestions([]);
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiSuggestQuestions(suggestInput.trim());
|
||||
setSuggestions(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Suggestion test failed');
|
||||
} finally {
|
||||
setIsSuggestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runPromptTest = async () => {
|
||||
if (!promptInput.trim()) return;
|
||||
setIsPromptLoading(true);
|
||||
setPromptOutput('');
|
||||
setPromptError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiPrompt(promptInput.trim(), 180, promptNoThink);
|
||||
setPromptOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setPromptError(err instanceof Error ? err.message : 'Prompt test failed');
|
||||
} finally {
|
||||
setIsPromptLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runVisionTest = async () => {
|
||||
if (!visionPromptInput.trim() || !visionImageInput.trim()) return;
|
||||
setIsVisionLoading(true);
|
||||
setVisionOutput('');
|
||||
setStatusError('');
|
||||
try {
|
||||
const imageRefs = visionImageInput
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
const result = await openhumanLocalAiVisionPrompt(visionPromptInput.trim(), imageRefs, 220);
|
||||
setVisionOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Vision test failed');
|
||||
} finally {
|
||||
setIsVisionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runEmbeddingTest = async () => {
|
||||
if (!embeddingInput.trim()) return;
|
||||
setIsEmbeddingLoading(true);
|
||||
setEmbeddingOutput(null);
|
||||
setStatusError('');
|
||||
try {
|
||||
const inputs = embeddingInput
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
const result = await openhumanLocalAiEmbed(inputs);
|
||||
setEmbeddingOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Embedding test failed');
|
||||
} finally {
|
||||
setIsEmbeddingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTranscribeTest = async () => {
|
||||
if (!audioPathInput.trim()) return;
|
||||
setIsTranscribeLoading(true);
|
||||
setTranscribeOutput(null);
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiTranscribe(audioPathInput.trim());
|
||||
setTranscribeOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Transcription test failed');
|
||||
} finally {
|
||||
setIsTranscribeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTtsTest = async () => {
|
||||
if (!ttsInput.trim()) return;
|
||||
setIsTtsLoading(true);
|
||||
setTtsOutput(null);
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiTts(
|
||||
ttsInput.trim(),
|
||||
ttsOutputPath.trim() ? ttsOutputPath.trim() : undefined
|
||||
);
|
||||
setTtsOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'TTS test failed');
|
||||
} finally {
|
||||
setIsTtsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerAssetDownload = async (
|
||||
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
|
||||
) => {
|
||||
setAssetDownloadBusy(prev => ({ ...prev, [capability]: true }));
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiDownloadAsset(capability);
|
||||
setAssets(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : `Failed to download ${capability} asset`);
|
||||
} finally {
|
||||
setAssetDownloadBusy(prev => ({ ...prev, [capability]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetOllamaPath = async () => {
|
||||
setIsSettingPath(true);
|
||||
setStatusError('');
|
||||
try {
|
||||
await openhumanLocalAiSetOllamaPath(ollamaPathInput);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Failed to set Ollama path');
|
||||
} finally {
|
||||
setIsSettingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearOllamaPath = async () => {
|
||||
setOllamaPathInput('');
|
||||
setIsSettingPath(true);
|
||||
try {
|
||||
await openhumanLocalAiSetOllamaPath('');
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Failed to clear Ollama path');
|
||||
} finally {
|
||||
setIsSettingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunDiagnostics = async () => {
|
||||
setIsDiagnosticsLoading(true);
|
||||
setDiagnosticsError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiDiagnostics();
|
||||
setDiagnostics(result);
|
||||
} catch (err) {
|
||||
setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed');
|
||||
} finally {
|
||||
setIsDiagnosticsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Local Model Debug"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<ModelStatusSection
|
||||
status={status}
|
||||
downloads={downloads}
|
||||
diagnostics={diagnostics}
|
||||
isDiagnosticsLoading={isDiagnosticsLoading}
|
||||
diagnosticsError={diagnosticsError}
|
||||
statusError={statusError}
|
||||
isTriggeringDownload={isTriggeringDownload}
|
||||
bootstrapMessage={bootstrapMessage}
|
||||
progress={progress}
|
||||
isIndeterminateDownload={isIndeterminateDownload}
|
||||
isInstalling={isInstalling}
|
||||
isInstallError={isInstallError}
|
||||
showErrorDetail={showErrorDetail}
|
||||
ollamaPathInput={ollamaPathInput}
|
||||
isSettingPath={isSettingPath}
|
||||
downloadedText={downloadedText}
|
||||
speedText={speedText}
|
||||
etaText={etaText}
|
||||
statusTone={statusTone}
|
||||
onRefreshStatus={() => void loadStatus()}
|
||||
onTriggerDownload={force => void triggerDownload(force)}
|
||||
onSetOllamaPath={() => void handleSetOllamaPath()}
|
||||
onClearOllamaPath={() => void handleClearOllamaPath()}
|
||||
onSetOllamaPathInput={setOllamaPathInput}
|
||||
onToggleErrorDetail={() => setShowErrorDetail(v => !v)}
|
||||
onRunDiagnostics={() => void handleRunDiagnostics()}
|
||||
/>
|
||||
|
||||
<ModelDownloadSection
|
||||
assets={assets}
|
||||
assetDownloadBusy={assetDownloadBusy}
|
||||
statusTone={statusTone}
|
||||
onTriggerAssetDownload={capability => void triggerAssetDownload(capability)}
|
||||
summaryInput={summaryInput}
|
||||
summaryOutput={summaryOutput}
|
||||
isSummaryLoading={isSummaryLoading}
|
||||
onSetSummaryInput={setSummaryInput}
|
||||
onRunSummaryTest={() => void runSummaryTest()}
|
||||
suggestInput={suggestInput}
|
||||
suggestions={suggestions}
|
||||
isSuggestLoading={isSuggestLoading}
|
||||
onSetSuggestInput={setSuggestInput}
|
||||
onRunSuggestTest={() => void runSuggestTest()}
|
||||
promptInput={promptInput}
|
||||
promptOutput={promptOutput}
|
||||
promptError={promptError}
|
||||
isPromptLoading={isPromptLoading}
|
||||
promptNoThink={promptNoThink}
|
||||
onSetPromptInput={setPromptInput}
|
||||
onSetPromptNoThink={setPromptNoThink}
|
||||
onRunPromptTest={() => void runPromptTest()}
|
||||
visionPromptInput={visionPromptInput}
|
||||
visionImageInput={visionImageInput}
|
||||
visionOutput={visionOutput}
|
||||
isVisionLoading={isVisionLoading}
|
||||
onSetVisionPromptInput={setVisionPromptInput}
|
||||
onSetVisionImageInput={setVisionImageInput}
|
||||
onRunVisionTest={() => void runVisionTest()}
|
||||
embeddingInput={embeddingInput}
|
||||
embeddingOutput={embeddingOutput}
|
||||
isEmbeddingLoading={isEmbeddingLoading}
|
||||
onSetEmbeddingInput={setEmbeddingInput}
|
||||
onRunEmbeddingTest={() => void runEmbeddingTest()}
|
||||
audioPathInput={audioPathInput}
|
||||
transcribeOutput={transcribeOutput}
|
||||
isTranscribeLoading={isTranscribeLoading}
|
||||
onSetAudioPathInput={setAudioPathInput}
|
||||
onRunTranscribeTest={() => void runTranscribeTest()}
|
||||
ttsInput={ttsInput}
|
||||
ttsOutputPath={ttsOutputPath}
|
||||
ttsOutput={ttsOutput}
|
||||
isTtsLoading={isTtsLoading}
|
||||
onSetTtsInput={setTtsInput}
|
||||
onSetTtsOutputPath={setTtsOutputPath}
|
||||
onRunTtsTest={() => void runTtsTest()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalModelDebugPanel;
|
||||
@@ -8,55 +8,19 @@ import {
|
||||
} from '../../../utils/localAiHelpers';
|
||||
import {
|
||||
type ApplyPresetResult,
|
||||
type LocalAiAssetsStatus,
|
||||
type LocalAiDiagnostics,
|
||||
type LocalAiDownloadsProgress,
|
||||
type LocalAiEmbeddingResult,
|
||||
type LocalAiSpeechResult,
|
||||
type LocalAiStatus,
|
||||
type LocalAiSuggestion,
|
||||
type LocalAiTtsResult,
|
||||
openhumanLocalAiApplyPreset,
|
||||
openhumanLocalAiAssetsStatus,
|
||||
openhumanLocalAiDiagnostics,
|
||||
openhumanLocalAiDownload,
|
||||
openhumanLocalAiDownloadAllAssets,
|
||||
openhumanLocalAiDownloadAsset,
|
||||
openhumanLocalAiDownloadsProgress,
|
||||
openhumanLocalAiEmbed,
|
||||
openhumanLocalAiPresets,
|
||||
openhumanLocalAiPrompt,
|
||||
openhumanLocalAiSetOllamaPath,
|
||||
openhumanLocalAiStatus,
|
||||
openhumanLocalAiSuggestQuestions,
|
||||
openhumanLocalAiSummarize,
|
||||
openhumanLocalAiTranscribe,
|
||||
openhumanLocalAiTts,
|
||||
openhumanLocalAiVisionPrompt,
|
||||
type PresetsResponse,
|
||||
} from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import DeviceCapabilitySection from './local-model/DeviceCapabilitySection';
|
||||
import ModelDownloadSection from './local-model/ModelDownloadSection';
|
||||
import ModelStatusSection from './local-model/ModelStatusSection';
|
||||
|
||||
const statusTone = (state: string): string => {
|
||||
switch (state) {
|
||||
case 'ready':
|
||||
return 'text-green-600';
|
||||
case 'downloading':
|
||||
case 'installing':
|
||||
case 'loading':
|
||||
return 'text-primary-600';
|
||||
case 'degraded':
|
||||
return 'text-amber-700';
|
||||
case 'disabled':
|
||||
return 'text-stone-500';
|
||||
default:
|
||||
return 'text-stone-700';
|
||||
}
|
||||
};
|
||||
|
||||
const formatRamGb = (bytes: number): string => {
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
@@ -64,57 +28,18 @@ const formatRamGb = (bytes: number): string => {
|
||||
};
|
||||
|
||||
const LocalModelPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
|
||||
const [status, setStatus] = useState<LocalAiStatus | null>(null);
|
||||
const [assets, setAssets] = useState<LocalAiAssetsStatus | null>(null);
|
||||
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
|
||||
const [statusError, setStatusError] = useState<string>('');
|
||||
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
|
||||
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
|
||||
const [assetDownloadBusy, setAssetDownloadBusy] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [summaryInput, setSummaryInput] = useState('');
|
||||
const [summaryOutput, setSummaryOutput] = useState('');
|
||||
const [isSummaryLoading, setIsSummaryLoading] = useState(false);
|
||||
|
||||
const [suggestInput, setSuggestInput] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<LocalAiSuggestion[]>([]);
|
||||
const [isSuggestLoading, setIsSuggestLoading] = useState(false);
|
||||
|
||||
const [promptInput, setPromptInput] = useState('');
|
||||
const [promptOutput, setPromptOutput] = useState('');
|
||||
const [isPromptLoading, setIsPromptLoading] = useState(false);
|
||||
const [promptNoThink, setPromptNoThink] = useState(true);
|
||||
const [promptError, setPromptError] = useState('');
|
||||
|
||||
const [visionPromptInput, setVisionPromptInput] = useState('');
|
||||
const [visionImageInput, setVisionImageInput] = useState('');
|
||||
const [visionOutput, setVisionOutput] = useState('');
|
||||
const [isVisionLoading, setIsVisionLoading] = useState(false);
|
||||
|
||||
const [embeddingInput, setEmbeddingInput] = useState('');
|
||||
const [embeddingOutput, setEmbeddingOutput] = useState<LocalAiEmbeddingResult | null>(null);
|
||||
const [isEmbeddingLoading, setIsEmbeddingLoading] = useState(false);
|
||||
|
||||
const [audioPathInput, setAudioPathInput] = useState('');
|
||||
const [transcribeOutput, setTranscribeOutput] = useState<LocalAiSpeechResult | null>(null);
|
||||
const [isTranscribeLoading, setIsTranscribeLoading] = useState(false);
|
||||
|
||||
const [ttsInput, setTtsInput] = useState('');
|
||||
const [ttsOutputPath, setTtsOutputPath] = useState('');
|
||||
const [ttsOutput, setTtsOutput] = useState<LocalAiTtsResult | null>(null);
|
||||
const [isTtsLoading, setIsTtsLoading] = useState(false);
|
||||
|
||||
const [diagnostics, setDiagnostics] = useState<LocalAiDiagnostics | null>(null);
|
||||
const [isDiagnosticsLoading, setIsDiagnosticsLoading] = useState(false);
|
||||
const [diagnosticsError, setDiagnosticsError] = useState('');
|
||||
|
||||
const [presetsData, setPresetsData] = useState<PresetsResponse | null>(null);
|
||||
const [presetsLoading, setPresetsLoading] = useState(true);
|
||||
const [isApplyingPreset, setIsApplyingPreset] = useState(false);
|
||||
const [presetError, setPresetError] = useState('');
|
||||
const [presetSuccess, setPresetSuccess] = useState<ApplyPresetResult | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const downloadProgress = progressFromDownloads(downloads);
|
||||
@@ -128,38 +53,24 @@ const LocalModelPanel = () => {
|
||||
(currentState === 'downloading' &&
|
||||
typeof downloads?.progress !== 'number' &&
|
||||
typeof status?.download_progress !== 'number');
|
||||
const isInstallError = status?.state === 'degraded' && status?.error_category === 'install';
|
||||
const [showErrorDetail, setShowErrorDetail] = useState(false);
|
||||
const [ollamaPathInput, setOllamaPathInput] = useState('');
|
||||
const [isSettingPath, setIsSettingPath] = useState(false);
|
||||
const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes;
|
||||
const totalBytes = downloads?.total_bytes ?? status?.total_bytes;
|
||||
const speedBps = downloads?.speed_bps ?? status?.download_speed_bps;
|
||||
const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds;
|
||||
const downloadedText =
|
||||
typeof downloadedBytes === 'number'
|
||||
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
|
||||
: '';
|
||||
const speedText =
|
||||
typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : '';
|
||||
const etaText = formatEta(etaSeconds);
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const [statusResponse, assetsResponse, downloadsResponse] = await Promise.all([
|
||||
const [statusResponse, downloadsResponse] = await Promise.all([
|
||||
openhumanLocalAiStatus(),
|
||||
openhumanLocalAiAssetsStatus(),
|
||||
openhumanLocalAiDownloadsProgress(),
|
||||
]);
|
||||
setStatus(statusResponse.result);
|
||||
setAssets(assetsResponse.result);
|
||||
setDownloads(downloadsResponse.result);
|
||||
setStatusError('');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to read local model status';
|
||||
setStatusError(message);
|
||||
setStatus(null);
|
||||
setAssets(null);
|
||||
setDownloads(null);
|
||||
}
|
||||
};
|
||||
@@ -172,7 +83,6 @@ const LocalModelPanel = () => {
|
||||
setPresetError('');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load presets';
|
||||
console.warn('[LocalModelPanel] failed to load presets:', msg);
|
||||
setPresetError(msg);
|
||||
} finally {
|
||||
setPresetsLoading(false);
|
||||
@@ -227,188 +137,10 @@ const LocalModelPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const runSummaryTest = async () => {
|
||||
if (!summaryInput.trim()) return;
|
||||
setIsSummaryLoading(true);
|
||||
setSummaryOutput('');
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiSummarize(summaryInput.trim(), 220);
|
||||
setSummaryOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Summarization test failed');
|
||||
} finally {
|
||||
setIsSummaryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runSuggestTest = async () => {
|
||||
if (!suggestInput.trim()) return;
|
||||
setIsSuggestLoading(true);
|
||||
setSuggestions([]);
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiSuggestQuestions(suggestInput.trim());
|
||||
setSuggestions(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Suggestion test failed');
|
||||
} finally {
|
||||
setIsSuggestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runPromptTest = async () => {
|
||||
if (!promptInput.trim()) return;
|
||||
setIsPromptLoading(true);
|
||||
setPromptOutput('');
|
||||
setPromptError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiPrompt(promptInput.trim(), 180, promptNoThink);
|
||||
setPromptOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setPromptError(err instanceof Error ? err.message : 'Prompt test failed');
|
||||
} finally {
|
||||
setIsPromptLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runVisionTest = async () => {
|
||||
if (!visionPromptInput.trim() || !visionImageInput.trim()) return;
|
||||
setIsVisionLoading(true);
|
||||
setVisionOutput('');
|
||||
setStatusError('');
|
||||
try {
|
||||
const imageRefs = visionImageInput
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
const result = await openhumanLocalAiVisionPrompt(visionPromptInput.trim(), imageRefs, 220);
|
||||
setVisionOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Vision test failed');
|
||||
} finally {
|
||||
setIsVisionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runEmbeddingTest = async () => {
|
||||
if (!embeddingInput.trim()) return;
|
||||
setIsEmbeddingLoading(true);
|
||||
setEmbeddingOutput(null);
|
||||
setStatusError('');
|
||||
try {
|
||||
const inputs = embeddingInput
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
const result = await openhumanLocalAiEmbed(inputs);
|
||||
setEmbeddingOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Embedding test failed');
|
||||
} finally {
|
||||
setIsEmbeddingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTranscribeTest = async () => {
|
||||
if (!audioPathInput.trim()) return;
|
||||
setIsTranscribeLoading(true);
|
||||
setTranscribeOutput(null);
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiTranscribe(audioPathInput.trim());
|
||||
setTranscribeOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Transcription test failed');
|
||||
} finally {
|
||||
setIsTranscribeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTtsTest = async () => {
|
||||
if (!ttsInput.trim()) return;
|
||||
setIsTtsLoading(true);
|
||||
setTtsOutput(null);
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiTts(
|
||||
ttsInput.trim(),
|
||||
ttsOutputPath.trim() ? ttsOutputPath.trim() : undefined
|
||||
);
|
||||
setTtsOutput(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'TTS test failed');
|
||||
} finally {
|
||||
setIsTtsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerAssetDownload = async (
|
||||
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
|
||||
) => {
|
||||
setAssetDownloadBusy(prev => ({ ...prev, [capability]: true }));
|
||||
setStatusError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiDownloadAsset(capability);
|
||||
setAssets(result.result);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : `Failed to download ${capability} asset`);
|
||||
} finally {
|
||||
setAssetDownloadBusy(prev => ({ ...prev, [capability]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetOllamaPath = async () => {
|
||||
setIsSettingPath(true);
|
||||
setStatusError('');
|
||||
try {
|
||||
await openhumanLocalAiSetOllamaPath(ollamaPathInput);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Failed to set Ollama path');
|
||||
} finally {
|
||||
setIsSettingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearOllamaPath = async () => {
|
||||
setOllamaPathInput('');
|
||||
setIsSettingPath(true);
|
||||
try {
|
||||
await openhumanLocalAiSetOllamaPath('');
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setStatusError(err instanceof Error ? err.message : 'Failed to clear Ollama path');
|
||||
} finally {
|
||||
setIsSettingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunDiagnostics = async () => {
|
||||
setIsDiagnosticsLoading(true);
|
||||
setDiagnosticsError('');
|
||||
try {
|
||||
const result = await openhumanLocalAiDiagnostics();
|
||||
setDiagnostics(result);
|
||||
} catch (err) {
|
||||
setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed');
|
||||
} finally {
|
||||
setIsDiagnosticsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Local Model"
|
||||
title="Local AI Model"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
@@ -425,101 +157,86 @@ const LocalModelPanel = () => {
|
||||
formatRamGb={formatRamGb}
|
||||
/>
|
||||
|
||||
{/* Simplified download status */}
|
||||
<section className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Model Status</h3>
|
||||
|
||||
<div className="text-sm text-stone-600">
|
||||
State:{' '}
|
||||
<span
|
||||
className={`font-medium ${
|
||||
currentState === 'ready'
|
||||
? 'text-green-600'
|
||||
: currentState === 'downloading' || currentState === 'installing'
|
||||
? 'text-primary-600'
|
||||
: currentState === 'degraded'
|
||||
? 'text-amber-700'
|
||||
: 'text-stone-700'
|
||||
}`}>
|
||||
{currentState ?? 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(currentState === 'downloading' || isInstalling) && (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full h-2 rounded-full bg-stone-200 overflow-hidden">
|
||||
{isIndeterminateDownload ? (
|
||||
<div className="h-full bg-primary-500 animate-pulse rounded-full w-1/2" />
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-primary-500 rounded-full transition-all"
|
||||
style={{ width: `${String(Math.min(progress ?? 0, 100))}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-stone-500">
|
||||
<span>
|
||||
{typeof downloadedBytes === 'number'
|
||||
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
|
||||
: ''}
|
||||
</span>
|
||||
<span>
|
||||
{typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : ''}
|
||||
{etaSeconds ? ` · ${formatEta(etaSeconds)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bootstrapMessage && <div className="text-xs text-green-700">{bootstrapMessage}</div>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void triggerDownload(false)}
|
||||
disabled={isTriggeringDownload}
|
||||
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
|
||||
{isTriggeringDownload ? 'Downloading…' : 'Download Models'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadStatus()}
|
||||
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
|
||||
{statusError}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(prev => !prev)}
|
||||
className="flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700 transition-colors">
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
onClick={() => navigateToSettings('local-model-debug')}
|
||||
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
|
||||
Advanced settings
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{showAdvanced ? 'Hide Advanced' : 'Show Advanced'}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<ModelStatusSection
|
||||
status={status}
|
||||
downloads={downloads}
|
||||
diagnostics={diagnostics}
|
||||
isDiagnosticsLoading={isDiagnosticsLoading}
|
||||
diagnosticsError={diagnosticsError}
|
||||
statusError={statusError}
|
||||
isTriggeringDownload={isTriggeringDownload}
|
||||
bootstrapMessage={bootstrapMessage}
|
||||
progress={progress}
|
||||
isIndeterminateDownload={isIndeterminateDownload}
|
||||
isInstalling={isInstalling}
|
||||
isInstallError={isInstallError}
|
||||
showErrorDetail={showErrorDetail}
|
||||
ollamaPathInput={ollamaPathInput}
|
||||
isSettingPath={isSettingPath}
|
||||
downloadedText={downloadedText}
|
||||
speedText={speedText}
|
||||
etaText={etaText}
|
||||
statusTone={statusTone}
|
||||
onRefreshStatus={() => void loadStatus()}
|
||||
onTriggerDownload={force => void triggerDownload(force)}
|
||||
onSetOllamaPath={() => void handleSetOllamaPath()}
|
||||
onClearOllamaPath={() => void handleClearOllamaPath()}
|
||||
onSetOllamaPathInput={setOllamaPathInput}
|
||||
onToggleErrorDetail={() => setShowErrorDetail(v => !v)}
|
||||
onRunDiagnostics={() => void handleRunDiagnostics()}
|
||||
/>
|
||||
|
||||
<ModelDownloadSection
|
||||
assets={assets}
|
||||
assetDownloadBusy={assetDownloadBusy}
|
||||
statusTone={statusTone}
|
||||
onTriggerAssetDownload={capability => void triggerAssetDownload(capability)}
|
||||
summaryInput={summaryInput}
|
||||
summaryOutput={summaryOutput}
|
||||
isSummaryLoading={isSummaryLoading}
|
||||
onSetSummaryInput={setSummaryInput}
|
||||
onRunSummaryTest={() => void runSummaryTest()}
|
||||
suggestInput={suggestInput}
|
||||
suggestions={suggestions}
|
||||
isSuggestLoading={isSuggestLoading}
|
||||
onSetSuggestInput={setSuggestInput}
|
||||
onRunSuggestTest={() => void runSuggestTest()}
|
||||
promptInput={promptInput}
|
||||
promptOutput={promptOutput}
|
||||
promptError={promptError}
|
||||
isPromptLoading={isPromptLoading}
|
||||
promptNoThink={promptNoThink}
|
||||
onSetPromptInput={setPromptInput}
|
||||
onSetPromptNoThink={setPromptNoThink}
|
||||
onRunPromptTest={() => void runPromptTest()}
|
||||
visionPromptInput={visionPromptInput}
|
||||
visionImageInput={visionImageInput}
|
||||
visionOutput={visionOutput}
|
||||
isVisionLoading={isVisionLoading}
|
||||
onSetVisionPromptInput={setVisionPromptInput}
|
||||
onSetVisionImageInput={setVisionImageInput}
|
||||
onRunVisionTest={() => void runVisionTest()}
|
||||
embeddingInput={embeddingInput}
|
||||
embeddingOutput={embeddingOutput}
|
||||
isEmbeddingLoading={isEmbeddingLoading}
|
||||
onSetEmbeddingInput={setEmbeddingInput}
|
||||
onRunEmbeddingTest={() => void runEmbeddingTest()}
|
||||
audioPathInput={audioPathInput}
|
||||
transcribeOutput={transcribeOutput}
|
||||
isTranscribeLoading={isTranscribeLoading}
|
||||
onSetAudioPathInput={setAudioPathInput}
|
||||
onRunTranscribeTest={() => void runTranscribeTest()}
|
||||
ttsInput={ttsInput}
|
||||
ttsOutputPath={ttsOutputPath}
|
||||
ttsOutput={ttsOutput}
|
||||
isTtsLoading={isTtsLoading}
|
||||
onSetTtsInput={setTtsInput}
|
||||
onSetTtsOutputPath={setTtsOutputPath}
|
||||
onRunTtsTest={() => void runTtsTest()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { type ComponentProps, useRef, useState } from 'react';
|
||||
|
||||
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
|
||||
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
|
||||
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const DebugSection = ({
|
||||
state,
|
||||
}: {
|
||||
state: ComponentProps<typeof ScreenIntelligenceDebugPanel>['state'];
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
className="flex w-full items-center justify-between text-sm font-semibold text-stone-900">
|
||||
<span>Debug & Diagnostics</span>
|
||||
<span className="text-xs text-stone-400">{isOpen ? 'Collapse' : 'Expand'}</span>
|
||||
</button>
|
||||
{isOpen && <ScreenIntelligenceDebugPanel state={state} />}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ScreenAwarenessDebugPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const {
|
||||
status,
|
||||
lastError,
|
||||
isLoadingVision,
|
||||
recentVisionSummaries,
|
||||
refreshStatus,
|
||||
refreshVision,
|
||||
runCaptureTest,
|
||||
captureTestResult,
|
||||
isCaptureTestRunning,
|
||||
} = useScreenIntelligenceState({ loadVision: true, visionLimit: 10, pollMs: 2000 });
|
||||
|
||||
const [baselineFps, setBaselineFps] = useState<string>('1');
|
||||
const [useVisionModel, setUseVisionModel] = useState<boolean>(true);
|
||||
const [keepScreenshots, setKeepScreenshots] = useState<boolean>(false);
|
||||
const [allowlistText, setAllowlistText] = useState('');
|
||||
const [denylistText, setDenylistText] = useState('');
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
|
||||
// Initialize form state from server config once on first render where config
|
||||
// is available. After initialization, form state is user-controlled until save.
|
||||
// This runs during render (not in useEffect) so it is synchronous and avoids
|
||||
// the set-state-in-effect lint rule.
|
||||
const initializedRef = useRef(false);
|
||||
if (!initializedRef.current && status?.config) {
|
||||
initializedRef.current = true;
|
||||
// One-time assignment — React batches these with the current render.
|
||||
setBaselineFps(String(status.config.baseline_fps ?? 1));
|
||||
setUseVisionModel(status.config.use_vision_model ?? true);
|
||||
setKeepScreenshots(status.config.keep_screenshots ?? false);
|
||||
setAllowlistText((status.config.allowlist ?? []).join('\n'));
|
||||
setDenylistText((status.config.denylist ?? []).join('\n'));
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!isTauri()) return;
|
||||
setConfigError(null);
|
||||
setIsSavingConfig(true);
|
||||
try {
|
||||
const fps = Number(baselineFps);
|
||||
await openhumanUpdateScreenIntelligenceSettings({
|
||||
enabled: status?.config.enabled ?? false,
|
||||
policy_mode:
|
||||
status?.config.policy_mode === 'whitelist_only'
|
||||
? 'whitelist_only'
|
||||
: 'all_except_blacklist',
|
||||
baseline_fps: Number.isFinite(fps) && fps > 0 ? fps : 1,
|
||||
use_vision_model: useVisionModel,
|
||||
keep_screenshots: keepScreenshots,
|
||||
allowlist: allowlistText
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean),
|
||||
denylist: denylistText
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
setConfigError(error instanceof Error ? error.message : 'Failed to save screen intelligence');
|
||||
} finally {
|
||||
setIsSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title="Screen Awareness Debug"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
|
||||
{/* Advanced policy settings */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Screen Intelligence Policy</h3>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Baseline FPS</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.2}
|
||||
max={30}
|
||||
step={0.1}
|
||||
value={baselineFps}
|
||||
onChange={event => setBaselineFps(event.target.value)}
|
||||
className="w-24 rounded border border-stone-200 bg-white px-2 py-1 text-xs text-stone-700"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div>
|
||||
<span className="text-sm text-stone-700">Use Vision Model</span>
|
||||
<p className="text-xs text-stone-400">
|
||||
Send screenshots to a vision LLM for richer context. When off, only OCR text is used
|
||||
with a text LLM — faster and no vision model required.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useVisionModel}
|
||||
onChange={event => setUseVisionModel(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div>
|
||||
<span className="text-sm text-stone-700">Keep Screenshots</span>
|
||||
<p className="text-xs text-stone-400">
|
||||
Save captured screenshots to the workspace instead of deleting after processing
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepScreenshots}
|
||||
onChange={event => setKeepScreenshots(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">Allowlist (one rule per line)</div>
|
||||
<textarea
|
||||
value={allowlistText}
|
||||
onChange={event => setAllowlistText(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">Denylist (one rule per line)</div>
|
||||
<textarea
|
||||
value={denylistText}
|
||||
onChange={event => setDenylistText(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSavingConfig}
|
||||
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
|
||||
{isSavingConfig ? 'Saving…' : 'Save Screen Intelligence Settings'}
|
||||
</button>
|
||||
{configError && <div className="text-xs text-red-600">{configError}</div>}
|
||||
</section>
|
||||
|
||||
{/* Session stats */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Session Stats</h3>
|
||||
<div className="text-sm text-stone-600 space-y-1">
|
||||
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
|
||||
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
|
||||
<div>Vision: {status?.session.vision_state ?? 'idle'}</div>
|
||||
<div>Vision queue: {status?.session.vision_queue_depth ?? 0}</div>
|
||||
<div>
|
||||
Last vision:{' '}
|
||||
{status?.session.last_vision_at_ms
|
||||
? new Date(status.session.last_vision_at_ms).toLocaleTimeString()
|
||||
: 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Vision summaries */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Vision Summaries</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshVision(10)}
|
||||
disabled={isLoadingVision}
|
||||
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-600 disabled:opacity-50">
|
||||
{isLoadingVision ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recentVisionSummaries.length === 0 ? (
|
||||
<div className="text-xs text-stone-500">No summaries yet.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentVisionSummaries.map(summary => (
|
||||
<div
|
||||
key={summary.id}
|
||||
className="rounded-xl border border-stone-200 bg-white p-3 text-xs text-stone-200">
|
||||
<div className="text-stone-500">
|
||||
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
|
||||
{summary.app_name ?? 'Unknown App'}
|
||||
{summary.window_title ? ` · ${summary.window_title}` : ''}
|
||||
</div>
|
||||
<div className="mt-1 text-stone-800">{summary.actionable_notes}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Debug & Diagnostics (collapsible) */}
|
||||
<DebugSection
|
||||
state={{
|
||||
status,
|
||||
recentVisionSummaries,
|
||||
lastError,
|
||||
captureTestResult,
|
||||
isCaptureTestRunning,
|
||||
refreshStatus,
|
||||
refreshVision,
|
||||
runCaptureTest,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Platform unsupported notice */}
|
||||
{status !== null && !status.platform_supported && (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Screen Intelligence V1 is currently supported on macOS only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error notice */}
|
||||
{lastError && (
|
||||
<div className="rounded-xl border border-red-300 bg-red-50 p-3 text-sm text-red-600">
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenAwarenessDebugPanel;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
|
||||
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
|
||||
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
@@ -20,29 +19,8 @@ const formatRemaining = (remainingMs: number | null): string => {
|
||||
return `${mins}:${secs}`;
|
||||
};
|
||||
|
||||
const DebugSection = ({
|
||||
state,
|
||||
}: {
|
||||
state: ComponentProps<typeof ScreenIntelligenceDebugPanel>['state'];
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
className="flex w-full items-center justify-between text-sm font-semibold text-stone-900">
|
||||
<span>Debug & Diagnostics</span>
|
||||
<span className="text-xs text-stone-400">{isOpen ? 'Collapse' : 'Expand'}</span>
|
||||
</button>
|
||||
{isOpen && <ScreenIntelligenceDebugPanel state={state} />}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ScreenIntelligencePanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
|
||||
const {
|
||||
status,
|
||||
lastRestartSummary,
|
||||
@@ -51,39 +29,23 @@ const ScreenIntelligencePanel = () => {
|
||||
isRestartingCore,
|
||||
isStartingSession,
|
||||
isStoppingSession,
|
||||
isLoadingVision,
|
||||
isFlushingVision,
|
||||
recentVisionSummaries,
|
||||
lastError,
|
||||
refreshStatus,
|
||||
refreshVision,
|
||||
startSession,
|
||||
stopSession,
|
||||
flushVision,
|
||||
requestPermission,
|
||||
refreshPermissionsWithRestart,
|
||||
runCaptureTest,
|
||||
captureTestResult,
|
||||
isCaptureTestRunning,
|
||||
} = useScreenIntelligenceState({ loadVision: true, visionLimit: 10, pollMs: 2000 });
|
||||
} = useScreenIntelligenceState({ loadVision: false, pollMs: 2000 });
|
||||
const [featureOverrides, setFeatureOverrides] = useState<{ screen_monitoring?: boolean }>({});
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
const [policyMode, setPolicyMode] = useState<'all_except_blacklist' | 'whitelist_only'>(
|
||||
'all_except_blacklist'
|
||||
);
|
||||
const [baselineFps, setBaselineFps] = useState<string>('1');
|
||||
const [useVisionModel, setUseVisionModel] = useState<boolean>(true);
|
||||
const [keepScreenshots, setKeepScreenshots] = useState<boolean>(false);
|
||||
const [allowlistText, setAllowlistText] = useState('');
|
||||
const [denylistText, setDenylistText] = useState('');
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
|
||||
// CoreStateProvider polls every 2s (CoreStateProvider.tsx POLL_MS), producing a
|
||||
// new `status` object reference on every tick even when the underlying config is
|
||||
// unchanged. Keying this effect on `status?.config` identity would therefore
|
||||
// clobber in-progress user edits every 2 seconds. Compare the serialized value
|
||||
// instead, so we only re-sync when the server config has actually changed.
|
||||
const lastSyncedConfigSigRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!status?.config) {
|
||||
@@ -98,11 +60,6 @@ const ScreenIntelligencePanel = () => {
|
||||
setPolicyMode(
|
||||
status.config.policy_mode === 'whitelist_only' ? 'whitelist_only' : 'all_except_blacklist'
|
||||
);
|
||||
setBaselineFps(String(status.config.baseline_fps ?? 1));
|
||||
setUseVisionModel(status.config.use_vision_model ?? true);
|
||||
setKeepScreenshots(status.config.keep_screenshots ?? false);
|
||||
setAllowlistText((status.config.allowlist ?? []).join('\n'));
|
||||
setDenylistText((status.config.denylist ?? []).join('\n'));
|
||||
}, [status?.config]);
|
||||
|
||||
const screenMonitoring =
|
||||
@@ -132,21 +89,14 @@ const ScreenIntelligencePanel = () => {
|
||||
setConfigError(null);
|
||||
setIsSavingConfig(true);
|
||||
try {
|
||||
const fps = Number(baselineFps);
|
||||
await openhumanUpdateScreenIntelligenceSettings({
|
||||
enabled,
|
||||
policy_mode: policyMode,
|
||||
baseline_fps: Number.isFinite(fps) && fps > 0 ? fps : 1,
|
||||
use_vision_model: useVisionModel,
|
||||
keep_screenshots: keepScreenshots,
|
||||
allowlist: allowlistText
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean),
|
||||
denylist: denylistText
|
||||
.split('\n')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean),
|
||||
baseline_fps: status?.config.baseline_fps ?? 1,
|
||||
use_vision_model: status?.config.use_vision_model ?? true,
|
||||
keep_screenshots: status?.config.keep_screenshots ?? false,
|
||||
allowlist: status?.config.allowlist ?? [],
|
||||
denylist: status?.config.denylist ?? [],
|
||||
});
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
@@ -159,7 +109,7 @@ const ScreenIntelligencePanel = () => {
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title="Screen Intelligence"
|
||||
title="Screen Awareness"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
@@ -182,7 +132,7 @@ const ScreenIntelligencePanel = () => {
|
||||
/>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Screen Intelligence Policy</h3>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Screen Awareness</h3>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Enabled</span>
|
||||
@@ -210,77 +160,6 @@ const ScreenIntelligencePanel = () => {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Baseline FPS</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.2}
|
||||
max={30}
|
||||
step={0.1}
|
||||
value={baselineFps}
|
||||
onChange={event => setBaselineFps(event.target.value)}
|
||||
className="w-24 rounded border border-stone-200 bg-white px-2 py-1 text-xs text-stone-700"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div>
|
||||
<span className="text-sm text-stone-700">Use Vision Model</span>
|
||||
<p className="text-xs text-stone-400">
|
||||
Send screenshots to a vision LLM for richer context. When off, only OCR text is used
|
||||
with a text LLM — faster and no vision model required.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useVisionModel}
|
||||
onChange={event => setUseVisionModel(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div>
|
||||
<span className="text-sm text-stone-700">Keep Screenshots</span>
|
||||
<p className="text-xs text-stone-400">
|
||||
Save captured screenshots to the workspace instead of deleting after processing
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepScreenshots}
|
||||
onChange={event => setKeepScreenshots(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">Allowlist (one rule per line)</div>
|
||||
<textarea
|
||||
value={allowlistText}
|
||||
onChange={event => setAllowlistText(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-stone-600">Denylist (one rule per line)</div>
|
||||
<textarea
|
||||
value={denylistText}
|
||||
onChange={event => setDenylistText(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSavingConfig}
|
||||
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
|
||||
{isSavingConfig ? 'Saving…' : 'Save Screen Intelligence Settings'}
|
||||
</button>
|
||||
{configError && <div className="text-xs text-red-600">{configError}</div>}
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<span className="text-sm text-stone-700">Screen Monitoring</span>
|
||||
<input
|
||||
@@ -294,6 +173,15 @@ const ScreenIntelligencePanel = () => {
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={isSavingConfig}
|
||||
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
|
||||
{isSavingConfig ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
{configError && <div className="text-xs text-red-600">{configError}</div>}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
@@ -301,16 +189,6 @@ const ScreenIntelligencePanel = () => {
|
||||
<div className="text-sm text-stone-600 space-y-1">
|
||||
<div>Status: {status?.session.active ? 'Active' : 'Stopped'}</div>
|
||||
<div>Remaining: {remaining}</div>
|
||||
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
|
||||
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
|
||||
<div>Vision: {status?.session.vision_state ?? 'idle'}</div>
|
||||
<div>Vision queue: {status?.session.vision_queue_depth ?? 0}</div>
|
||||
<div>
|
||||
Last vision:{' '}
|
||||
{status?.session.last_vision_at_ms
|
||||
? new Date(status.session.last_vision_at_ms).toLocaleTimeString()
|
||||
: 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -344,53 +222,9 @@ const ScreenIntelligencePanel = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Vision Summaries</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshVision(10)}
|
||||
disabled={isLoadingVision}
|
||||
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-600 disabled:opacity-50">
|
||||
{isLoadingVision ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recentVisionSummaries.length === 0 ? (
|
||||
<div className="text-xs text-stone-500">No summaries yet.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentVisionSummaries.map(summary => (
|
||||
<div
|
||||
key={summary.id}
|
||||
className="rounded-xl border border-stone-200 bg-white p-3 text-xs text-stone-200">
|
||||
<div className="text-stone-500">
|
||||
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
|
||||
{summary.app_name ?? 'Unknown App'}
|
||||
{summary.window_title ? ` · ${summary.window_title}` : ''}
|
||||
</div>
|
||||
<div className="mt-1 text-stone-800">{summary.actionable_notes}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<DebugSection
|
||||
state={{
|
||||
status,
|
||||
recentVisionSummaries,
|
||||
lastError,
|
||||
captureTestResult,
|
||||
isCaptureTestRunning,
|
||||
refreshStatus,
|
||||
refreshVision,
|
||||
runCaptureTest,
|
||||
}}
|
||||
/>
|
||||
{status !== null && !status.platform_supported && (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Screen Intelligence V1 is currently supported on macOS only.
|
||||
Screen Awareness is currently supported on macOS only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,6 +233,16 @@ const ScreenIntelligencePanel = () => {
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateToSettings('screen-awareness-debug')}
|
||||
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
|
||||
Advanced settings
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
openhumanGetVoiceServerSettings,
|
||||
openhumanUpdateVoiceServerSettings,
|
||||
openhumanVoiceServerStatus,
|
||||
openhumanVoiceStatus,
|
||||
type VoiceServerSettings,
|
||||
type VoiceServerStatus,
|
||||
type VoiceStatus,
|
||||
} from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const VoiceDebugPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
|
||||
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
|
||||
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(null);
|
||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const settingsRef = useRef<VoiceServerSettings | null>(null);
|
||||
const savedSettingsRef = useRef<VoiceServerSettings | null>(null);
|
||||
|
||||
const hasUnsavedChanges =
|
||||
settings != null &&
|
||||
savedSettings != null &&
|
||||
JSON.stringify(settings) !== JSON.stringify(savedSettings);
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
savedSettingsRef.current = savedSettings;
|
||||
}, [savedSettings]);
|
||||
|
||||
const loadData = async (forceSettings = false) => {
|
||||
try {
|
||||
const [settingsResponse, serverResponse, voiceResponse] = await Promise.all([
|
||||
openhumanGetVoiceServerSettings(),
|
||||
openhumanVoiceServerStatus(),
|
||||
openhumanVoiceStatus(),
|
||||
]);
|
||||
// Only overwrite local settings if there are no unsaved edits,
|
||||
// or if explicitly forced (e.g. after save or initial load).
|
||||
// This prevents the 2s polling timer from clobbering user input.
|
||||
const currentSettings = settingsRef.current;
|
||||
const currentSavedSettings = savedSettingsRef.current;
|
||||
if (
|
||||
forceSettings ||
|
||||
!currentSettings ||
|
||||
JSON.stringify(currentSettings) === JSON.stringify(currentSavedSettings)
|
||||
) {
|
||||
setSettings(settingsResponse.result);
|
||||
}
|
||||
setSavedSettings(settingsResponse.result);
|
||||
setServerStatus(serverResponse);
|
||||
setVoiceStatus(voiceResponse);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load voice debug data';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(true);
|
||||
const timer = window.setInterval(() => {
|
||||
void loadData(false);
|
||||
}, 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const updateSetting = <K extends keyof VoiceServerSettings>(
|
||||
key: K,
|
||||
value: VoiceServerSettings[K]
|
||||
) => {
|
||||
setSettings(current => (current ? { ...current, [key]: value } : current));
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await openhumanUpdateVoiceServerSettings({
|
||||
auto_start: settings.auto_start,
|
||||
hotkey: settings.hotkey,
|
||||
activation_mode: settings.activation_mode,
|
||||
skip_cleanup: settings.skip_cleanup,
|
||||
min_duration_secs: settings.min_duration_secs,
|
||||
silence_threshold: settings.silence_threshold,
|
||||
custom_dictionary: settings.custom_dictionary,
|
||||
});
|
||||
setNotice('Debug settings saved.');
|
||||
await loadData(true);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save voice settings';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Voice Debug"
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Runtime status section */}
|
||||
<section className="space-y-3">
|
||||
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Runtime Status</h3>
|
||||
<p className="text-xs text-stone-500 mt-1">
|
||||
Live diagnostics for the voice server and speech-to-text engine.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadData()}
|
||||
className="text-xs text-primary-600 hover:text-primary-700">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-stone-200 bg-white p-3">
|
||||
<div className="text-[10px] uppercase tracking-wide text-stone-500">Server</div>
|
||||
<div className="mt-1 font-medium text-stone-900">
|
||||
{serverStatus ? serverStatus.state : isLoading ? 'Loading…' : 'Unavailable'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-stone-200 bg-white p-3">
|
||||
<div className="text-[10px] uppercase tracking-wide text-stone-500">STT</div>
|
||||
<div className="mt-1 font-medium text-stone-900">
|
||||
{voiceStatus?.stt_available ? 'Ready' : 'Not ready'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverStatus && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-stone-600">
|
||||
<div>Hotkey: {serverStatus.hotkey || 'n/a'}</div>
|
||||
<div>Mode: {serverStatus.activation_mode}</div>
|
||||
<div>Transcriptions: {serverStatus.transcription_count}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverStatus?.last_error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
|
||||
<div className="font-medium mb-1">Server Error</div>
|
||||
{serverStatus.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<section className="space-y-3">
|
||||
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Advanced Settings</h3>
|
||||
<p className="text-xs text-stone-500 mt-1">
|
||||
Low-level tuning parameters for recording and silence detection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{settings && (
|
||||
<>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600">
|
||||
Minimum Recording Seconds
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={settings.min_duration_secs}
|
||||
onChange={e => updateSetting('min_duration_secs', Number(e.target.value) || 0)}
|
||||
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600">
|
||||
Silence Threshold (RMS)
|
||||
</span>
|
||||
<p className="text-[11px] text-stone-400">
|
||||
Recordings with energy below this are treated as silence and skipped. Lower =
|
||||
more sensitive.
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.001"
|
||||
value={settings.silence_threshold}
|
||||
onChange={e =>
|
||||
updateSetting('silence_threshold', Number(e.target.value) || 0.002)
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-700">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveSettings()}
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceDebugPanel;
|
||||
@@ -20,9 +20,9 @@ const VoicePanel = () => {
|
||||
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
|
||||
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
|
||||
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(null);
|
||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
|
||||
const [, setVoiceStatus] = useState<VoiceStatus | null>(null);
|
||||
const [sttReady, setSttReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
@@ -53,9 +53,6 @@ const VoicePanel = () => {
|
||||
openhumanVoiceStatus(),
|
||||
openhumanLocalAiAssetsStatus(),
|
||||
]);
|
||||
// Only overwrite local settings if there are no unsaved edits,
|
||||
// or if explicitly forced (e.g. after save or initial load).
|
||||
// This prevents the 2s polling timer from clobbering user input.
|
||||
const currentSettings = settingsRef.current;
|
||||
const currentSavedSettings = savedSettingsRef.current;
|
||||
if (
|
||||
@@ -191,60 +188,12 @@ const VoicePanel = () => {
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<section className="space-y-3">
|
||||
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
|
||||
<p className="text-xs text-stone-500 mt-1">
|
||||
Hold the hotkey to dictate and insert text into the active field.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadData()}
|
||||
className="text-xs text-primary-600 hover:text-primary-700">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-stone-200 bg-white p-3">
|
||||
<div className="text-[10px] uppercase tracking-wide text-stone-500">Server</div>
|
||||
<div className="mt-1 font-medium text-stone-900">
|
||||
{serverStatus ? serverStatus.state : isLoading ? 'Loading…' : 'Unavailable'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-stone-200 bg-white p-3">
|
||||
<div className="text-[10px] uppercase tracking-wide text-stone-500">STT</div>
|
||||
<div className="mt-1 font-medium text-stone-900">
|
||||
{voiceStatus?.stt_available ? 'Ready' : 'Not ready'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverStatus && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-stone-600">
|
||||
<div>Hotkey: {serverStatus.hotkey || 'n/a'}</div>
|
||||
<div>Mode: {serverStatus.activation_mode}</div>
|
||||
<div>Transcriptions: {serverStatus.transcription_count}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverStatus?.last_error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
|
||||
{serverStatus.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`space-y-3 ${disabled ? 'opacity-60' : ''}`}>
|
||||
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Voice Server Settings</h3>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Voice Settings</h3>
|
||||
<p className="text-xs text-stone-500 mt-1">
|
||||
Configure startup behavior, hotkey handling, and transcription cleanup.
|
||||
Hold the hotkey to dictate and insert text into the active field.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -286,53 +235,14 @@ const VoicePanel = () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.auto_start}
|
||||
onChange={e => updateSetting('auto_start', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
Start voice server automatically with the core
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600">
|
||||
Minimum Recording Seconds
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={settings.min_duration_secs}
|
||||
onChange={e =>
|
||||
updateSetting('min_duration_secs', Number(e.target.value) || 0)
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600">
|
||||
Silence Threshold (RMS)
|
||||
</span>
|
||||
<p className="text-[11px] text-stone-400">
|
||||
Recordings with energy below this are treated as silence and skipped. Lower =
|
||||
more sensitive.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.001"
|
||||
value={settings.silence_threshold}
|
||||
onChange={e =>
|
||||
updateSetting('silence_threshold', Number(e.target.value) || 0.002)
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
type="checkbox"
|
||||
checked={settings.auto_start}
|
||||
onChange={e => updateSetting('auto_start', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
Start voice server automatically with the core
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -452,6 +362,16 @@ const VoicePanel = () => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateToSettings('voice-debug')}
|
||||
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
|
||||
Advanced settings
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type ScreenIntelligenceState,
|
||||
useScreenIntelligenceState,
|
||||
} from '../../../../features/screen-intelligence/useScreenIntelligenceState';
|
||||
import AccessibilityPanel from '../AccessibilityPanel';
|
||||
|
||||
vi.mock('../../../../features/screen-intelligence/useScreenIntelligenceState', () => ({
|
||||
useScreenIntelligenceState: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockState: ScreenIntelligenceState = {
|
||||
status: {
|
||||
platform_supported: true,
|
||||
permissions: {
|
||||
screen_recording: 'unknown',
|
||||
accessibility: 'granted',
|
||||
input_monitoring: 'unknown',
|
||||
},
|
||||
features: { screen_monitoring: true },
|
||||
session: {
|
||||
active: false,
|
||||
started_at_ms: null,
|
||||
expires_at_ms: null,
|
||||
remaining_ms: null,
|
||||
ttl_secs: 300,
|
||||
panic_hotkey: 'Cmd+Shift+.',
|
||||
stop_reason: null,
|
||||
frames_in_memory: 0,
|
||||
last_capture_at_ms: null,
|
||||
last_context: null,
|
||||
vision_enabled: true,
|
||||
vision_state: 'idle',
|
||||
vision_queue_depth: 0,
|
||||
last_vision_at_ms: null,
|
||||
last_vision_summary: null,
|
||||
},
|
||||
config: {
|
||||
enabled: true,
|
||||
capture_policy: 'hybrid',
|
||||
policy_mode: 'all_except_blacklist',
|
||||
baseline_fps: 1,
|
||||
vision_enabled: true,
|
||||
session_ttl_secs: 300,
|
||||
panic_stop_hotkey: 'Cmd+Shift+.',
|
||||
autocomplete_enabled: true,
|
||||
use_vision_model: true,
|
||||
keep_screenshots: false,
|
||||
allowlist: [],
|
||||
denylist: ['wallet'],
|
||||
},
|
||||
denylist: ['wallet'],
|
||||
is_context_blocked: false,
|
||||
},
|
||||
lastRestartSummary: null,
|
||||
recentVisionSummaries: [],
|
||||
captureTestResult: null,
|
||||
isCaptureTestRunning: false,
|
||||
isLoading: false,
|
||||
isRequestingPermissions: false,
|
||||
isRestartingCore: false,
|
||||
isStartingSession: false,
|
||||
isStoppingSession: false,
|
||||
isLoadingVision: false,
|
||||
isFlushingVision: false,
|
||||
lastError: null,
|
||||
refreshStatus: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
refreshPermissionsWithRestart: vi.fn(),
|
||||
startSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
refreshVision: vi.fn(),
|
||||
flushVision: vi.fn(),
|
||||
runCaptureTest: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
};
|
||||
|
||||
describe('AccessibilityPanel', () => {
|
||||
it('renders permission and session sections', () => {
|
||||
vi.mocked(useScreenIntelligenceState).mockReturnValue(mockState);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/accessibility']}>
|
||||
<AccessibilityPanel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Accessibility Automation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Permissions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Session')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Screen Recording')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Start Session' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,13 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import {
|
||||
type AutocompleteConfig,
|
||||
type AutocompleteCurrentParams,
|
||||
type AutocompleteStatus,
|
||||
type CommandResponse,
|
||||
type ConfigSnapshot,
|
||||
isTauri,
|
||||
openhumanAutocompleteAccept,
|
||||
openhumanAutocompleteClearHistory,
|
||||
openhumanAutocompleteCurrent,
|
||||
openhumanAutocompleteDebugFocus,
|
||||
openhumanAutocompleteHistory,
|
||||
openhumanAutocompleteSetStyle,
|
||||
openhumanAutocompleteStart,
|
||||
openhumanAutocompleteStatus,
|
||||
@@ -52,7 +46,7 @@ const cloneStatus = (status: AutocompleteStatus): AutocompleteStatus => ({
|
||||
suggestion: status.suggestion ? { ...status.suggestion } : null,
|
||||
});
|
||||
|
||||
describe('AutocompletePanel', () => {
|
||||
describe('AutocompletePanel (simplified)', () => {
|
||||
let runtime: RuntimeHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -87,25 +81,13 @@ describe('AutocompletePanel', () => {
|
||||
|
||||
vi.mocked(openhumanAutocompleteStatus).mockImplementation(async () => ({
|
||||
result: cloneStatus(runtime.status),
|
||||
logs: [
|
||||
`[autocomplete] status running=${runtime.status.running ? 'yes' : 'no'} phase=${runtime.status.phase}`,
|
||||
],
|
||||
logs: [],
|
||||
}));
|
||||
|
||||
vi.mocked(openhumanGetConfig).mockImplementation(async () =>
|
||||
makeConfigSnapshot(runtime.config)
|
||||
);
|
||||
|
||||
vi.mocked(openhumanAutocompleteHistory).mockResolvedValue({
|
||||
result: { entries: [] },
|
||||
logs: ['[autocomplete] history entries=0'],
|
||||
});
|
||||
|
||||
vi.mocked(openhumanAutocompleteClearHistory).mockResolvedValue({
|
||||
result: { cleared: 0 },
|
||||
logs: ['[autocomplete] history cleared=0'],
|
||||
});
|
||||
|
||||
vi.mocked(openhumanAutocompleteSetStyle).mockImplementation(async params => {
|
||||
runtime.config = {
|
||||
...runtime.config,
|
||||
@@ -115,181 +97,125 @@ describe('AutocompletePanel', () => {
|
||||
disabled_apps: params.disabled_apps ?? runtime.config.disabled_apps,
|
||||
};
|
||||
runtime.status.enabled = runtime.config.enabled;
|
||||
runtime.status.debounce_ms = runtime.config.debounce_ms;
|
||||
if (!runtime.config.enabled) {
|
||||
runtime.status.running = false;
|
||||
runtime.status.phase = 'disabled';
|
||||
runtime.status.suggestion = null;
|
||||
}
|
||||
return {
|
||||
result: { config: { ...runtime.config } },
|
||||
logs: [
|
||||
`[autocomplete] set_style enabled=${String(runtime.config.enabled)} debounce=${String(runtime.config.debounce_ms)} max_chars=${String(runtime.config.max_chars)} accept_with_tab=${String(runtime.config.accept_with_tab)}`,
|
||||
],
|
||||
};
|
||||
return { result: { config: { ...runtime.config } }, logs: [] };
|
||||
});
|
||||
|
||||
vi.mocked(openhumanAutocompleteStart).mockImplementation(async params => {
|
||||
vi.mocked(openhumanAutocompleteStart).mockImplementation(async () => {
|
||||
if (!runtime.config.enabled) {
|
||||
return { result: { started: false }, logs: ['[autocomplete] start blocked: disabled'] };
|
||||
return { result: { started: false }, logs: [] };
|
||||
}
|
||||
runtime.status.running = true;
|
||||
runtime.status.phase = 'idle';
|
||||
runtime.status.debounce_ms = params?.debounce_ms ?? runtime.config.debounce_ms;
|
||||
runtime.status.updated_at_ms = Date.now();
|
||||
return {
|
||||
result: { started: true },
|
||||
logs: [`[autocomplete] start running=yes debounce=${String(runtime.status.debounce_ms)}`],
|
||||
};
|
||||
return { result: { started: true }, logs: [] };
|
||||
});
|
||||
|
||||
vi.mocked(openhumanAutocompleteStop).mockImplementation(async () => {
|
||||
runtime.status.running = false;
|
||||
runtime.status.phase = 'idle';
|
||||
runtime.status.suggestion = null;
|
||||
runtime.status.updated_at_ms = Date.now();
|
||||
return { result: { stopped: true }, logs: ['[autocomplete] stop running=no'] };
|
||||
});
|
||||
|
||||
vi.mocked(openhumanAutocompleteCurrent).mockImplementation(
|
||||
async (params?: AutocompleteCurrentParams) => {
|
||||
const context = params?.context?.trim() ?? '';
|
||||
const suggestion = context ? 'completion draft' : '';
|
||||
runtime.status.app_name = 'OpenHuman';
|
||||
runtime.status.phase = suggestion ? 'ready' : 'idle';
|
||||
runtime.status.suggestion = suggestion ? { value: suggestion, confidence: 0.74 } : null;
|
||||
runtime.status.updated_at_ms = Date.now();
|
||||
return {
|
||||
result: {
|
||||
app_name: runtime.status.app_name,
|
||||
context,
|
||||
suggestion: runtime.status.suggestion,
|
||||
},
|
||||
logs: [
|
||||
`[autocomplete] current context_chars=${String(context.length)} suggestion=${suggestion ? 'yes' : 'no'}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
vi.mocked(openhumanAutocompleteAccept).mockImplementation(async params => {
|
||||
const value = params?.suggestion ?? runtime.status.suggestion?.value ?? '';
|
||||
if (!value) {
|
||||
return {
|
||||
result: {
|
||||
accepted: false,
|
||||
applied: false,
|
||||
value: null,
|
||||
reason: 'no suggestion available',
|
||||
},
|
||||
logs: ['[autocomplete] accept no-op'],
|
||||
};
|
||||
}
|
||||
runtime.status.phase = 'idle';
|
||||
runtime.status.suggestion = null;
|
||||
runtime.status.updated_at_ms = Date.now();
|
||||
return {
|
||||
result: { accepted: true, applied: true, value, reason: null },
|
||||
logs: [`[autocomplete] accept applied value_chars=${String(value.length)}`],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mocked(openhumanAutocompleteDebugFocus).mockResolvedValue({
|
||||
result: {
|
||||
app_name: 'OpenHuman',
|
||||
role: 'TextArea',
|
||||
context: 'draft context',
|
||||
selected_text: null,
|
||||
raw_error: null,
|
||||
},
|
||||
logs: ['[autocomplete] debug focus'],
|
||||
return { result: { stopped: true }, logs: [] };
|
||||
});
|
||||
});
|
||||
|
||||
it('runs start → suggest → accept flow and reflects status, settings, and logs', async () => {
|
||||
it('shows user-facing settings and can save style preset changes', async () => {
|
||||
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
|
||||
|
||||
await screen.findByText('Inline Autocomplete');
|
||||
await screen.findByText('Autocomplete');
|
||||
|
||||
// Verify user-facing controls are present
|
||||
expect(screen.getByText('Enabled')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accept With Tab')).toBeInTheDocument();
|
||||
expect(screen.getByText('Style Preset')).toBeInTheDocument();
|
||||
|
||||
// Verify runtime status section shows
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Phase: idle')).toBeInTheDocument();
|
||||
expect(screen.getByText('Running: no')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Platform supported: yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Running: no')).toBeInTheDocument();
|
||||
expect(screen.getByText('Debounce: 120ms')).toBeInTheDocument();
|
||||
// Change style preset and save
|
||||
const presetRow = screen.getByText('Style Preset').closest('label');
|
||||
const presetSelect = presetRow?.querySelector('select') as HTMLSelectElement;
|
||||
fireEvent.change(presetSelect, { target: { value: 'concise' } });
|
||||
|
||||
const debounceRow = screen.getByText('Debounce (ms)').closest('label');
|
||||
const debounceInput = debounceRow?.querySelector('input') as HTMLInputElement;
|
||||
fireEvent.change(debounceInput, { target: { value: '220' } });
|
||||
|
||||
const maxCharsRow = screen.getByText('Max Chars').closest('label');
|
||||
const maxCharsInput = maxCharsRow?.querySelector('input') as HTMLInputElement;
|
||||
fireEvent.change(maxCharsInput, { target: { value: '256' } });
|
||||
|
||||
const acceptWithTabRow = screen.getByText('Accept With Tab').closest('label');
|
||||
const acceptWithTabInput = acceptWithTabRow?.querySelector('input') as HTMLInputElement;
|
||||
fireEvent.click(acceptWithTabInput);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Autocomplete Settings' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ debounce_ms: 220, max_chars: 256, accept_with_tab: false })
|
||||
expect.objectContaining({ style_preset: 'concise', accept_with_tab: true })
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
|
||||
expect(await screen.findByText('Autocomplete settings saved.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can start and stop the autocomplete runtime', async () => {
|
||||
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
|
||||
|
||||
await screen.findByText('Autocomplete');
|
||||
|
||||
// Wait for status to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Running: no')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Start
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
|
||||
await waitFor(() => {
|
||||
expect(openhumanAutocompleteStart).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Running: yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Debounce: 220ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('Autocomplete started.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const contextLabel = screen.getByText('Context Override (optional)');
|
||||
const contextInput = contextLabel.parentElement?.querySelector(
|
||||
'textarea'
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(contextInput, { target: { value: 'Please review this change' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Get Suggestion' }));
|
||||
|
||||
// Stop
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
|
||||
await waitFor(() => {
|
||||
expect(openhumanAutocompleteCurrent).toHaveBeenCalledWith({
|
||||
context: 'Please review this change',
|
||||
});
|
||||
expect(openhumanAutocompleteStop).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current suggestion: completion draft')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Accept Suggestion' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openhumanAutocompleteAccept).toHaveBeenCalledWith({
|
||||
suggestion: 'completion draft',
|
||||
skip_apply: true,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current suggestion: none')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accepted: completion draft')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const logsSection = screen.getByText('Live Logs').closest('section');
|
||||
expect(logsSection).not.toBeNull();
|
||||
const logsScope = within(logsSection as HTMLElement);
|
||||
const logsOutput = (logsSection as HTMLElement).querySelector('pre') as HTMLElement;
|
||||
|
||||
expect(logsOutput.textContent).toContain('[autocomplete] start');
|
||||
expect(logsOutput.textContent).toContain('[autocomplete] current');
|
||||
expect(logsOutput.textContent).toContain('phase idle -> ready');
|
||||
expect(logsOutput.textContent).toContain('phase ready -> idle');
|
||||
|
||||
fireEvent.click(logsScope.getByRole('button', { name: 'Clear' }));
|
||||
await waitFor(() => {
|
||||
expect(logsOutput.textContent).toContain('No logs yet.');
|
||||
expect(screen.getByText('Autocomplete stopped.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves advanced settings when saving from the simplified panel', async () => {
|
||||
runtime.config.debounce_ms = 500;
|
||||
runtime.config.max_chars = 800;
|
||||
runtime.config.overlay_ttl_ms = 2000;
|
||||
|
||||
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
|
||||
|
||||
await screen.findByText('Autocomplete');
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Running: no')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle enabled off and save
|
||||
const enabledLabel = screen.getByText('Enabled').closest('label');
|
||||
const enabledCheckbox = enabledLabel?.querySelector(
|
||||
'input[type="checkbox"]'
|
||||
) as HTMLInputElement;
|
||||
fireEvent.click(enabledCheckbox);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
debounce_ms: 500,
|
||||
max_chars: 800,
|
||||
overlay_ttl_ms: 2000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the Advanced settings link', async () => {
|
||||
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
|
||||
|
||||
await screen.findByText('Autocomplete');
|
||||
expect(screen.getByText('Advanced settings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,13 +117,18 @@ describe('ScreenIntelligencePanel', () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('saves screen intelligence settings and refreshes core-backed status', async () => {
|
||||
it('saves screen awareness settings and refreshes core-backed status', async () => {
|
||||
const deferred = createDeferred<{ result: ConfigSnapshot; logs: [] }>();
|
||||
vi.mocked(openhumanUpdateScreenIntelligenceSettings).mockReturnValueOnce(deferred.promise);
|
||||
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Screen Intelligence Policy');
|
||||
// Both the header h2 and section h3 say "Screen Awareness" — wait for either.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('heading', { name: 'Screen Awareness' }).length).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
const enabledLabel = screen.getByText('Enabled').closest('label');
|
||||
const enabledCheckbox = enabledLabel?.querySelector(
|
||||
@@ -132,18 +137,12 @@ describe('ScreenIntelligencePanel', () => {
|
||||
expect(enabledCheckbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(enabledCheckbox);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Screen Intelligence Settings' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeInTheDocument();
|
||||
expect(openhumanUpdateScreenIntelligenceSettings).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
policy_mode: 'all_except_blacklist',
|
||||
baseline_fps: 1,
|
||||
use_vision_model: true,
|
||||
keep_screenshots: false,
|
||||
allowlist: ['Code'],
|
||||
denylist: ['1Password'],
|
||||
});
|
||||
expect(openhumanUpdateScreenIntelligenceSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true, policy_mode: 'all_except_blacklist' })
|
||||
);
|
||||
|
||||
deferred.resolve({
|
||||
result: { config: {}, workspace_dir: '/tmp/workspace', config_path: '/tmp/config.toml' },
|
||||
@@ -151,9 +150,7 @@ describe('ScreenIntelligencePanel', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Save Screen Intelligence Settings' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save Settings' })).toBeInTheDocument();
|
||||
});
|
||||
expect(baseState.refreshStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -178,7 +175,7 @@ describe('ScreenIntelligencePanel', () => {
|
||||
screen.getByRole('button', { name: 'Restart & Refresh Permissions' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Screen Intelligence V1 is currently supported on macOS only.')
|
||||
screen.getByText('Screen Awareness is currently supported on macOS only.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
+99
-104
@@ -1,25 +1,28 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import AccessibilityPanel from '../components/settings/panels/AccessibilityPanel';
|
||||
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
|
||||
import AIPanel from '../components/settings/panels/AIPanel';
|
||||
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
|
||||
import AutocompletePanel from '../components/settings/panels/AutocompletePanel';
|
||||
import BillingPanel from '../components/settings/panels/BillingPanel';
|
||||
import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel';
|
||||
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
|
||||
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
|
||||
import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel';
|
||||
import LocalModelPanel from '../components/settings/panels/LocalModelPanel';
|
||||
import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
|
||||
import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel';
|
||||
import MessagingPanel from '../components/settings/panels/MessagingPanel';
|
||||
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
|
||||
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
|
||||
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
|
||||
import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel';
|
||||
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
|
||||
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
|
||||
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
|
||||
import TeamPanel from '../components/settings/panels/TeamPanel';
|
||||
import ToolsPanel from '../components/settings/panels/ToolsPanel';
|
||||
import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel';
|
||||
import VoicePanel from '../components/settings/panels/VoicePanel';
|
||||
import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel';
|
||||
import SettingsHome from '../components/settings/SettingsHome';
|
||||
@@ -75,29 +78,45 @@ const accountSettingsItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const automationSettingsItems = [
|
||||
{
|
||||
id: 'accessibility',
|
||||
title: 'Accessibility Automation',
|
||||
description: 'Desktop permissions, assisted controls, and safety-bound sessions',
|
||||
route: 'accessibility',
|
||||
id: 'billing',
|
||||
title: 'Billing & Usage',
|
||||
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
|
||||
route: 'billing',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-7 9h8a2 2 0 002-2V7a2 2 0 00-2-2h-1l-.707-.707A1 1 0 0013.586 4h-3.172a1 1 0 00-.707.293L9 5H8a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
title: 'Privacy',
|
||||
description: 'Manage data sharing and anonymized usage preferences',
|
||||
route: 'privacy',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const featuresSettingsItems = [
|
||||
{
|
||||
id: 'screen-intelligence',
|
||||
title: 'Screen Intelligence',
|
||||
description: 'Window capture policy, vision summaries, and memory ingestion',
|
||||
title: 'Screen Awareness',
|
||||
description: 'Screen capture permissions, monitoring policy, and session controls',
|
||||
route: 'screen-intelligence',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -112,8 +131,8 @@ const automationSettingsItems = [
|
||||
},
|
||||
{
|
||||
id: 'autocomplete',
|
||||
title: 'Inline Autocomplete',
|
||||
description: 'Manage predictive text style, app filters, and live completion controls',
|
||||
title: 'Autocomplete',
|
||||
description: 'Inline text completion style and app preferences',
|
||||
route: 'autocomplete',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -126,6 +145,22 @@ const automationSettingsItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'voice',
|
||||
title: 'Voice Dictation',
|
||||
description: 'Hotkey, activation mode, writing style, and custom dictionary',
|
||||
route: 'voice',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3a3 3 0 00-3 3v6a3 3 0 006 0V6a3 3 0 00-3-3zm-7 9a7 7 0 0014 0m-7 7v2m-4 0h8"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'messaging',
|
||||
title: 'Messaging Channels',
|
||||
@@ -142,73 +177,6 @@ const automationSettingsItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cron-jobs',
|
||||
title: 'Cron Jobs',
|
||||
description: 'View and configure scheduled jobs for runtime skills',
|
||||
route: 'cron-jobs',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const aiSettingsItems = [
|
||||
{
|
||||
id: 'voice',
|
||||
title: 'Voice Dictation',
|
||||
description: 'Manage dictation startup, hotkeys, writing style, and runtime controls',
|
||||
route: 'voice',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3a3 3 0 00-3 3v6a3 3 0 006 0V6a3 3 0 00-3-3zm-7 9a7 7 0 0014 0m-7 7v2m-4 0h8"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'local-model',
|
||||
title: 'Local AI Model',
|
||||
description: 'Choose model tier by device capability and manage downloads',
|
||||
route: 'local-model',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI Configuration',
|
||||
description: 'Configure persona, prompting behavior, and AI runtime settings',
|
||||
route: 'ai',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3l1.9 3.85 4.25.62-3.08 3 .73 4.23L12 12.77 8.2 14.7l.73-4.23-3.08-3 4.25-.62L12 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
title: 'Tools',
|
||||
@@ -233,6 +201,25 @@ const aiSettingsItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const aiModelsSettingsItems = [
|
||||
{
|
||||
id: 'local-model',
|
||||
title: 'Local AI Model',
|
||||
description: 'Choose model tier by device capability and manage downloads',
|
||||
route: 'local-model',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const Settings = () => {
|
||||
return (
|
||||
<div className="p-4 pt-6">
|
||||
@@ -243,56 +230,64 @@ const Settings = () => {
|
||||
path="account"
|
||||
element={
|
||||
<SettingsSectionPage
|
||||
title="Account & Security"
|
||||
description="Recovery, team access, and linked account settings."
|
||||
title="Account & Billing"
|
||||
description="Recovery phrase, team, connections, billing, and privacy settings."
|
||||
items={accountSettingsItems}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="automation"
|
||||
path="features"
|
||||
element={
|
||||
<SettingsSectionPage
|
||||
title="Automation & Channels"
|
||||
description="Desktop automation, capture, messaging, and scheduled jobs."
|
||||
items={automationSettingsItems}
|
||||
title="Features"
|
||||
description="Screen awareness, autocomplete, voice, messaging, and tools."
|
||||
items={featuresSettingsItems}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-tools"
|
||||
path="ai-models"
|
||||
element={
|
||||
<SettingsSectionPage
|
||||
title="AI & Skills"
|
||||
description="Model management, AI behavior, and skill configuration."
|
||||
items={aiSettingsItems}
|
||||
title="AI & Models"
|
||||
description="Local AI model setup and management."
|
||||
items={aiModelsSettingsItems}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="connections" element={<ConnectionsPanel />} />
|
||||
<Route path="messaging" element={<MessagingPanel />} />
|
||||
<Route path="cron-jobs" element={<CronJobsPanel />} />
|
||||
<Route path="screen-intelligence" element={<ScreenIntelligencePanel />} />
|
||||
<Route path="autocomplete" element={<AutocompletePanel />} />
|
||||
<Route path="privacy" element={<PrivacyPanel />} />
|
||||
<Route path="agent-chat" element={<AgentChatPanel />} />
|
||||
<Route path="ai" element={<AIPanel />} />
|
||||
<Route path="accessibility" element={<AccessibilityPanel />} />
|
||||
<Route path="local-model" element={<LocalModelPanel />} />
|
||||
<Route path="voice" element={<VoicePanel />} />
|
||||
<Route path="billing" element={<BillingPanel />} />
|
||||
<Route path="tools" element={<ToolsPanel />} />
|
||||
{/* Account & Billing leaf panels */}
|
||||
<Route path="recovery-phrase" element={<RecoveryPhrasePanel />} />
|
||||
<Route path="team" element={<TeamPanel />} />
|
||||
<Route path="team/manage/:teamId" element={<TeamManagementPanel />} />
|
||||
<Route path="team/manage/:teamId/members" element={<TeamMembersPanel />} />
|
||||
<Route path="team/manage/:teamId/invites" element={<TeamInvitesPanel />} />
|
||||
<Route path="team/members" element={<TeamMembersPanel />} />
|
||||
<Route path="team/invites" element={<TeamInvitesPanel />} />
|
||||
<Route path="connections" element={<ConnectionsPanel />} />
|
||||
<Route path="billing" element={<BillingPanel />} />
|
||||
<Route path="privacy" element={<PrivacyPanel />} />
|
||||
{/* Features leaf panels */}
|
||||
<Route path="screen-intelligence" element={<ScreenIntelligencePanel />} />
|
||||
<Route path="autocomplete" element={<AutocompletePanel />} />
|
||||
<Route path="voice" element={<VoicePanel />} />
|
||||
<Route path="messaging" element={<MessagingPanel />} />
|
||||
<Route path="tools" element={<ToolsPanel />} />
|
||||
{/* AI & Models leaf panels */}
|
||||
<Route path="local-model" element={<LocalModelPanel />} />
|
||||
{/* Developer Options */}
|
||||
<Route path="developer-options" element={<DeveloperOptionsPanel />} />
|
||||
<Route path="ai" element={<AIPanel />} />
|
||||
<Route path="agent-chat" element={<AgentChatPanel />} />
|
||||
<Route path="cron-jobs" element={<CronJobsPanel />} />
|
||||
<Route path="screen-awareness-debug" element={<ScreenAwarenessDebugPanel />} />
|
||||
<Route path="autocomplete-debug" element={<AutocompleteDebugPanel />} />
|
||||
<Route path="voice-debug" element={<VoiceDebugPanel />} />
|
||||
<Route path="local-model-debug" element={<LocalModelDebugPanel />} />
|
||||
<Route path="webhooks-debug" element={<WebhooksDebugPanel />} />
|
||||
<Route path="memory-data" element={<MemoryDataPanel />} />
|
||||
<Route path="memory-debug" element={<MemoryDebugPanel />} />
|
||||
<Route path="recovery-phrase" element={<RecoveryPhrasePanel />} />
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/settings" replace />} />
|
||||
</Routes>
|
||||
<div className="border-t border-stone-100 px-4 py-3 text-center text-[11px] text-stone-400">
|
||||
|
||||
Reference in New Issue
Block a user