- Quantization preference: {assets?.quantization ?? 'q4'}
-
- {[
- { 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 }) => (
-
-
{label}
-
{item?.id ?? 'n/a'}
-
- {statusLabel(item?.state ?? 'idle')}
+ {/* Device info */}
+ {presetsData?.device && (
+
+
+
+
RAM
+
+ {formatRamGb(presetsData.device.total_ram_bytes)}
- {item?.path && (
-
{item.path}
- )}
-
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'}
-
- ))}
-
-
-
-
-
-
-
- Test Suggested Prompts
-
-
-
-
- Test Vision Prompt
-
-
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"
- />
- 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"
- />
- 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'}
-
- {visionOutput && (
-
- {visionOutput}
-
- )}
-
-
-
-
- Test Embeddings
-
-
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"
- />
- 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'}
-
- {embeddingOutput && (
-
-
Model: {embeddingOutput.model_id}
-
Dimensions: {embeddingOutput.dimensions}
-
Vectors: {embeddingOutput.vectors.length}
+
+
+ Calls `openhuman.local_ai_summarize` via Rust core
+
+
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'}
+
+
+ {summaryOutput && (
+
+ {summaryOutput}
+
+ )}
- )}
-
-
+
-
- Test Voice Input (STT)
-
-
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"
- />
-
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'}
-
- {transcribeOutput && (
-
-
Model: {transcribeOutput.model_id}
-
{transcribeOutput.text}
-
- )}
-
-
+
+ Test Suggested Prompts
+
+
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"
+ />
+
+
+ Calls `openhuman.local_ai_suggest_questions` via Rust core
+
+
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'}
+
+
-
- Test Voice Output (TTS)
-
-
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"
- />
- 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"
- />
- 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'}
-
- {ttsOutput && (
-
-
Voice: {ttsOutput.voice_id}
-
Output: {ttsOutput.output_path}
+ {suggestions.length > 0 && (
+
+ {suggestions.map(suggestion => (
+
+
{suggestion.text}
+
+ Confidence: {(suggestion.confidence * 100).toFixed(0)}%
+
+
+ ))}
+
+ )}
- )}
-
-
+
+
+
+ Test Custom Prompt
+
+
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"
+ />
+
+
+ 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
+
+ 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'}
+
+
+
+ Calls `openhuman.local_ai_prompt` via Rust core
+
+ {promptOutput && (
+
+ {promptOutput}
+
+ )}
+
+
+
+
+ Test Vision Prompt
+
+
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"
+ />
+ 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"
+ />
+ 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'}
+
+ {visionOutput && (
+
+ {visionOutput}
+
+ )}
+
+
+
+
+ Test Embeddings
+
+
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"
+ />
+ 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'}
+
+ {embeddingOutput && (
+
+
Model: {embeddingOutput.model_id}
+
Dimensions: {embeddingOutput.dimensions}
+
Vectors: {embeddingOutput.vectors.length}
+
+ )}
+
+
+
+
+ Test Voice Input (STT)
+
+
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"
+ />
+
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'}
+
+ {transcribeOutput && (
+
+
Model: {transcribeOutput.model_id}
+
{transcribeOutput.text}
+
+ )}
+
+
+
+
+ Test Voice Output (TTS)
+
+
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"
+ />
+ 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"
+ />
+ 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'}
+
+ {ttsOutput && (
+
+
Voice: {ttsOutput.voice_id}
+
Output: {ttsOutput.output_path}
+
+ )}
+
+
+ >
+ )}
);
diff --git a/app/src/components/skills/SkillSetupWizard.tsx b/app/src/components/skills/SkillSetupWizard.tsx
index 727b28536..50ab38a6f 100644
--- a/app/src/components/skills/SkillSetupWizard.tsx
+++ b/app/src/components/skills/SkillSetupWizard.tsx
@@ -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]);
diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts
index 612b3b250..b45210b85 100644
--- a/app/src/utils/tauriCommands.ts
+++ b/app/src/utils/tauriCommands.ts
@@ -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
{
+ return await callCoreRpc({ method: 'openhuman.local_ai_device_profile' });
+}
+
+export async function openhumanLocalAiPresets(): Promise {
+ return await callCoreRpc({ method: 'openhuman.local_ai_presets' });
+}
+
+export async function openhumanLocalAiApplyPreset(tier: string): Promise {
+ return await callCoreRpc({
+ method: 'openhuman.local_ai_apply_preset',
+ params: { tier },
+ });
+}
+
export async function aiGetConfig(): Promise {
return {
soul: {
diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs
index c5ccc0dfe..9af009ab9 100644
--- a/src/openhuman/config/schema/load.rs
+++ b/src/openhuman/config/schema/load.rs
@@ -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();
}
diff --git a/src/openhuman/config/schema/local_ai.rs b/src/openhuman/config/schema/local_ai.rs
index b24116ef1..9fa7de03e 100644
--- a/src/openhuman/config/schema/local_ai.rs
+++ b/src/openhuman/config/schema/local_ai.rs
@@ -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,
}
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,
}
}
}
diff --git a/src/openhuman/local_ai/device.rs b/src/openhuman/local_ai/device.rs
new file mode 100644
index 000000000..ca7cc0bfb
--- /dev/null
+++ b/src/openhuman/local_ai/device.rs
@@ -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,
+}
+
+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) {
+ 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);
+ }
+}
diff --git a/src/openhuman/local_ai/mod.rs b/src/openhuman/local_ai/mod.rs
index 5982e8d83..350297813 100644
--- a/src/openhuman/local_ai/mod.rs
+++ b/src/openhuman/local_ai/mod.rs
@@ -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,
diff --git a/src/openhuman/local_ai/presets.rs b/src/openhuman/local_ai/presets.rs
new file mode 100644
index 000000000..21503fd22
--- /dev/null
+++ b/src/openhuman/local_ai/presets.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/src/openhuman/local_ai/schemas.rs b/src/openhuman/local_ai/schemas.rs
index 7ae1478d9..89d77e90a 100644
--- a/src/openhuman/local_ai/schemas.rs
+++ b/src/openhuman/local_ai/schemas.rs
@@ -89,6 +89,11 @@ struct LocalAiDownloadAssetParams {
capability: String,
}
+#[derive(Debug, Deserialize)]
+struct LocalAiApplyPresetParams {
+ tier: String,
+}
+
pub fn all_controller_schemas() -> Vec {
vec![
schemas("agent_chat"),
@@ -110,6 +115,9 @@ pub fn all_controller_schemas() -> Vec {
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 {
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) -> ControllerFutur
})
}
+fn handle_local_ai_device_profile(_params: Map) -> 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) -> 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) -> ControllerFuture {
+ Box::pin(async move {
+ let p = deserialize_params::(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(params: Map) -> Result {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
diff --git a/src/openhuman/local_ai/service/bootstrap.rs b/src/openhuman/local_ai/service/bootstrap.rs
index 7adfd88ec..495b74e9d 100644
--- a/src/openhuman/local_ai/service/bootstrap.rs
+++ b/src/openhuman/local_ai/service/bootstrap.rs
@@ -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::*;
diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs
index 8e9fc600d..262afe56d 100644
--- a/tests/json_rpc_e2e.rs
+++ b/tests/json_rpc_e2e.rs
@@ -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();
+}