feat(local_ai): default to cloud fallback on <8GB RAM devices (#589)

* Enhance draft message handling in streaming edits

- Introduced a new `draft_sent` field in the `StreamingState` struct to track when a draft message has been posted, decoupling the existence of a draft from the ability to edit it.
- Updated the `flush_streaming_edit` function to set `draft_sent` upon posting a draft, ensuring that duplicate messages are not sent if the backend fails to return an ID.
- Modified the `finalize_channel_reply` function to prevent sending a fresh message if a draft was already posted without an ID, improving user experience by avoiding duplicate bubbles.

These changes enhance the robustness of message handling during streaming edits, ensuring a smoother interaction for users.

* feat(onboarding): enhance LocalAIStep for low-RAM device handling

- Added logic to determine if the device is below the RAM threshold, defaulting to a cloud AI fallback if necessary.
- Introduced a new UI for low-RAM devices, informing users about the cloud mode and providing options to continue with cloud AI or force-enable local AI.
- Updated the `ensureRecommendedLocalAiPresetIfNeeded` function to return a `recommend_disabled` flag based on device RAM.
- Enhanced tests to cover new cloud fallback UI and local AI consent handling.

These changes improve the onboarding experience by providing clearer options based on device capabilities.

* style: apply prettier formatting to LocalAIStep

* feat(local_ai): unlock model selection + add Disabled/cloud fallback option

- Remove MVP ceiling that clamped selection to the 2-4 GB tier, in bootstrap
  and in the apply_preset RPC — users can now pick any tier.
- Add special "disabled" tier string to apply_preset that toggles
  config.local_ai.enabled = false so the app uses the cloud summarizer.
- Presets RPC now returns local_ai_enabled so the UI can render the
  currently-active state correctly.
- Rewrite DeviceCapabilitySection: tiers are now clickable buttons that
  call apply_preset; adds a "Disabled — Cloud fallback" card at the top
  marked "Recommended" on low-RAM devices and "Active" when enabled=false.
- Remove the MVP message copy from the model tier panel.
- Remove unread-count badge from the bottom tab bar.

* refactor(onboarding): streamline LocalAIStep and update local AI preset handling

- Removed the `ensureRecommendedLocalAiPresetIfNeeded` function call from `LocalAIStep`, replacing it with `openhumanLocalAiPresets` to directly fetch preset information.
- Updated the logic in `ensureRecommendedLocalAiPresetIfNeeded` to ensure the recommended tier is applied correctly based on user consent, improving clarity in the local AI setup process.
- Enhanced tests to mock the new `openhumanLocalAiPresets` function, ensuring coverage for low-RAM device scenarios and local AI consent handling.
- Updated documentation in the `schemas.rs` file to reflect changes in the data structure returned by the presets RPC.

These changes improve the onboarding experience by providing a more direct and efficient method for managing local AI presets based on device capabilities.

* test(LocalAIStep): improve mock implementation for local AI presets

- Refactored the mock for `openhumanLocalAiPresets` to enhance readability and maintainability.
- Ensured the mock returns consistent device information and preset details for testing scenarios.
- This change supports better test coverage and clarity in the LocalAIStep component's behavior during onboarding.

* fix(local_ai): preserve hadSelectedTier semantics in bootstrap return

hadSelectedTier / selectedTier represent the incoming state ("was a tier
already selected before this call"), not the post-apply state. Setting
both to the just-applied tier broke the existing localAiBootstrap
contract and the unit test that encodes it.

The Rust-side persistence fix is independent: removing the
recommend_disabled short-circuit ensures openhumanLocalAiApplyPreset
actually writes the selected tier to disk on the opt-in path, so
config_with_recommended_tier_if_unselected() honors the user's choice.
This commit is contained in:
Steven Enamakel
2026-04-16 10:20:28 -07:00
committed by GitHub
parent 923c920ad5
commit 7db71408bd
9 changed files with 463 additions and 113 deletions
@@ -37,7 +37,7 @@ const LocalModelPanel = () => {
const [presetsData, setPresetsData] = useState<PresetsResponse | null>(null);
const [presetsLoading, setPresetsLoading] = useState(true);
const [presetError, setPresetError] = useState('');
const [presetSuccess] = useState<ApplyPresetResult | null>(null);
const [presetSuccess, setPresetSuccess] = useState<ApplyPresetResult | null>(null);
const progress = useMemo(() => {
const downloadProgress = progressFromDownloads(downloads);
@@ -134,6 +134,11 @@ const LocalModelPanel = () => {
presetError={presetError}
presetSuccess={presetSuccess}
formatRamGb={formatRamGb}
onPresetApplied={result => {
setPresetSuccess(result);
void loadPresets();
void loadStatus();
}}
/>
{/* Simplified download status */}
@@ -1,4 +1,10 @@
import type { ApplyPresetResult, PresetsResponse } from '../../../../utils/tauriCommands';
import { useState } from 'react';
import {
type ApplyPresetResult,
openhumanLocalAiApplyPreset,
type PresetsResponse,
} from '../../../../utils/tauriCommands';
interface DeviceCapabilitySectionProps {
presetsData: PresetsResponse | null;
@@ -6,15 +12,40 @@ interface DeviceCapabilitySectionProps {
presetError: string;
presetSuccess: ApplyPresetResult | null;
formatRamGb: (bytes: number) => string;
onPresetApplied?: (result: ApplyPresetResult) => void;
}
const DISABLED_TIER_ID = 'disabled';
const DeviceCapabilitySection = ({
presetsData,
presetsLoading,
presetError,
presetSuccess,
formatRamGb,
onPresetApplied,
}: DeviceCapabilitySectionProps) => {
const [applying, setApplying] = useState<string | null>(null);
const [applyError, setApplyError] = useState<string>('');
const [applySuccess, setApplySuccess] = useState<ApplyPresetResult | null>(null);
const isDisabledActive = presetsData ? presetsData.local_ai_enabled === false : false;
const handleApply = async (tierId: string) => {
setApplying(tierId);
setApplyError('');
try {
const result = await openhumanLocalAiApplyPreset(tierId);
setApplySuccess(result);
onPresetApplied?.(result);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to apply preset';
setApplyError(msg);
} finally {
setApplying(null);
}
};
return (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Model Tier</h3>
@@ -63,21 +94,51 @@ const DeviceCapabilitySection = ({
{presetsData && (
<div className="space-y-2">
<div className="rounded-lg border border-primary-200 bg-primary-50 p-3 text-xs text-primary-700">
The local AI model is fixed for the MVP release. Broader model options will be available
in a future update.
</div>
{/* Disabled — Cloud fallback card (always available, recommended on low-RAM) */}
<button
type="button"
onClick={() => void handleApply(DISABLED_TIER_ID)}
disabled={applying !== null}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isDisabledActive
? 'border-primary-400 bg-primary-50'
: 'border-stone-200 bg-stone-50 hover:bg-stone-100'
} ${applying !== null ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900">Disabled</span>
{isDisabledActive && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-50 text-primary-600 uppercase tracking-wide">
Active
</span>
)}
{presetsData.recommend_disabled && !isDisabledActive && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 text-amber-700 uppercase tracking-wide">
Recommended
</span>
)}
</div>
<span className="text-xs text-stone-500">0 GB</span>
</div>
<div className="text-xs text-stone-500 mt-1">
Fallback to the cloud summarizer model. No local download or Ollama install required.
</div>
</button>
{presetsData.presets.map(preset => {
const isCurrent = preset.tier === presetsData.current_tier;
const isLocked = !isCurrent;
const isCurrent = !isDisabledActive && preset.tier === presetsData.current_tier;
const isApplying = applying === preset.tier;
return (
<div
<button
type="button"
key={preset.tier}
className={`w-full text-left rounded-lg border p-3 ${
onClick={() => void handleApply(preset.tier)}
disabled={applying !== null}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isCurrent
? 'border-primary-400 bg-primary-50'
: 'border-stone-200 bg-stone-50 opacity-50'
}`}>
: 'border-stone-200 bg-stone-50 hover:bg-stone-100'
} ${applying !== null && !isApplying ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900">{preset.label}</span>
@@ -86,9 +147,9 @@ const DeviceCapabilitySection = ({
Active
</span>
)}
{isLocked && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-stone-100 text-stone-400 uppercase tracking-wide">
Coming soon
{isApplying && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-stone-100 text-stone-500 uppercase tracking-wide">
Applying
</span>
)}
</div>
@@ -104,11 +165,11 @@ const DeviceCapabilitySection = ({
: preset.vision_model_id || preset.vision_mode}{' '}
&middot; Target RAM: {preset.target_ram_gb} GB
</div>
</div>
</button>
);
})}
{presetsData.current_tier === 'custom' && (
{presetsData.current_tier === 'custom' && !isDisabledActive && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
You are using custom model IDs that do not match any built-in preset.
</div>
@@ -116,12 +177,19 @@ const DeviceCapabilitySection = ({
</div>
)}
{applyError && <div className="text-xs text-red-600">{applyError}</div>}
{presetError && !(!presetsLoading && !presetsData) && (
<div className="text-xs text-red-600">{presetError}</div>
)}
{presetSuccess && (
{(applySuccess ?? presetSuccess) && (
<div className="text-xs text-green-700">
Applied {presetSuccess.applied_tier} tier: {presetSuccess.chat_model_id}
{(applySuccess ?? presetSuccess)?.applied_tier === DISABLED_TIER_ID
? 'Local AI disabled — using cloud fallback.'
: `Applied ${(applySuccess ?? presetSuccess)?.applied_tier} tier${
(applySuccess ?? presetSuccess)?.chat_model_id
? `: ${(applySuccess ?? presetSuccess)?.chat_model_id}`
: ''
}`}
</div>
)}
</section>
+105 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { bootstrapLocalAiWithRecommendedPreset } from '../../../utils/localAiBootstrap';
import { openhumanLocalAiPresets } from '../../../utils/tauriCommands';
import OnboardingNextButton from '../components/OnboardingNextButton';
/* ---------- component ---------- */
@@ -13,6 +14,25 @@ interface LocalAIStepProps {
const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepProps) => {
const downloadStartedRef = useRef(false);
const [recommendDisabled, setRecommendDisabled] = useState<boolean | null>(null);
useEffect(() => {
let cancelled = false;
// Read-only probe: never apply/persist a preset from the mount effect.
// Preset application lives in handleConsent via bootstrapLocalAiWithRecommendedPreset.
openhumanLocalAiPresets()
.then(presets => {
if (!cancelled) {
setRecommendDisabled(presets.recommend_disabled ?? false);
}
})
.catch(() => {
if (!cancelled) setRecommendDisabled(false);
});
return () => {
cancelled = true;
};
}, []);
const handleConsent = useCallback(() => {
if (downloadStartedRef.current) return;
@@ -29,6 +49,83 @@ const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepPr
onNext({ consentGiven: true, downloadStarted: true });
}, [onNext, onDownloadError]);
const handleSkip = useCallback(() => {
console.debug('[LocalAIStep] skipping local AI — using cloud fallback');
onNext({ consentGiven: false, downloadStarted: false });
}, [onNext]);
// Still probing device — show nothing yet.
if (recommendDisabled === null) {
return null;
}
// Low-RAM device: show cloud fallback option as the primary path.
if (recommendDisabled) {
return (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="flex flex-col items-center mb-5">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 mb-3">
<svg
className="h-8 w-8 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
</div>
<h1 className="text-xl font-bold mb-2 text-stone-900">AI Cloud Mode</h1>
<p className="text-stone-600 text-sm text-center">
Your device has limited RAM, so we&apos;ll use a fast, lightweight cloud model for AI
features. You can switch to local AI later in Settings.
</p>
</div>
<div className="space-y-2 mb-5">
<div className="rounded-xl border border-primary-200 bg-primary-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">Fast &amp; lightweight</span>
<span className="text-stone-600">
&nbsp; uses a cheap cloud summarizer model with minimal latency.
</span>
</p>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">No downloads needed</span>
<span className="text-stone-600">
&nbsp; no large model files or Ollama install required.
</span>
</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">Requires internet</span>
<span className="text-stone-600">
&nbsp; AI features need an active connection. You can opt into local AI in Settings
if preferred.
</span>
</p>
</div>
</div>
<OnboardingNextButton label="Continue with Cloud" onClick={handleSkip} />
<button
type="button"
onClick={handleConsent}
className="mt-3 w-full text-center text-xs text-stone-400 hover:text-stone-600 transition-colors">
Use local AI anyway (not recommended for your device)
</button>
</div>
);
}
// Sufficient RAM: show the standard local AI onboarding.
return (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="flex flex-col items-center mb-5">
@@ -68,6 +165,13 @@ const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepPr
</div>
<OnboardingNextButton label="Continue" onClick={handleConsent} />
<button
type="button"
onClick={handleSkip}
className="mt-3 w-full text-center text-xs text-stone-400 hover:text-stone-600 transition-colors">
Skip use cloud AI instead
</button>
</div>
);
};
@@ -8,6 +8,27 @@ vi.mock('../../../../utils/localAiBootstrap', () => ({
bootstrapLocalAiWithRecommendedPreset: vi.fn().mockResolvedValue({} as never),
}));
vi.mock('../../../../utils/tauriCommands', () => ({
openhumanLocalAiPresets: vi
.fn()
.mockResolvedValue({
recommend_disabled: false,
presets: [],
recommended_tier: 'ram_2_4gb',
current_tier: 'ram_2_4gb',
selected_tier: null,
device: {
total_ram_bytes: 16 * 1024 * 1024 * 1024,
cpu_count: 8,
cpu_brand: 'test',
os_name: 'test',
os_version: '1.0',
has_gpu: false,
gpu_description: null,
},
} as never),
}));
describe('LocalAIStep', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -17,7 +38,8 @@ describe('LocalAIStep', () => {
const onNext = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} />);
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
const button = await screen.findByRole('button', { name: /continue/i });
fireEvent.click(button);
expect(onNext).toHaveBeenCalledOnce();
expect(onNext).toHaveBeenCalledWith({ consentGiven: true, downloadStarted: true });
@@ -34,7 +56,8 @@ describe('LocalAIStep', () => {
const onDownloadError = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} onDownloadError={onDownloadError} />);
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
const button = await screen.findByRole('button', { name: /continue/i });
fireEvent.click(button);
// onNext still fires immediately
expect(onNext).toHaveBeenCalledOnce();
@@ -53,7 +76,8 @@ describe('LocalAIStep', () => {
const onNext = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} />);
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
const button = await screen.findByRole('button', { name: /continue/i });
fireEvent.click(button);
expect(bootstrapLocalAiWithRecommendedPreset).toHaveBeenCalledOnce();
expect(bootstrapLocalAiWithRecommendedPreset).toHaveBeenCalledWith(false, '[LocalAIStep]');
@@ -67,11 +91,71 @@ describe('LocalAIStep', () => {
const onNext = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} />);
const button = screen.getByRole('button', { name: /continue/i });
const button = await screen.findByRole('button', { name: /continue/i });
fireEvent.click(button);
fireEvent.click(button);
expect(onNext).toHaveBeenCalledOnce();
expect(bootstrapLocalAiWithRecommendedPreset).toHaveBeenCalledOnce();
});
it('shows cloud fallback UI when device is below RAM floor', async () => {
const { openhumanLocalAiPresets } = await import('../../../../utils/tauriCommands');
vi.mocked(openhumanLocalAiPresets).mockResolvedValue({
recommend_disabled: true,
presets: [],
recommended_tier: 'ram_2_4gb',
current_tier: 'ram_2_4gb',
selected_tier: null,
device: {
total_ram_bytes: 4 * 1024 * 1024 * 1024,
cpu_count: 4,
cpu_brand: 'test',
os_name: 'test',
os_version: '1.0',
has_gpu: false,
gpu_description: null,
},
} as never);
const onNext = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} />);
const cloudButton = await screen.findByRole('button', { name: /continue with cloud/i });
expect(cloudButton).toBeTruthy();
fireEvent.click(cloudButton);
expect(onNext).toHaveBeenCalledWith({ consentGiven: false, downloadStarted: false });
});
it('allows force-enabling local AI on low-RAM device', async () => {
const { openhumanLocalAiPresets } = await import('../../../../utils/tauriCommands');
const { bootstrapLocalAiWithRecommendedPreset } =
await import('../../../../utils/localAiBootstrap');
vi.mocked(openhumanLocalAiPresets).mockResolvedValue({
recommend_disabled: true,
presets: [],
recommended_tier: 'ram_2_4gb',
current_tier: 'ram_2_4gb',
selected_tier: null,
device: {
total_ram_bytes: 4 * 1024 * 1024 * 1024,
cpu_count: 4,
cpu_brand: 'test',
os_name: 'test',
os_version: '1.0',
has_gpu: false,
gpu_description: null,
},
} as never);
const onNext = vi.fn();
renderWithProviders(<LocalAIStep onNext={onNext} />);
const forceButton = await screen.findByRole('button', { name: /use local ai anyway/i });
fireEvent.click(forceButton);
expect(onNext).toHaveBeenCalledWith({ consentGiven: true, downloadStarted: true });
expect(bootstrapLocalAiWithRecommendedPreset).toHaveBeenCalledOnce();
});
});
+7 -1
View File
@@ -70,9 +70,15 @@ export const ensureRecommendedLocalAiPresetIfNeeded = async (
return { presets, recommendedTier, selectedTier, hadSelectedTier: true, appliedTier: null };
}
// No selected tier yet: persist the recommended tier so the Rust-side
// `config_with_recommended_tier_if_unselected()` honors the user's
// opt-in instead of defaulting a low-RAM device back to disabled.
// The mount-time probe in LocalAIStep uses `openhumanLocalAiPresets()`
// directly, so this apply only runs when the user has explicitly
// chosen to proceed with local AI (consent flow).
console.debug(
`${logPrefix} applying recommended local AI preset`,
JSON.stringify({ recommendedTier })
JSON.stringify({ recommendedTier, recommendDisabled: presets.recommend_disabled ?? false })
);
await retryLocalAiCommand(
'apply recommended local AI preset',
+9 -4
View File
@@ -184,15 +184,20 @@ export interface PresetsResponse {
current_tier: string;
selected_tier?: string | null;
device: DeviceProfileResult;
/** When true the device is below the RAM floor and cloud fallback is the recommended default. */
recommend_disabled?: boolean;
/** Current value of `config.local_ai.enabled`. When false, cloud fallback is in use. */
local_ai_enabled?: boolean;
}
export interface ApplyPresetResult {
applied_tier: string;
chat_model_id: string;
vision_model_id: string;
embedding_model_id: string;
quantization: string;
chat_model_id?: string;
vision_model_id?: string;
embedding_model_id?: string;
quantization?: string;
vision_mode?: string;
local_ai_enabled?: boolean;
}
export interface LocalAiDiagnostics {
+44 -22
View File
@@ -33,6 +33,24 @@ pub enum ModelTier {
/// selection is re-enabled post-MVP.
pub const MVP_MAX_TIER: ModelTier = ModelTier::Ram2To4Gb;
/// Minimum host RAM (in whole GB) below which the **default** is to skip
/// local inference and use the cloud summarizer instead. The user can still
/// override this and opt into local AI via settings.
pub const MIN_RAM_GB_FOR_LOCAL_AI: u64 = 8;
/// Returns `true` when the device has enough RAM that local AI should be
/// enabled by default. Below the floor we recommend cloud fallback instead.
pub fn device_supports_local_ai(device: &DeviceProfile) -> bool {
device.total_ram_gb() >= MIN_RAM_GB_FOR_LOCAL_AI
}
/// Returns `true` when the device is below the RAM floor and local AI should
/// default to disabled (cloud fallback). This is a **recommendation**, not a
/// hard gate — the user can still opt in.
pub fn should_default_to_cloud_fallback(device: &DeviceProfile) -> bool {
!device_supports_local_ai(device)
}
impl ModelTier {
pub fn as_str(&self) -> &'static str {
match self {
@@ -178,12 +196,9 @@ pub fn preset_for_tier(tier: ModelTier) -> Option<ModelPreset> {
}
/// Recommend a tier based on device capabilities.
///
/// The recommendation is capped at [`MVP_MAX_TIER`] so that auto-provisioning
/// never selects a model above the MVP ceiling, regardless of available RAM.
pub fn recommend_tier(device: &DeviceProfile) -> ModelTier {
let ram_gb = device.total_ram_gb();
let uncapped = if ram_gb >= 16 {
let tier = if ram_gb >= 16 {
ModelTier::Ram16PlusGb
} else if ram_gb >= 8 {
ModelTier::Ram8To16Gb
@@ -194,17 +209,6 @@ pub fn recommend_tier(device: &DeviceProfile) -> ModelTier {
} else {
ModelTier::Ram1Gb
};
let tier = if uncapped.is_mvp_allowed() {
uncapped
} else {
tracing::debug!(
ram_gb,
?uncapped,
capped_to = ?MVP_MAX_TIER,
"[local_ai] capping recommended tier to MVP ceiling"
);
MVP_MAX_TIER
};
tracing::debug!(ram_gb, ?tier, "[local_ai] recommended model tier");
tier
}
@@ -318,13 +322,12 @@ mod tests {
}
#[test]
fn recommend_tier_by_ram_capped_at_mvp_max() {
// All devices are capped at the single MVP tier (Ram2To4Gb).
assert_eq!(recommend_tier(&test_device(1)), MVP_MAX_TIER);
assert_eq!(recommend_tier(&test_device(3)), MVP_MAX_TIER);
assert_eq!(recommend_tier(&test_device(4)), MVP_MAX_TIER);
assert_eq!(recommend_tier(&test_device(8)), MVP_MAX_TIER);
assert_eq!(recommend_tier(&test_device(32)), MVP_MAX_TIER);
fn recommend_tier_scales_with_ram() {
assert_eq!(recommend_tier(&test_device(1)), ModelTier::Ram1Gb);
assert_eq!(recommend_tier(&test_device(3)), ModelTier::Ram2To4Gb);
assert_eq!(recommend_tier(&test_device(4)), ModelTier::Ram4To8Gb);
assert_eq!(recommend_tier(&test_device(8)), ModelTier::Ram8To16Gb);
assert_eq!(recommend_tier(&test_device(32)), ModelTier::Ram16PlusGb);
}
#[test]
@@ -381,6 +384,25 @@ mod tests {
assert_eq!(vision_mode_for_config(&config), VisionMode::Bundled);
}
#[test]
fn device_supports_local_ai_honors_min_ram_floor() {
assert!(!device_supports_local_ai(&test_device(1)));
assert!(!device_supports_local_ai(&test_device(4)));
assert!(!device_supports_local_ai(&test_device(7)));
assert!(device_supports_local_ai(&test_device(8)));
assert!(device_supports_local_ai(&test_device(16)));
assert!(device_supports_local_ai(&test_device(64)));
}
#[test]
fn should_default_to_cloud_fallback_below_floor() {
assert!(should_default_to_cloud_fallback(&test_device(1)));
assert!(should_default_to_cloud_fallback(&test_device(4)));
assert!(should_default_to_cloud_fallback(&test_device(7)));
assert!(!should_default_to_cloud_fallback(&test_device(8)));
assert!(!should_default_to_cloud_fallback(&test_device(16)));
}
#[test]
fn built_in_vision_modes_match_expectations() {
let mut config = LocalAiConfig::default();
+30 -14
View File
@@ -437,7 +437,11 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![json_output(
"presets",
"Presets, recommended tier, current tier.",
"Object containing: presets (array of ModelPreset), recommended_tier (string), \
current_tier (string), selected_tier (string | null), device (DeviceProfile), \
recommend_disabled (boolean — true when the device is below the RAM floor and \
cloud fallback is the recommended default), local_ai_enabled (boolean — mirrors \
config.local_ai.enabled so the UI can render the active state when disabled).",
)],
},
"local_ai_apply_preset" => ControllerSchema {
@@ -759,12 +763,16 @@ fn handle_local_ai_presets(_params: Map<String, Value>) -> ControllerFuture {
preset_count = presets.len(),
"[local_ai] presets: returning"
);
let recommend_disabled =
crate::openhuman::local_ai::presets::should_default_to_cloud_fallback(&device);
let value = serde_json::json!({
"presets": presets,
"recommended_tier": recommended,
"current_tier": current,
"selected_tier": selected_tier,
"device": device,
"recommend_disabled": recommend_disabled,
"local_ai_enabled": config.local_ai.enabled,
});
Ok(value)
})
@@ -776,10 +784,26 @@ fn handle_local_ai_apply_preset(params: Map<String, Value>) -> ControllerFuture
let tier_str = p.tier.trim().to_ascii_lowercase();
tracing::debug!(tier = %tier_str, "[local_ai] apply_preset: parsing tier");
// Special "disabled" tier: turn local_ai off and route AI to cloud.
if tier_str == "disabled" {
let mut config = config_rpc::load_config_with_timeout().await?;
config.local_ai.enabled = false;
config.local_ai.selected_tier = Some("disabled".to_string());
config
.save()
.await
.map_err(|e| format!("save config: {e}"))?;
tracing::debug!("[local_ai] apply_preset: local_ai disabled (cloud fallback)");
return Ok(serde_json::json!({
"applied_tier": "disabled",
"local_ai_enabled": false,
}));
}
let tier = crate::openhuman::local_ai::presets::ModelTier::from_str_opt(&tier_str)
.ok_or_else(|| {
format!(
"invalid tier '{}': expected one of ram_1gb, ram_2_4gb, ram_4_8gb, ram_8_16gb, ram_16_plus_gb",
"invalid tier '{}': expected one of disabled, ram_1gb, ram_2_4gb, ram_4_8gb, ram_8_16gb, ram_16_plus_gb",
tier_str
)
})?;
@@ -788,19 +812,10 @@ fn handle_local_ai_apply_preset(params: Map<String, Value>) -> ControllerFuture
return Err("cannot apply 'custom' tier; set model IDs directly".to_string());
}
if !tier.is_mvp_allowed() {
tracing::debug!(
tier = %tier_str,
"[local_ai] apply_preset: rejected — tier exceeds MVP ceiling"
);
return Err(format!(
"model tier '{}' is not available in MVP; only tiers up to {} are allowed",
tier_str,
crate::openhuman::local_ai::presets::MVP_MAX_TIER.as_str()
));
}
let mut config = config_rpc::load_config_with_timeout().await?;
// Re-enable local AI in case it was previously disabled via the
// "disabled" tier, so the user can switch back to local inference.
config.local_ai.enabled = true;
crate::openhuman::local_ai::presets::apply_preset_to_config(&mut config.local_ai, tier);
config
.save()
@@ -815,6 +830,7 @@ fn handle_local_ai_apply_preset(params: Map<String, Value>) -> ControllerFuture
"embedding_model_id": config.local_ai.embedding_model_id,
"quantization": config.local_ai.quantization,
"vision_mode": crate::openhuman::local_ai::presets::vision_mode_for_config(&config.local_ai),
"local_ai_enabled": true,
}))
})
}
+88 -48
View File
@@ -274,28 +274,31 @@ fn config_with_recommended_tier_if_unselected(config: &Config, device: &DevicePr
let current_tier =
crate::openhuman::local_ai::presets::current_tier_from_config(&config.local_ai);
// If a tier is already selected, check whether it exceeds the MVP ceiling.
// If so, clamp it down to the recommended (MVP-capped) tier.
// When the user has *not* explicitly picked a tier and the device is below
// the RAM floor, default to disabled (cloud fallback). The user can still
// opt in via Settings. This check runs first so even a "detected" tier from
// matching defaults gets overridden on low-RAM hosts.
if selected_tier.is_none()
&& crate::openhuman::local_ai::presets::should_default_to_cloud_fallback(device)
{
tracing::debug!(
total_ram_gb = device.total_ram_gb(),
min_required_gb = crate::openhuman::local_ai::presets::MIN_RAM_GB_FOR_LOCAL_AI,
"[local_ai] bootstrap: device below RAM floor, defaulting to disabled (cloud fallback)"
);
let mut effective_config = config.clone();
effective_config.local_ai.enabled = false;
return effective_config;
}
// If the user has already picked a tier (explicit or matching defaults),
// honor it. No MVP ceiling — users can pick any tier.
if selected_tier.is_some()
|| !matches!(
current_tier,
crate::openhuman::local_ai::presets::ModelTier::Custom
)
{
if !current_tier.is_mvp_allowed() {
let recommended = crate::openhuman::local_ai::presets::recommend_tier(device);
tracing::warn!(
?current_tier,
?recommended,
"[local_ai] bootstrap: selected tier exceeds MVP ceiling, clamping to recommended"
);
let mut effective_config = config.clone();
crate::openhuman::local_ai::presets::apply_preset_to_config(
&mut effective_config.local_ai,
recommended,
);
return effective_config;
}
return config.clone();
}
@@ -360,51 +363,88 @@ mod tests {
assert!(!service.should_run_memory_autosummary(&config));
}
#[test]
fn bootstrap_clamps_non_mvp_tier_when_selection_missing() {
let config = Config::default();
// Default config matches Ram8To16Gb which is not MVP-allowed,
// so bootstrap clamps it to the MVP-capped recommended tier.
let device = DeviceProfile {
total_ram_bytes: 4 * 1024 * 1024 * 1024,
fn test_device(ram_gb: u64) -> DeviceProfile {
DeviceProfile {
total_ram_bytes: ram_gb * 1024 * 1024 * 1024,
cpu_count: 4,
cpu_brand: String::new(),
os_name: String::new(),
os_version: String::new(),
has_gpu: false,
gpu_description: None,
};
let effective = config_with_recommended_tier_if_unselected(&config, &device);
assert_eq!(
effective.local_ai.selected_tier.as_deref(),
Some("ram_2_4gb")
);
assert_eq!(effective.local_ai.chat_model_id, "gemma3:1b-it-qat");
}
}
#[test]
fn bootstrap_clamps_non_mvp_selected_tier() {
fn bootstrap_defaults_to_disabled_on_low_ram_device() {
let config = Config::default();
let device = test_device(4);
let effective = config_with_recommended_tier_if_unselected(&config, &device);
assert!(
!effective.local_ai.enabled,
"local_ai.enabled must default to false on <8 GB device"
);
}
#[test]
fn bootstrap_keeps_enabled_on_sufficient_ram_device() {
let config = Config::default();
let device = test_device(16);
let effective = config_with_recommended_tier_if_unselected(&config, &device);
assert!(
effective.local_ai.enabled,
"local_ai.enabled must stay true on >= 8 GB device"
);
}
#[test]
fn bootstrap_respects_explicit_tier_on_low_ram_device() {
let mut config = Config::default();
config.local_ai.selected_tier = Some("ram_2_4gb".to_string());
crate::openhuman::local_ai::presets::apply_preset_to_config(
&mut config.local_ai,
crate::openhuman::local_ai::presets::ModelTier::Ram2To4Gb,
);
let device = test_device(4);
let effective = config_with_recommended_tier_if_unselected(&config, &device);
// User explicitly chose a tier, so local_ai stays enabled.
assert!(
effective.local_ai.enabled,
"explicit tier selection must be honored even on low-RAM device"
);
}
#[test]
fn bootstrap_keeps_default_tier_when_selection_missing_and_ram_sufficient() {
// Default config already matches a preset (Ram8To16Gb) and device has
// enough RAM, so bootstrap returns it untouched (no auto-change).
let config = Config::default();
let device = test_device(8);
let effective = config_with_recommended_tier_if_unselected(&config, &device);
assert!(effective.local_ai.enabled);
assert_eq!(
effective.local_ai.chat_model_id,
config.local_ai.chat_model_id
);
}
#[test]
fn bootstrap_honors_explicit_non_mvp_tier_selection() {
let mut config = Config::default();
config.local_ai.selected_tier = Some("high".to_string());
let device = DeviceProfile {
total_ram_bytes: 4 * 1024 * 1024 * 1024,
cpu_count: 4,
cpu_brand: String::new(),
os_name: String::new(),
os_version: String::new(),
has_gpu: false,
gpu_description: None,
};
let device = test_device(8);
let effective = config_with_recommended_tier_if_unselected(&config, &device);
// "high" maps to Ram16PlusGb which is not MVP-allowed, so it gets clamped.
assert_eq!(
effective.local_ai.selected_tier.as_deref(),
Some("ram_2_4gb")
);
assert_eq!(effective.local_ai.chat_model_id, "gemma3:1b-it-qat");
// User explicitly picked a high-tier preset — honor it, no MVP clamp.
assert_eq!(effective.local_ai.selected_tier.as_deref(), Some("high"));
}
}