feat(local-ai): add guided model tier selection by device capability

Add tiered model presets (Low/Medium/High) with device-aware recommendations
so users can pick a local AI model that fits their machine without editing
raw JSON config. Detect RAM, CPU, GPU via sysinfo crate and recommend a tier.
Persist selection to config.toml, with env var override and graceful
degradation hints on bootstrap failure.

- Rust: presets.rs (tier definitions, recommendation logic), device.rs
  (hardware detection), 3 new RPC methods, env var override, bootstrap hints
- Frontend: tier selector UI in Settings > Local AI Model with device info,
  loading/error states, and "Advanced" toggle for existing controls
- Tests: 7 Rust unit tests + comprehensive JSON-RPC E2E test
- Also fixes pre-existing lint warning in SkillSetupWizard.tsx

Closes #80
This commit is contained in:
cyrus@tinyhumans.ai
2026-03-31 18:43:50 +05:30
parent 064ec59ce0
commit 2570195604
18 changed files with 1337 additions and 353 deletions
+9
View File
@@ -25,6 +25,15 @@ Quick reference for anyone starting with Claude on this project. Updated by the
- **Ask user when in doubt** — never assume scope or approach
- **PRs target upstream** — `tinyhumansai/openhuman` main branch, not fork
## Local AI Presets & Daemon Gotcha
- **Tier system lives in `src/openhuman/local_ai/presets.rs`** — single source of truth for tier→model ID mapping. To change default models for a release, edit `all_presets()` there.
- **Device detection** uses `sysinfo` crate (`src/openhuman/local_ai/device.rs`). Apple Silicon = GPU always; others = best-effort.
- **`OPENHUMAN_LOCAL_AI_TIER` env var** overrides the selected tier at config load time (in `load.rs`).
- **Frontend tier selector** is in `LocalModelPanel.tsx` under Settings > Local AI Model. Uses `coreRpcClient` to call 3 RPC methods: `local_ai_device_profile`, `local_ai_presets`, `local_ai_apply_preset`.
- **Default config maps to Medium tier** (`gemma3:4b-it-qat`). If someone changes `model_ids.rs` defaults, they should keep `presets.rs` in sync.
- **Daemon binary gotcha** — A daemon process (`openhuman-aarch64-apple-darwin run`) auto-starts on port 7788 and respawns on kill. `yarn tauri dev` reuses it if already running. When adding new RPC methods, you must replace this binary: `cp -f target/debug/openhuman-core app/src-tauri/binaries/openhuman-aarch64-apple-darwin`, then kill the old PID so it respawns with the new binary.
## Environment
- **Core sidecar port** — `7788` (default). Check with `lsof -i :7788`.
+7
View File
@@ -88,6 +88,13 @@ OPENHUMAN_PROXY_SCOPE=
# [optional] Comma-separated services to proxy
OPENHUMAN_PROXY_SERVICES=
# ---------------------------------------------------------------------------
# Local AI model tier
# ---------------------------------------------------------------------------
# [optional] Override selected model tier: low, medium, high
# Applies the corresponding preset at config load time (overrides config.toml).
OPENHUMAN_LOCAL_AI_TIER=
# ---------------------------------------------------------------------------
# Local AI binary overrides
# ---------------------------------------------------------------------------
Generated
+80 -4
View File
@@ -2458,7 +2458,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
"windows-core 0.62.2",
]
[[package]]
@@ -3746,6 +3746,15 @@ dependencies = [
"nom 8.0.0",
]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@@ -3968,6 +3977,7 @@ dependencies = [
"sha2",
"shellexpand",
"socketioxide",
"sysinfo",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -6053,6 +6063,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "sysinfo"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"windows",
]
[[package]]
name = "tagptr"
version = "0.2.0"
@@ -7459,19 +7482,52 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link",
"windows-result",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -7483,6 +7539,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -7500,6 +7567,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.4.1"
+1
View File
@@ -67,6 +67,7 @@ rustls = { version = "0.23", features = ["ring"] }
rustls-pki-types = "1.14.0"
tokio-rustls = "0.26.4"
webpki-roots = "1.0.6"
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
clap = { version = "4.5", features = ["derive"] }
clap_complete = "4.5"
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "OpenHuman"
version = "0.49.31"
version = "0.49.32"
dependencies = [
"env_logger",
"log",
@@ -271,6 +271,23 @@ const SettingsHome = () => {
// onClick: handleViewEncryptionKey,
// dangerous: false,
// },
{
id: 'local-model',
title: 'Local AI Model',
description: 'Choose model tier by device capability and manage downloads',
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>
),
onClick: () => navigateToSettings('local-model'),
dangerous: false,
},
{
id: 'team',
title: 'Team',
@@ -35,22 +35,6 @@ const developerItems = [
</svg>
),
},
{
id: 'local-model',
title: 'Local Model Runtime',
description: 'Monitor download/load status and test local model calls',
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 17v-6m3 6V7m3 10v-4m5 6H4a2 2 0 01-2-2V7a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2z"
/>
</svg>
),
},
{
id: 'tauri-commands',
title: 'Tauri Command Console',
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import {
type ApplyPresetResult,
type LocalAiAssetsStatus,
type LocalAiDownloadsProgress,
type LocalAiEmbeddingResult,
@@ -8,12 +9,14 @@ import {
type LocalAiStatus,
type LocalAiSuggestion,
type LocalAiTtsResult,
openhumanLocalAiApplyPreset,
openhumanLocalAiAssetsStatus,
openhumanLocalAiDownload,
openhumanLocalAiDownloadAllAssets,
openhumanLocalAiDownloadAsset,
openhumanLocalAiDownloadsProgress,
openhumanLocalAiEmbed,
openhumanLocalAiPresets,
openhumanLocalAiPrompt,
openhumanLocalAiStatus,
openhumanLocalAiSuggestQuestions,
@@ -21,6 +24,7 @@ import {
openhumanLocalAiTranscribe,
openhumanLocalAiTts,
openhumanLocalAiVisionPrompt,
type PresetsResponse,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -148,6 +152,13 @@ const LocalModelPanel = () => {
const [ttsOutput, setTtsOutput] = useState<LocalAiTtsResult | null>(null);
const [isTtsLoading, setIsTtsLoading] = useState(false);
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);
if (downloadProgress != null) return downloadProgress;
@@ -189,8 +200,41 @@ const LocalModelPanel = () => {
}
};
const loadPresets = async () => {
setPresetsLoading(true);
try {
const data = await openhumanLocalAiPresets();
setPresetsData(data);
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);
}
};
const applyPreset = async (tier: string) => {
setIsApplyingPreset(true);
setPresetError('');
setPresetSuccess(null);
try {
const result = await openhumanLocalAiApplyPreset(tier);
setPresetSuccess(result);
await loadPresets();
await loadStatus();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to apply preset';
setPresetError(msg);
} finally {
setIsApplyingPreset(false);
}
};
useEffect(() => {
void loadStatus();
void loadPresets();
const timer = setInterval(() => {
void loadStatus();
}, 1500);
@@ -360,356 +404,497 @@ const LocalModelPanel = () => {
}
};
const formatRamGb = (bytes: number): string => {
const gb = bytes / (1024 * 1024 * 1024);
return gb >= 10 ? `${Math.round(gb)} GB` : `${gb.toFixed(1)} GB`;
};
return (
<div className="h-full flex flex-col">
<SettingsHeader title="Local Model" showBackButton={true} onBack={navigateBack} />
<div className="flex-1 overflow-y-auto px-6 pb-10 space-y-6">
{/* --- Model Tier Selection --- */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Runtime Status</h3>
<button
onClick={() => void loadStatus()}
className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Refresh
</button>
</div>
<h3 className="text-lg font-semibold text-white">Model Tier</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">State</span>
<span className={`font-medium ${statusTone(status?.state ?? 'idle')}`}>
{status ? statusLabel(downloads?.state ?? status.state) : 'Unavailable'}
</span>
{/* Loading / error states */}
{presetsLoading && !presetsData && (
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 text-sm text-stone-400 animate-pulse">
Loading device info and presets
</div>
<div className="h-2 rounded-full bg-stone-800 overflow-hidden">
<div
className={`h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500 ${
isIndeterminateDownload ? 'animate-pulse' : ''
}`}
style={{ width: `${Math.round((isIndeterminateDownload ? 1 : progress) * 100)}%` }}
/>
)}
{!presetsLoading && !presetsData && presetError && (
<div className="bg-gray-900 rounded-lg border border-red-700/40 p-4 text-sm text-red-300">
Could not load presets: {presetError}
</div>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-400">
<span>
Progress:{' '}
{isIndeterminateDownload
? 'Downloading (size unknown)'
: `${Math.round(progress * 100)}%`}
</span>
{downloadedText && <span className="text-stone-300">{downloadedText}</span>}
{speedText && <span className="text-blue-300">{speedText}</span>}
{etaText && <span className="text-cyan-300">ETA {etaText}</span>}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Provider</div>
<div className="text-stone-100 mt-1">{status?.provider ?? 'n/a'}</div>
</div>
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Model</div>
<div className="text-stone-100 mt-1">{status?.model_id ?? 'n/a'}</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Backend</div>
<div className="text-stone-100 mt-1">{status?.active_backend ?? 'cpu'}</div>
</div>
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Last Latency</div>
<div className="text-stone-100 mt-1">
{typeof status?.last_latency_ms === 'number'
? `${status.last_latency_ms} ms`
: 'n/a'}
</div>
</div>
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Generation TPS</div>
<div className="text-stone-100 mt-1">
{typeof status?.gen_toks_per_sec === 'number'
? `${status.gen_toks_per_sec.toFixed(1)} tok/s`
: 'n/a'}
</div>
</div>
</div>
{status?.model_path && (
<div className="text-xs text-stone-400 break-all">Artifact: {status.model_path}</div>
)}
{status?.backend_reason && (
<div className="text-xs text-blue-300">{status.backend_reason}</div>
)}
{status?.warning && <div className="text-xs text-amber-300">{status.warning}</div>}
{statusError && <div className="text-xs text-red-300">{statusError}</div>}
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => void triggerDownload(false)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white">
{isTriggeringDownload ? 'Triggering...' : 'Bootstrap / Resume'}
</button>
<button
onClick={() => void triggerDownload(true)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md border border-gray-600 hover:border-gray-500 disabled:opacity-60 text-stone-200">
Force Re-bootstrap
</button>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Capability Assets</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<div className="text-xs text-stone-400">
Quantization preference: {assets?.quantization ?? 'q4'}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
{[
{ label: 'Chat', key: 'chat' as const, item: assets?.chat },
{ label: 'Vision', key: 'vision' as const, item: assets?.vision },
{ label: 'Embedding', key: 'embedding' as const, item: assets?.embedding },
{ label: 'STT', key: 'stt' as const, item: assets?.stt },
{ label: 'TTS', key: 'tts' as const, item: assets?.tts },
].map(({ label, key, item }) => (
<div key={String(label)} className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">{label}</div>
<div className="text-stone-100 mt-1 break-all">{item?.id ?? 'n/a'}</div>
<div className={`text-xs mt-1 ${statusTone(item?.state ?? 'idle')}`}>
{statusLabel(item?.state ?? 'idle')}
{/* Device info */}
{presetsData?.device && (
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3">
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<div className="text-stone-400 uppercase tracking-wide">RAM</div>
<div className="text-stone-100 mt-0.5 font-medium">
{formatRamGb(presetsData.device.total_ram_bytes)}
</div>
{item?.path && (
<div className="text-[10px] text-stone-500 mt-1 break-all">{item.path}</div>
)}
<button
onClick={() => void triggerAssetDownload(key)}
disabled={assetDownloadBusy[key]}
className="mt-2 px-2 py-1 text-[10px] rounded border border-gray-600 hover:border-gray-500 disabled:opacity-60 text-stone-200">
{assetDownloadBusy[key] ? 'Downloading...' : 'Download'}
</button>
</div>
))}
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Summarization</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={summaryInput}
onChange={e => setSummaryInput(e.target.value)}
placeholder="Paste text to summarize with the local model..."
className="w-full min-h-28 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex items-center justify-between">
<div className="text-xs text-stone-400">
Calls `openhuman.local_ai_summarize` via Rust core
</div>
<button
onClick={() => void runSummaryTest()}
disabled={isSummaryLoading || !summaryInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
{isSummaryLoading ? 'Running...' : 'Run Summary Test'}
</button>
</div>
{summaryOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200">
{summaryOutput}
</pre>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Suggested Prompts</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={suggestInput}
onChange={e => setSuggestInput(e.target.value)}
placeholder="Paste conversation context to generate suggestions..."
className="w-full min-h-28 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex items-center justify-between">
<div className="text-xs text-stone-400">
Calls `openhuman.local_ai_suggest_questions` via Rust core
</div>
<button
onClick={() => void runSuggestTest()}
disabled={isSuggestLoading || !suggestInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 disabled:opacity-60 text-white">
{isSuggestLoading ? 'Running...' : 'Run Suggestion Test'}
</button>
</div>
{suggestions.length > 0 && (
<div className="space-y-2">
{suggestions.map(suggestion => (
<div>
<div className="text-stone-400 uppercase tracking-wide">CPU</div>
<div
key={`${suggestion.text}-${suggestion.confidence}`}
className="rounded-md border border-gray-700 bg-stone-950 p-3">
<div className="text-sm text-stone-100">{suggestion.text}</div>
<div className="text-xs text-stone-500 mt-1">
Confidence: {(suggestion.confidence * 100).toFixed(0)}%
className="text-stone-100 mt-0.5 font-medium truncate"
title={presetsData.device.cpu_brand}>
{presetsData.device.cpu_count} cores
</div>
</div>
<div>
<div className="text-stone-400 uppercase tracking-wide">GPU</div>
<div
className="text-stone-100 mt-0.5 font-medium truncate"
title={presetsData.device.gpu_description ?? undefined}>
{presetsData.device.has_gpu
? (presetsData.device.gpu_description ?? 'Detected')
: 'Not detected'}
</div>
</div>
</div>
</div>
)}
{/* Tier cards */}
{presetsData && (
<div className="space-y-2">
{presetsData.presets.map(preset => {
const isRecommended = preset.tier === presetsData.recommended_tier;
const isCurrent = preset.tier === presetsData.current_tier;
return (
<button
key={preset.tier}
type="button"
onClick={() => void applyPreset(preset.tier)}
disabled={isApplyingPreset || isCurrent}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isCurrent
? 'border-blue-500 bg-blue-500/10'
: 'border-gray-700 bg-gray-900 hover:border-gray-500'
} ${isApplyingPreset ? 'opacity-60' : ''}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">{preset.label}</span>
{isRecommended && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-emerald-600/30 text-emerald-300 uppercase tracking-wide">
Recommended
</span>
)}
{isCurrent && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-blue-600/30 text-blue-300 uppercase tracking-wide">
Active
</span>
)}
</div>
<span className="text-xs text-stone-400">
~{preset.approx_download_gb} GB
</span>
</div>
<div className="text-xs text-stone-400 mt-1">{preset.description}</div>
<div className="text-[10px] text-stone-500 mt-1">
Chat: {preset.chat_model_id} &middot; Min RAM: {preset.min_ram_gb} GB
</div>
</button>
);
})}
{presetsData.current_tier === 'custom' && (
<div className="rounded-lg border border-amber-600/30 bg-amber-600/5 p-3 text-xs text-amber-300">
You are using custom model IDs that do not match any built-in preset.
</div>
)}
</div>
)}
{presetError && <div className="text-xs text-red-300">{presetError}</div>}
{presetSuccess && (
<div className="text-xs text-green-300">
Applied {presetSuccess.applied_tier} tier: {presetSuccess.chat_model_id}
</div>
)}
</section>
{/* Advanced toggle */}
<button
type="button"
onClick={() => setShowAdvanced(prev => !prev)}
className="flex items-center gap-2 text-sm text-stone-400 hover:text-stone-200 transition-colors">
<svg
className={`w-4 h-4 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
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 && (
<>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Runtime Status</h3>
<button
onClick={() => void loadStatus()}
className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Refresh
</button>
</div>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">State</span>
<span className={`font-medium ${statusTone(status?.state ?? 'idle')}`}>
{status ? statusLabel(downloads?.state ?? status.state) : 'Unavailable'}
</span>
</div>
<div className="h-2 rounded-full bg-stone-800 overflow-hidden">
<div
className={`h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500 ${
isIndeterminateDownload ? 'animate-pulse' : ''
}`}
style={{
width: `${Math.round((isIndeterminateDownload ? 1 : progress) * 100)}%`,
}}
/>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-400">
<span>
Progress:{' '}
{isIndeterminateDownload
? 'Downloading (size unknown)'
: `${Math.round(progress * 100)}%`}
</span>
{downloadedText && <span className="text-stone-300">{downloadedText}</span>}
{speedText && <span className="text-blue-300">{speedText}</span>}
{etaText && <span className="text-cyan-300">ETA {etaText}</span>}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Provider</div>
<div className="text-stone-100 mt-1">{status?.provider ?? 'n/a'}</div>
</div>
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Model</div>
<div className="text-stone-100 mt-1">{status?.model_id ?? 'n/a'}</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">Backend</div>
<div className="text-stone-100 mt-1">{status?.active_backend ?? 'cpu'}</div>
</div>
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">
Last Latency
</div>
<div className="text-stone-100 mt-1">
{typeof status?.last_latency_ms === 'number'
? `${status.last_latency_ms} ms`
: 'n/a'}
</div>
</div>
))}
</div>
)}
</div>
</section>
<div className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">
Generation TPS
</div>
<div className="text-stone-100 mt-1">
{typeof status?.gen_toks_per_sec === 'number'
? `${status.gen_toks_per_sec.toFixed(1)} tok/s`
: 'n/a'}
</div>
</div>
</div>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Custom Prompt</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={promptInput}
onChange={e => setPromptInput(e.target.value)}
placeholder="Type any prompt and run it against the local model..."
className="w-full min-h-28 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-xs text-stone-300">
<input
type="checkbox"
checked={promptNoThink}
onChange={e => setPromptNoThink(e.target.checked)}
className="h-3.5 w-3.5 rounded border-gray-600 bg-stone-900 text-blue-500 focus:ring-blue-500"
{status?.model_path && (
<div className="text-xs text-stone-400 break-all">
Artifact: {status.model_path}
</div>
)}
{status?.backend_reason && (
<div className="text-xs text-blue-300">{status.backend_reason}</div>
)}
{status?.warning && <div className="text-xs text-amber-300">{status.warning}</div>}
{statusError && <div className="text-xs text-red-300">{statusError}</div>}
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => void triggerDownload(false)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white">
{isTriggeringDownload ? 'Triggering...' : 'Bootstrap / Resume'}
</button>
<button
onClick={() => void triggerDownload(true)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md border border-gray-600 hover:border-gray-500 disabled:opacity-60 text-stone-200">
Force Re-bootstrap
</button>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Capability Assets</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<div className="text-xs text-stone-400">
Quantization preference: {assets?.quantization ?? 'q4'}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
{[
{ label: 'Chat', key: 'chat' as const, item: assets?.chat },
{ label: 'Vision', key: 'vision' as const, item: assets?.vision },
{ label: 'Embedding', key: 'embedding' as const, item: assets?.embedding },
{ label: 'STT', key: 'stt' as const, item: assets?.stt },
{ label: 'TTS', key: 'tts' as const, item: assets?.tts },
].map(({ label, key, item }) => (
<div key={String(label)} className="rounded-md border border-gray-700 p-2">
<div className="text-stone-400 text-xs uppercase tracking-wide">{label}</div>
<div className="text-stone-100 mt-1 break-all">{item?.id ?? 'n/a'}</div>
<div className={`text-xs mt-1 ${statusTone(item?.state ?? 'idle')}`}>
{statusLabel(item?.state ?? 'idle')}
</div>
{item?.path && (
<div className="text-[10px] text-stone-500 mt-1 break-all">{item.path}</div>
)}
<button
onClick={() => void triggerAssetDownload(key)}
disabled={assetDownloadBusy[key]}
className="mt-2 px-2 py-1 text-[10px] rounded border border-gray-600 hover:border-gray-500 disabled:opacity-60 text-stone-200">
{assetDownloadBusy[key] ? 'Downloading...' : 'Download'}
</button>
</div>
))}
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Summarization</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={summaryInput}
onChange={e => setSummaryInput(e.target.value)}
placeholder="Paste text to summarize with the local model..."
className="w-full min-h-28 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
No-think mode
</label>
<button
onClick={() => void runPromptTest()}
disabled={isPromptLoading || !promptInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white">
{isPromptLoading ? 'Running...' : 'Run Prompt Test'}
</button>
</div>
<div className="text-xs text-stone-400">
Calls `openhuman.local_ai_prompt` via Rust core
</div>
{promptOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200">
{promptOutput}
</pre>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Vision Prompt</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={visionPromptInput}
onChange={e => setVisionPromptInput(e.target.value)}
placeholder="Enter a prompt for the vision model..."
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<textarea
value={visionImageInput}
onChange={e => setVisionImageInput(e.target.value)}
placeholder="One image reference per line (data URI, URL, or local path marker)"
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runVisionTest()}
disabled={isVisionLoading || !visionPromptInput.trim() || !visionImageInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white">
{isVisionLoading ? 'Running...' : 'Run Vision Test'}
</button>
{visionOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200">
{visionOutput}
</pre>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Embeddings</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={embeddingInput}
onChange={e => setEmbeddingInput(e.target.value)}
placeholder="One input string per line..."
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runEmbeddingTest()}
disabled={isEmbeddingLoading || !embeddingInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-teal-600 hover:bg-teal-700 disabled:opacity-60 text-white">
{isEmbeddingLoading ? 'Running...' : 'Run Embedding Test'}
</button>
{embeddingOutput && (
<div className="rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200 space-y-1">
<div>Model: {embeddingOutput.model_id}</div>
<div>Dimensions: {embeddingOutput.dimensions}</div>
<div>Vectors: {embeddingOutput.vectors.length}</div>
<div className="flex items-center justify-between">
<div className="text-xs text-stone-400">
Calls `openhuman.local_ai_summarize` via Rust core
</div>
<button
onClick={() => void runSummaryTest()}
disabled={isSummaryLoading || !summaryInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
{isSummaryLoading ? 'Running...' : 'Run Summary Test'}
</button>
</div>
{summaryOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200">
{summaryOutput}
</pre>
)}
</div>
)}
</div>
</section>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Voice Input (STT)</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<input
value={audioPathInput}
onChange={e => setAudioPathInput(e.target.value)}
placeholder="Absolute path to audio file"
className="w-full rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runTranscribeTest()}
disabled={isTranscribeLoading || !audioPathInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-purple-600 hover:bg-purple-700 disabled:opacity-60 text-white">
{isTranscribeLoading ? 'Running...' : 'Run Transcription Test'}
</button>
{transcribeOutput && (
<div className="rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200 space-y-1">
<div>Model: {transcribeOutput.model_id}</div>
<pre className="whitespace-pre-wrap">{transcribeOutput.text}</pre>
</div>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Suggested Prompts</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={suggestInput}
onChange={e => setSuggestInput(e.target.value)}
placeholder="Paste conversation context to generate suggestions..."
className="w-full min-h-28 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex items-center justify-between">
<div className="text-xs text-stone-400">
Calls `openhuman.local_ai_suggest_questions` via Rust core
</div>
<button
onClick={() => void runSuggestTest()}
disabled={isSuggestLoading || !suggestInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 disabled:opacity-60 text-white">
{isSuggestLoading ? 'Running...' : 'Run Suggestion Test'}
</button>
</div>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Voice Output (TTS)</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={ttsInput}
onChange={e => setTtsInput(e.target.value)}
placeholder="Enter text to synthesize..."
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
value={ttsOutputPath}
onChange={e => setTtsOutputPath(e.target.value)}
placeholder="Optional output WAV path"
className="w-full rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runTtsTest()}
disabled={isTtsLoading || !ttsInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-rose-600 hover:bg-rose-700 disabled:opacity-60 text-white">
{isTtsLoading ? 'Running...' : 'Run TTS Test'}
</button>
{ttsOutput && (
<div className="rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200 space-y-1">
<div>Voice: {ttsOutput.voice_id}</div>
<div className="break-all">Output: {ttsOutput.output_path}</div>
{suggestions.length > 0 && (
<div className="space-y-2">
{suggestions.map(suggestion => (
<div
key={`${suggestion.text}-${suggestion.confidence}`}
className="rounded-md border border-gray-700 bg-stone-950 p-3">
<div className="text-sm text-stone-100">{suggestion.text}</div>
<div className="text-xs text-stone-500 mt-1">
Confidence: {(suggestion.confidence * 100).toFixed(0)}%
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</section>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Custom Prompt</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={promptInput}
onChange={e => setPromptInput(e.target.value)}
placeholder="Type any prompt and run it against the local model..."
className="w-full min-h-28 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-xs text-stone-300">
<input
type="checkbox"
checked={promptNoThink}
onChange={e => setPromptNoThink(e.target.checked)}
className="h-3.5 w-3.5 rounded border-gray-600 bg-stone-900 text-blue-500 focus:ring-blue-500"
/>
No-think mode
</label>
<button
onClick={() => void runPromptTest()}
disabled={isPromptLoading || !promptInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white">
{isPromptLoading ? 'Running...' : 'Run Prompt Test'}
</button>
</div>
<div className="text-xs text-stone-400">
Calls `openhuman.local_ai_prompt` via Rust core
</div>
{promptOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200">
{promptOutput}
</pre>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Vision Prompt</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={visionPromptInput}
onChange={e => setVisionPromptInput(e.target.value)}
placeholder="Enter a prompt for the vision model..."
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<textarea
value={visionImageInput}
onChange={e => setVisionImageInput(e.target.value)}
placeholder="One image reference per line (data URI, URL, or local path marker)"
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runVisionTest()}
disabled={
isVisionLoading || !visionPromptInput.trim() || !visionImageInput.trim()
}
className="px-3 py-1.5 text-xs rounded-md bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white">
{isVisionLoading ? 'Running...' : 'Run Vision Test'}
</button>
{visionOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200">
{visionOutput}
</pre>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Embeddings</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={embeddingInput}
onChange={e => setEmbeddingInput(e.target.value)}
placeholder="One input string per line..."
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runEmbeddingTest()}
disabled={isEmbeddingLoading || !embeddingInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-teal-600 hover:bg-teal-700 disabled:opacity-60 text-white">
{isEmbeddingLoading ? 'Running...' : 'Run Embedding Test'}
</button>
{embeddingOutput && (
<div className="rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200 space-y-1">
<div>Model: {embeddingOutput.model_id}</div>
<div>Dimensions: {embeddingOutput.dimensions}</div>
<div>Vectors: {embeddingOutput.vectors.length}</div>
</div>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Voice Input (STT)</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<input
value={audioPathInput}
onChange={e => setAudioPathInput(e.target.value)}
placeholder="Absolute path to audio file"
className="w-full rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runTranscribeTest()}
disabled={isTranscribeLoading || !audioPathInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-purple-600 hover:bg-purple-700 disabled:opacity-60 text-white">
{isTranscribeLoading ? 'Running...' : 'Run Transcription Test'}
</button>
{transcribeOutput && (
<div className="rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200 space-y-1">
<div>Model: {transcribeOutput.model_id}</div>
<pre className="whitespace-pre-wrap">{transcribeOutput.text}</pre>
</div>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-lg font-semibold text-white">Test Voice Output (TTS)</h3>
<div className="bg-gray-900 rounded-lg border border-gray-700 p-4 space-y-3">
<textarea
value={ttsInput}
onChange={e => setTtsInput(e.target.value)}
placeholder="Enter text to synthesize..."
className="w-full min-h-20 rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
value={ttsOutputPath}
onChange={e => setTtsOutputPath(e.target.value)}
placeholder="Optional output WAV path"
className="w-full rounded-md bg-stone-950 border border-gray-700 px-3 py-2 text-sm text-stone-100 placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
onClick={() => void runTtsTest()}
disabled={isTtsLoading || !ttsInput.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-rose-600 hover:bg-rose-700 disabled:opacity-60 text-white">
{isTtsLoading ? 'Running...' : 'Run TTS Test'}
</button>
{ttsOutput && (
<div className="rounded-md bg-stone-950 border border-gray-700 p-3 text-xs text-stone-200 space-y-1">
<div>Voice: {ttsOutput.voice_id}</div>
<div className="break-all">Output: {ttsOutput.output_path}</div>
</div>
)}
</div>
</section>
</>
)}
</div>
</div>
);
@@ -53,7 +53,10 @@ export default function SkillSetupWizard({
isConnected
) {
setSetupComplete(skillId, true).catch(() => {});
setState({ phase: "complete", message: "Successfully connected!" });
// Schedule state update to avoid synchronous setState inside an effect
setTimeout(() => {
setState({ phase: "complete", message: "Successfully connected!" });
}, 0);
}
}, [isConnected, state.phase, skillId]);
+54
View File
@@ -1294,6 +1294,60 @@ export async function openhumanLocalAiDownloadAsset(
});
}
// --- Device profile & model tier presets ---
export interface DeviceProfileResult {
total_ram_bytes: number;
cpu_count: number;
cpu_brand: string;
os_name: string;
os_version: string;
has_gpu: boolean;
gpu_description: string | null;
}
export interface ModelPresetResult {
tier: string;
label: string;
description: string;
chat_model_id: string;
vision_model_id: string;
embedding_model_id: string;
quantization: string;
min_ram_gb: number;
approx_download_gb: number;
}
export interface PresetsResponse {
presets: ModelPresetResult[];
recommended_tier: string;
current_tier: string;
device: DeviceProfileResult;
}
export interface ApplyPresetResult {
applied_tier: string;
chat_model_id: string;
vision_model_id: string;
embedding_model_id: string;
quantization: string;
}
export async function openhumanLocalAiDeviceProfile(): Promise<DeviceProfileResult> {
return await callCoreRpc<DeviceProfileResult>({ method: 'openhuman.local_ai_device_profile' });
}
export async function openhumanLocalAiPresets(): Promise<PresetsResponse> {
return await callCoreRpc<PresetsResponse>({ method: 'openhuman.local_ai_presets' });
}
export async function openhumanLocalAiApplyPreset(tier: string): Promise<ApplyPresetResult> {
return await callCoreRpc<ApplyPresetResult>({
method: 'openhuman.local_ai_apply_preset',
params: { tier },
});
}
export async function aiGetConfig(): Promise<AIPreview> {
return {
soul: {
+22
View File
@@ -570,6 +570,28 @@ impl Config {
self.proxy.enabled = false;
}
if let Ok(tier_str) = std::env::var("OPENHUMAN_LOCAL_AI_TIER") {
let tier_str = tier_str.trim().to_ascii_lowercase();
if !tier_str.is_empty() {
if let Some(tier) =
crate::openhuman::local_ai::presets::ModelTier::from_str_opt(&tier_str)
{
if tier != crate::openhuman::local_ai::presets::ModelTier::Custom {
crate::openhuman::local_ai::presets::apply_preset_to_config(
&mut self.local_ai,
tier,
);
tracing::debug!(tier = %tier_str, "applied local AI tier from OPENHUMAN_LOCAL_AI_TIER");
}
} else {
tracing::warn!(
tier = %tier_str,
"ignoring invalid OPENHUMAN_LOCAL_AI_TIER (valid: low, medium, high)"
);
}
}
}
if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
self.proxy.apply_to_process_env();
}
+3
View File
@@ -49,6 +49,8 @@ pub struct LocalAiConfig {
pub context_compaction_threshold_tokens: usize,
#[serde(default = "default_max_suggestions")]
pub max_suggestions: usize,
#[serde(default)]
pub selected_tier: Option<String>,
}
fn default_enabled() -> bool {
@@ -169,6 +171,7 @@ impl Default for LocalAiConfig {
autosummary_debounce_ms: default_autosummary_debounce_ms(),
context_compaction_threshold_tokens: default_context_compaction_threshold_tokens(),
max_suggestions: default_max_suggestions(),
selected_tier: None,
}
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Device profile detection for guided model selection.
use serde::{Deserialize, Serialize};
use sysinfo::System;
/// Summary of local hardware relevant for model tier selection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceProfile {
pub total_ram_bytes: u64,
pub cpu_count: usize,
pub cpu_brand: String,
pub os_name: String,
pub os_version: String,
pub has_gpu: bool,
pub gpu_description: Option<String>,
}
impl DeviceProfile {
/// Total RAM expressed in whole gigabytes (rounded down).
pub fn total_ram_gb(&self) -> u64 {
self.total_ram_bytes / (1024 * 1024 * 1024)
}
}
/// Probe the current machine and return a [`DeviceProfile`].
///
/// GPU detection is best-effort: Apple Silicon is assumed to have a GPU (Metal);
/// on other platforms we report "unknown" unless more specific probing is added later.
pub fn detect_device_profile() -> DeviceProfile {
let mut sys = System::new_all();
sys.refresh_all();
let total_ram_bytes = sys.total_memory();
let cpu_count = sys.cpus().len();
let cpu_brand = sys
.cpus()
.first()
.map(|c| c.brand().trim().to_string())
.unwrap_or_default();
let os_name = System::name().unwrap_or_else(|| "unknown".to_string());
let os_version = System::os_version().unwrap_or_else(|| "unknown".to_string());
let (has_gpu, gpu_description) = detect_gpu(&cpu_brand, &os_name);
tracing::debug!(
total_ram_bytes,
cpu_count,
cpu_brand = %cpu_brand,
os_name = %os_name,
os_version = %os_version,
has_gpu,
gpu_description = ?gpu_description,
"device profile detected"
);
DeviceProfile {
total_ram_bytes,
cpu_count,
cpu_brand,
os_name,
os_version,
has_gpu,
gpu_description,
}
}
/// Best-effort GPU detection.
///
/// Apple Silicon always has a unified GPU (Metal). On other systems we cannot
/// reliably detect discrete GPUs without heavy platform-specific dependencies,
/// so we conservatively report unknown.
fn detect_gpu(cpu_brand: &str, os_name: &str) -> (bool, Option<String>) {
let brand_lower = cpu_brand.to_ascii_lowercase();
let os_lower = os_name.to_ascii_lowercase();
// Apple Silicon detection: brand contains "apple" or we're on macOS with an ARM chip
if brand_lower.contains("apple") || (os_lower.contains("mac") && brand_lower.contains("arm")) {
return (true, Some("Apple Silicon (Metal)".to_string()));
}
// Fallback: cannot reliably detect discrete GPU without platform libraries.
(false, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_device_profile_returns_nonzero_hardware() {
let profile = detect_device_profile();
assert!(profile.total_ram_bytes > 0, "RAM should be > 0");
assert!(profile.cpu_count > 0, "CPU count should be > 0");
assert!(!profile.os_name.is_empty(), "OS name should be non-empty");
}
#[test]
fn total_ram_gb_rounds_down() {
let profile = DeviceProfile {
total_ram_bytes: 17_179_869_184, // 16 GiB exactly
cpu_count: 8,
cpu_brand: "test".to_string(),
os_name: "test".to_string(),
os_version: "1.0".to_string(),
has_gpu: false,
gpu_description: None,
};
assert_eq!(profile.total_ram_gb(), 16);
}
}
+4
View File
@@ -1,7 +1,9 @@
//! Bundled local AI stack (Ollama, whisper.cpp, Piper).
mod core;
pub mod device;
pub mod ops;
pub mod presets;
mod schemas;
mod install;
@@ -13,8 +15,10 @@ mod service;
mod types;
pub use core::*;
pub use device::DeviceProfile;
pub use ops as rpc;
pub use ops::*;
pub use presets::{ModelPreset, ModelTier};
pub use schemas::{
all_controller_schemas as all_local_ai_controller_schemas,
all_registered_controllers as all_local_ai_registered_controllers,
+251
View File
@@ -0,0 +1,251 @@
//! Tiered model presets and recommendation logic for local AI.
//!
//! # Tier → Model ID Mapping
//!
//! | Tier | Chat / Vision Model | Embedding Model | Min RAM | ~Download |
//! |--------|---------------------------|----------------------------|---------|-----------|
//! | Low | `gemma3:1b-it-q4_0` | `nomic-embed-text:latest` | 4 GB | ~1 GB |
//! | Medium | `gemma3:4b-it-qat` | `nomic-embed-text:latest` | 8 GB | ~3 GB |
//! | High | `gemma3:12b-it-q4_K_M` | `nomic-embed-text:latest` | 16 GB | ~8 GB |
//!
//! # Changing defaults for a release
//!
//! Edit [`all_presets()`] below. Each `ModelPreset` defines:
//! - `chat_model_id` / `vision_model_id` — Ollama tag for the chat and vision models.
//! - `embedding_model_id` — Ollama tag for the embedding model.
//! - `quantization` — quantization label shown in the UI.
//! - `min_ram_gb` / `approx_download_gb` — user-facing guidance.
//!
//! After changing model tags, verify they exist in the Ollama library and update
//! the `OPENHUMAN_LOCAL_AI_TIER` env var docs in `.env.example` if tier names change.
use serde::{Deserialize, Serialize};
use crate::openhuman::config::schema::LocalAiConfig;
use super::device::DeviceProfile;
/// Performance tier for local AI model selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModelTier {
Low,
Medium,
High,
Custom,
}
impl ModelTier {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Custom => "custom",
}
}
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"custom" => Some(Self::Custom),
_ => None,
}
}
}
/// A concrete model preset tied to a performance tier.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelPreset {
pub tier: ModelTier,
pub label: &'static str,
pub description: &'static str,
pub chat_model_id: &'static str,
pub vision_model_id: &'static str,
pub embedding_model_id: &'static str,
pub quantization: &'static str,
pub min_ram_gb: u64,
pub approx_download_gb: f32,
}
/// Return all built-in presets (Low, Medium, High).
pub fn all_presets() -> Vec<ModelPreset> {
vec![
ModelPreset {
tier: ModelTier::Low,
label: "Lightweight",
description: "Smallest footprint. Works on machines with 4 GB+ RAM.",
chat_model_id: "gemma3:1b-it-q4_0",
vision_model_id: "gemma3:1b-it-q4_0",
embedding_model_id: "nomic-embed-text:latest",
quantization: "q4_0",
min_ram_gb: 4,
approx_download_gb: 1.0,
},
ModelPreset {
tier: ModelTier::Medium,
label: "Balanced",
description: "Good quality with moderate resource use. Requires 8 GB+ RAM.",
chat_model_id: "gemma3:4b-it-qat",
vision_model_id: "gemma3:4b-it-qat",
embedding_model_id: "nomic-embed-text:latest",
quantization: "q4",
min_ram_gb: 8,
approx_download_gb: 3.0,
},
ModelPreset {
tier: ModelTier::High,
label: "Performance",
description: "Best quality. Requires 16 GB+ RAM and a capable GPU.",
chat_model_id: "gemma3:12b-it-q4_K_M",
vision_model_id: "gemma3:12b-it-q4_K_M",
embedding_model_id: "nomic-embed-text:latest",
quantization: "q4_K_M",
min_ram_gb: 16,
approx_download_gb: 8.0,
},
]
}
/// Return the preset for a specific tier, or `None` for `Custom`.
pub fn preset_for_tier(tier: ModelTier) -> Option<ModelPreset> {
all_presets().into_iter().find(|p| p.tier == tier)
}
/// Recommend a tier based on device capabilities.
///
/// * < 8 GB RAM -> Low
/// * 8 - 15 GB -> Medium
/// * >= 16 GB -> High
pub fn recommend_tier(device: &DeviceProfile) -> ModelTier {
let ram_gb = device.total_ram_gb();
let tier = if ram_gb >= 16 {
ModelTier::High
} else if ram_gb >= 8 {
ModelTier::Medium
} else {
ModelTier::Low
};
tracing::debug!(ram_gb, ?tier, "recommended model tier");
tier
}
/// Apply a preset to a [`LocalAiConfig`], overwriting model IDs, quantization,
/// and the `selected_tier` marker.
pub fn apply_preset_to_config(config: &mut LocalAiConfig, tier: ModelTier) {
if let Some(preset) = preset_for_tier(tier) {
tracing::debug!(
?tier,
chat = preset.chat_model_id,
"applying preset to config"
);
config.model_id = preset.chat_model_id.to_string();
config.chat_model_id = preset.chat_model_id.to_string();
config.vision_model_id = preset.vision_model_id.to_string();
config.embedding_model_id = preset.embedding_model_id.to_string();
config.quantization = preset.quantization.to_string();
config.selected_tier = Some(tier.as_str().to_string());
} else {
tracing::debug!("apply_preset_to_config called for Custom tier; no-op");
}
}
/// Reverse-lookup the current tier from config. Returns `Custom` if none of the
/// built-in presets match the current model IDs.
pub fn current_tier_from_config(config: &LocalAiConfig) -> ModelTier {
// If a tier is explicitly stored, try to match it first.
if let Some(ref stored) = config.selected_tier {
if let Some(tier) = ModelTier::from_str_opt(stored) {
if tier == ModelTier::Custom {
return ModelTier::Custom;
}
// Verify the stored tier still matches the actual model IDs.
if let Some(preset) = preset_for_tier(tier) {
if config.chat_model_id == preset.chat_model_id
&& config.vision_model_id == preset.vision_model_id
&& config.embedding_model_id == preset.embedding_model_id
{
return tier;
}
}
}
}
// Fallback: match by model IDs.
for preset in all_presets() {
if config.chat_model_id == preset.chat_model_id
&& config.vision_model_id == preset.vision_model_id
&& config.embedding_model_id == preset.embedding_model_id
{
return preset.tier;
}
}
ModelTier::Custom
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recommend_tier_by_ram() {
let low_device = DeviceProfile {
total_ram_bytes: 4 * 1024 * 1024 * 1024, // 4 GB
cpu_count: 4,
cpu_brand: String::new(),
os_name: String::new(),
os_version: String::new(),
has_gpu: false,
gpu_description: None,
};
assert_eq!(recommend_tier(&low_device), ModelTier::Low);
let medium_device = DeviceProfile {
total_ram_bytes: 8 * 1024 * 1024 * 1024, // 8 GB
..low_device.clone()
};
assert_eq!(recommend_tier(&medium_device), ModelTier::Medium);
let high_device = DeviceProfile {
total_ram_bytes: 32 * 1024 * 1024 * 1024_u64, // 32 GB
..low_device.clone()
};
assert_eq!(recommend_tier(&high_device), ModelTier::High);
}
#[test]
fn preset_application_and_round_trip() {
let mut config = LocalAiConfig::default();
apply_preset_to_config(&mut config, ModelTier::Low);
assert_eq!(config.chat_model_id, "gemma3:1b-it-q4_0");
assert_eq!(config.selected_tier, Some("low".to_string()));
assert_eq!(current_tier_from_config(&config), ModelTier::Low);
}
#[test]
fn custom_detection_when_models_dont_match() {
let mut config = LocalAiConfig::default();
config.chat_model_id = "some-other-model:latest".to_string();
config.selected_tier = None;
assert_eq!(current_tier_from_config(&config), ModelTier::Custom);
}
#[test]
fn all_presets_returns_three_tiers() {
let presets = all_presets();
assert_eq!(presets.len(), 3);
assert_eq!(presets[0].tier, ModelTier::Low);
assert_eq!(presets[1].tier, ModelTier::Medium);
assert_eq!(presets[2].tier, ModelTier::High);
}
#[test]
fn default_config_maps_to_medium() {
let config = LocalAiConfig::default();
// Default config uses gemma3:4b-it-qat which is the Medium preset.
assert_eq!(current_tier_from_config(&config), ModelTier::Medium);
}
}
+115
View File
@@ -89,6 +89,11 @@ struct LocalAiDownloadAssetParams {
capability: String,
}
#[derive(Debug, Deserialize)]
struct LocalAiApplyPresetParams {
tier: String,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("agent_chat"),
@@ -110,6 +115,9 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("local_ai_assets_status"),
schemas("local_ai_downloads_progress"),
schemas("local_ai_download_asset"),
schemas("local_ai_device_profile"),
schemas("local_ai_presets"),
schemas("local_ai_apply_preset"),
]
}
@@ -191,6 +199,18 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("local_ai_download_asset"),
handler: handle_local_ai_download_asset,
},
RegisteredController {
schema: schemas("local_ai_device_profile"),
handler: handle_local_ai_device_profile,
},
RegisteredController {
schema: schemas("local_ai_presets"),
handler: handle_local_ai_presets,
},
RegisteredController {
schema: schemas("local_ai_apply_preset"),
handler: handle_local_ai_apply_preset,
},
]
}
@@ -383,6 +403,30 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![required_string("capability", "Asset capability id.")],
outputs: vec![json_output("status", "Assets status payload.")],
},
"local_ai_device_profile" => ControllerSchema {
namespace: "local_ai",
function: "device_profile",
description: "Detect local device hardware profile (RAM, CPU, GPU).",
inputs: vec![],
outputs: vec![json_output("profile", "Device hardware profile.")],
},
"local_ai_presets" => ControllerSchema {
namespace: "local_ai",
function: "presets",
description: "List model tier presets with recommendation and current selection.",
inputs: vec![],
outputs: vec![json_output(
"presets",
"Presets, recommended tier, current tier.",
)],
},
"local_ai_apply_preset" => ControllerSchema {
namespace: "local_ai",
function: "apply_preset",
description: "Apply a model tier preset to local AI config and persist.",
inputs: vec![required_string("tier", "Tier to apply: low, medium, high.")],
outputs: vec![json_output("result", "Applied tier status.")],
},
_ => ControllerSchema {
namespace: "local_ai",
function: "unknown",
@@ -624,6 +668,77 @@ fn handle_local_ai_download_asset(params: Map<String, Value>) -> ControllerFutur
})
}
fn handle_local_ai_device_profile(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
tracing::debug!("[local_ai] device_profile: detecting hardware");
let profile = crate::openhuman::local_ai::device::detect_device_profile();
tracing::debug!("[local_ai] device_profile: done");
let value = serde_json::to_value(&profile).map_err(|e| format!("serialize: {e}"))?;
Ok(value)
})
}
fn handle_local_ai_presets(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
tracing::debug!("[local_ai] presets: loading config and computing tiers");
let config = config_rpc::load_config_with_timeout().await?;
let device = crate::openhuman::local_ai::device::detect_device_profile();
let recommended = crate::openhuman::local_ai::presets::recommend_tier(&device);
let current =
crate::openhuman::local_ai::presets::current_tier_from_config(&config.local_ai);
let presets = crate::openhuman::local_ai::presets::all_presets();
tracing::debug!(
?recommended,
?current,
preset_count = presets.len(),
"[local_ai] presets: returning"
);
let value = serde_json::json!({
"presets": presets,
"recommended_tier": recommended,
"current_tier": current,
"device": device,
});
Ok(value)
})
}
fn handle_local_ai_apply_preset(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<LocalAiApplyPresetParams>(params)?;
let tier_str = p.tier.trim().to_ascii_lowercase();
tracing::debug!(tier = %tier_str, "[local_ai] apply_preset: parsing tier");
let tier = crate::openhuman::local_ai::presets::ModelTier::from_str_opt(&tier_str)
.ok_or_else(|| {
format!(
"invalid tier '{}': expected one of low, medium, high",
tier_str
)
})?;
if tier == crate::openhuman::local_ai::presets::ModelTier::Custom {
return Err("cannot apply 'custom' tier; set model IDs directly".to_string());
}
let mut config = config_rpc::load_config_with_timeout().await?;
crate::openhuman::local_ai::presets::apply_preset_to_config(&mut config.local_ai, tier);
config
.save()
.await
.map_err(|e| format!("save config: {e}"))?;
tracing::debug!(tier = %tier_str, "[local_ai] apply_preset: config saved");
Ok(serde_json::json!({
"applied_tier": tier,
"chat_model_id": config.local_ai.chat_model_id,
"vision_model_id": config.local_ai.vision_model_id,
"embedding_model_id": config.local_ai.embedding_model_id,
"quantization": config.local_ai.quantization,
}))
})
}
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
+22 -2
View File
@@ -121,14 +121,14 @@ impl LocalAiService {
if let Err(err) = self.ensure_ollama_server(config).await {
let mut status = self.status.lock();
status.state = "degraded".to_string();
status.warning = Some(err);
status.warning = Some(format_degraded_warning(&err, config));
return;
}
if let Err(err) = self.ensure_models_available(config).await {
let mut status = self.status.lock();
status.state = "degraded".to_string();
status.warning = Some(err);
status.warning = Some(format_degraded_warning(&err, config));
return;
}
@@ -180,6 +180,26 @@ impl LocalAiService {
}
}
/// Append a tier step-down hint when the current tier is Medium or High.
fn format_degraded_warning(err: &str, config: &Config) -> String {
let current = crate::openhuman::local_ai::presets::current_tier_from_config(&config.local_ai);
match current {
crate::openhuman::local_ai::presets::ModelTier::High => {
format!(
"{err}. Hint: your device may not support the High tier model. \
Try switching to Medium or Low in Settings > Local AI Model."
)
}
crate::openhuman::local_ai::presets::ModelTier::Medium => {
format!(
"{err}. Hint: your device may not support the Medium tier model. \
Try switching to Low in Settings > Local AI Model."
)
}
_ => err.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
+122
View File
@@ -803,3 +803,125 @@ async fn json_rpc_skills_runtime_start_tools_call_stop() {
mock_join.abort();
rpc_join.abort();
}
// ---------------------------------------------------------------------------
// Local AI device profile, presets, and apply preset
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_rpc_local_ai_device_profile_and_presets() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _tier_guard = EnvVarGuard::unset("OPENHUMAN_LOCAL_AI_TIER");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// --- device_profile ---
let profile = post_json_rpc(
&rpc_base,
30,
"openhuman.local_ai_device_profile",
json!({}),
)
.await;
let profile_result = assert_no_jsonrpc_error(&profile, "device_profile");
assert!(
profile_result
.get("total_ram_bytes")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0,
"expected positive RAM: {profile_result}"
);
assert!(
profile_result
.get("cpu_count")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0,
"expected positive CPU count: {profile_result}"
);
// --- presets ---
let presets = post_json_rpc(&rpc_base, 31, "openhuman.local_ai_presets", json!({})).await;
let presets_result = assert_no_jsonrpc_error(&presets, "presets");
let presets_arr = presets_result
.get("presets")
.and_then(Value::as_array)
.expect("presets should be an array");
assert_eq!(presets_arr.len(), 3, "expected 3 presets: {presets_result}");
let recommended = presets_result
.get("recommended_tier")
.and_then(Value::as_str)
.expect("should have recommended_tier");
assert!(
["low", "medium", "high"].contains(&recommended),
"unexpected recommended_tier: {recommended}"
);
let current = presets_result
.get("current_tier")
.and_then(Value::as_str)
.expect("should have current_tier");
// Default config uses gemma3:4b-it-qat which matches Medium
assert_eq!(current, "medium", "default config should be medium tier");
// --- apply_preset (switch to Low) ---
let apply = post_json_rpc(
&rpc_base,
32,
"openhuman.local_ai_apply_preset",
json!({"tier": "low"}),
)
.await;
let apply_result = assert_no_jsonrpc_error(&apply, "apply_preset");
assert_eq!(
apply_result.get("applied_tier").and_then(Value::as_str),
Some("low")
);
assert_eq!(
apply_result.get("chat_model_id").and_then(Value::as_str),
Some("gemma3:1b-it-q4_0")
);
// --- verify presets reflects the change ---
let presets_after = post_json_rpc(&rpc_base, 33, "openhuman.local_ai_presets", json!({})).await;
let presets_after_result = assert_no_jsonrpc_error(&presets_after, "presets_after");
assert_eq!(
presets_after_result
.get("current_tier")
.and_then(Value::as_str),
Some("low"),
"current tier should now be low after apply"
);
// --- apply_preset with invalid tier should error ---
let bad_apply = post_json_rpc(
&rpc_base,
34,
"openhuman.local_ai_apply_preset",
json!({"tier": "ultra"}),
)
.await;
assert!(
bad_apply.get("error").is_some(),
"expected error for invalid tier: {bad_apply}"
);
mock_join.abort();
rpc_join.abort();
}