mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
Generated
+1
-1
@@ -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} · 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]);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user