mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
* 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)
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import {
|
|
type AutocompleteConfig,
|
|
type AutocompleteStatus,
|
|
isTauri,
|
|
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 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 AutocompletePanel = () => {
|
|
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
|
|
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
|
|
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 [stylePreset, setStylePreset] = useState<string>(DEFAULT_CONFIG.style_preset);
|
|
const [disabledAppsText, setDisabledAppsText] = useState<string>(
|
|
DEFAULT_CONFIG.disabled_apps.join('\n')
|
|
);
|
|
const [acceptWithTab, setAcceptWithTab] = useState<boolean>(DEFAULT_CONFIG.accept_with_tab);
|
|
|
|
// 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;
|
|
setError(null);
|
|
try {
|
|
const [statusResponse, configResponse] = await Promise.all([
|
|
openhumanAutocompleteStatus(),
|
|
openhumanGetConfig(),
|
|
]);
|
|
setStatus(statusResponse.result);
|
|
const config = parseAutocompleteConfig(
|
|
(configResponse.result.config as Record<string, unknown> | undefined)?.autocomplete
|
|
);
|
|
fullConfigRef.current = config;
|
|
setConfigLoaded(true);
|
|
setEnabled(config.enabled);
|
|
setStylePreset(config.style_preset);
|
|
setDisabledAppsText(config.disabled_apps.join('\n'));
|
|
setAcceptWithTab(config.accept_with_tab);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load autocomplete settings');
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, []);
|
|
|
|
const refreshStatus = async () => {
|
|
if (!isTauri()) return;
|
|
try {
|
|
const response = await openhumanAutocompleteStatus();
|
|
setStatus(response.result);
|
|
} 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 {
|
|
const prev = fullConfigRef.current;
|
|
const response = await openhumanAutocompleteSetStyle({
|
|
enabled,
|
|
debounce_ms: prev.debounce_ms,
|
|
max_chars: prev.max_chars,
|
|
style_preset: stylePreset.trim() || 'balanced',
|
|
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: prev.overlay_ttl_ms,
|
|
});
|
|
|
|
fullConfigRef.current = response.result.config;
|
|
setEnabled(response.result.config.enabled);
|
|
setStylePreset(response.result.config.style_preset);
|
|
setDisabledAppsText(response.result.config.disabled_apps.join('\n'));
|
|
setAcceptWithTab(response.result.config.accept_with_tab);
|
|
setMessage('Autocomplete settings saved.');
|
|
await refreshStatus();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save autocomplete settings');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const start = async () => {
|
|
if (!isTauri()) return;
|
|
setError(null);
|
|
setMessage(null);
|
|
try {
|
|
const response = await openhumanAutocompleteStart({
|
|
debounce_ms: fullConfigRef.current.debounce_ms,
|
|
});
|
|
await refreshStatus();
|
|
if (response.result.started) {
|
|
setMessage('Autocomplete started.');
|
|
} else {
|
|
setMessage('Autocomplete did not start. Check if it is enabled.');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to start autocomplete');
|
|
}
|
|
};
|
|
|
|
const stop = async () => {
|
|
if (!isTauri()) return;
|
|
setError(null);
|
|
setMessage(null);
|
|
try {
|
|
await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
|
|
await refreshStatus();
|
|
setMessage('Autocomplete stopped.');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to stop autocomplete');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="z-10 relative">
|
|
<SettingsHeader
|
|
title="Autocomplete"
|
|
showBackButton={true}
|
|
onBack={navigateBack}
|
|
breadcrumbs={breadcrumbs}
|
|
/>
|
|
|
|
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
|
|
<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>
|
|
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export default AutocompletePanel;
|