diff --git a/app/src/components/intelligence/IntelligenceCallsTab.test.tsx b/app/src/components/intelligence/IntelligenceCallsTab.test.tsx index 1e0747947..2649df583 100644 --- a/app/src/components/intelligence/IntelligenceCallsTab.test.tsx +++ b/app/src/components/intelligence/IntelligenceCallsTab.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +// import { fireEvent, waitFor } from '@testing-library/react'; // re-enable with the full UI import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { closeMeetCall, joinMeetCall } from '../../services/meetCallService'; +// import { closeMeetCall, joinMeetCall } from '../../services/meetCallService'; // re-enable with the full UI import IntelligenceCallsTab from './IntelligenceCallsTab'; vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => undefined) })); @@ -11,8 +12,6 @@ vi.mock('../../services/meetCallService', () => ({ closeMeetCall: vi.fn(), })); -const VALID_URL = 'https://meet.google.com/abc-defg-hij'; - describe('IntelligenceCallsTab', () => { beforeEach(() => { vi.clearAllMocks(); @@ -22,126 +21,9 @@ describe('IntelligenceCallsTab', () => { vi.restoreAllMocks(); }); - it('renders form with URL + display name inputs and a disabled join button', () => { + it('renders coming soon placeholder', () => { render(); - - expect(screen.getByRole('heading', { name: /Join a Google Meet call/i })).toBeInTheDocument(); - const urlInput = screen.getByPlaceholderText(/meet\.google\.com/i); - expect(urlInput).toBeInTheDocument(); - // Display name has a default value, so the join button is enabled only - // once the URL field is also non-empty. With an empty URL it stays - // disabled. - expect(screen.getByRole('button', { name: /Join call/i })).toBeDisabled(); - }); - - it('calls joinMeetCall on submit and adds the result to the active-call list', async () => { - vi.mocked(joinMeetCall).mockResolvedValueOnce({ - requestId: 'req-1', - meetUrl: VALID_URL, - displayName: 'OpenHuman Agent', - windowLabel: 'meet-call-req-1', - }); - - const onToast = vi.fn(); - render(); - - fireEvent.change(screen.getByPlaceholderText(/meet\.google\.com/i), { - target: { value: VALID_URL }, - }); - fireEvent.click(screen.getByRole('button', { name: /Join call/i })); - - await waitFor(() => expect(joinMeetCall).toHaveBeenCalledTimes(1)); - expect(joinMeetCall).toHaveBeenCalledWith({ - meetUrl: VALID_URL, - displayName: 'OpenHuman Agent', - }); - - // Active call appears with a Leave button. - await screen.findByText('OpenHuman Agent'); - expect(screen.getByRole('button', { name: /Leave/i })).toBeInTheDocument(); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'success', title: 'Joining call' }) - ); - }); - - it('renders the rejection reason in the form when joinMeetCall throws', async () => { - vi.mocked(joinMeetCall).mockRejectedValueOnce(new Error('Core rejected the request')); - const onToast = vi.fn(); - - render(); - fireEvent.change(screen.getByPlaceholderText(/meet\.google\.com/i), { - target: { value: VALID_URL }, - }); - fireEvent.click(screen.getByRole('button', { name: /Join call/i })); - - await screen.findByRole('alert'); - expect(screen.getByRole('alert')).toHaveTextContent(/Core rejected the request/i); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', title: 'Could not start call' }) - ); - }); - - it('falls back to a generic error message for non-Error rejections', async () => { - // joinMeetCall throws a non-Error value (e.g. a raw string) — the - // component should still surface a sane message instead of crashing. - vi.mocked(joinMeetCall).mockRejectedValueOnce('boom'); - - render(); - fireEvent.change(screen.getByPlaceholderText(/meet\.google\.com/i), { - target: { value: VALID_URL }, - }); - fireEvent.click(screen.getByRole('button', { name: /Join call/i })); - - await screen.findByRole('alert'); - expect(screen.getByRole('alert')).toHaveTextContent(/Failed to start Meet call/i); - }); - - it('removes the call from the list when the user clicks Leave', async () => { - vi.mocked(joinMeetCall).mockResolvedValueOnce({ - requestId: 'req-2', - meetUrl: VALID_URL, - displayName: 'OpenHuman Agent', - windowLabel: 'meet-call-req-2', - }); - vi.mocked(closeMeetCall).mockResolvedValueOnce(true); - - render(); - fireEvent.change(screen.getByPlaceholderText(/meet\.google\.com/i), { - target: { value: VALID_URL }, - }); - fireEvent.click(screen.getByRole('button', { name: /Join call/i })); - - const leaveBtn = await screen.findByRole('button', { name: /Leave/i }); - fireEvent.click(leaveBtn); - - await waitFor(() => expect(closeMeetCall).toHaveBeenCalledWith('req-2')); - await waitFor(() => - expect(screen.queryByRole('button', { name: /Leave/i })).not.toBeInTheDocument() - ); - }); - - it('keeps the row when closeMeetCall returns false (window stayed open)', async () => { - vi.mocked(joinMeetCall).mockResolvedValueOnce({ - requestId: 'req-3', - meetUrl: VALID_URL, - displayName: 'OpenHuman Agent', - windowLabel: 'meet-call-req-3', - }); - vi.mocked(closeMeetCall).mockResolvedValueOnce(false); - - render(); - fireEvent.change(screen.getByPlaceholderText(/meet\.google\.com/i), { - target: { value: VALID_URL }, - }); - fireEvent.click(screen.getByRole('button', { name: /Join call/i })); - - const leaveBtn = await screen.findByRole('button', { name: /Leave/i }); - fireEvent.click(leaveBtn); - - await waitFor(() => expect(closeMeetCall).toHaveBeenCalledWith('req-3')); - // Row stays so the user can retry; the meet-call:closed event listener - // would still drop it later if the shell ends up tearing the window - // down on its own. - expect(screen.getByRole('button', { name: /Leave/i })).toBeInTheDocument(); + expect(screen.getByText('Calls')).toBeInTheDocument(); + expect(screen.getByText('Coming Soon')).toBeInTheDocument(); }); }); diff --git a/app/src/components/intelligence/IntelligenceCallsTab.tsx b/app/src/components/intelligence/IntelligenceCallsTab.tsx index 9dd499ffc..cc3801ce2 100644 --- a/app/src/components/intelligence/IntelligenceCallsTab.tsx +++ b/app/src/components/intelligence/IntelligenceCallsTab.tsx @@ -99,6 +99,46 @@ export default function IntelligenceCallsTab({ onToast }: Props) { } }; + // Suppress unused-variable warnings while the UI is hidden behind Coming Soon. + void t; + void meetUrl; + void setMeetUrl; + void displayName; + void setDisplayName; + void submitting; + void error; + void activeCalls; + void handleSubmit; + void handleClose; + void PLACEHOLDER_URL; + + return ( +
+
+ + + +
+

Calls

+

+ AI-assisted calls are coming soon. Stay tuned. +

+ + Coming Soon + +
+ ); + + /* Original Calls UI — re-enable when the feature is ready return (
@@ -189,4 +229,5 @@ export default function IntelligenceCallsTab({ onToast }: Props) { )}
); + */ } diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 0ab696d18..7fd58a898 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -19,9 +19,12 @@ import { type ProviderRef as ApiProviderRef, clearCloudProviderKey, type CloudProviderView, + flushCloudProviders, + listProviderModels, loadAISettings, loadLocalProviderSnapshot, type LocalProviderSnapshot, + type ModelInfo, saveAISettings, setCloudProviderKey, } from '../../../services/api/aiSettingsApi'; @@ -60,6 +63,7 @@ type OllamaState = 'disabled' | 'missing' | 'stopped' | 'starting' | 'running' | type OllamaModel = { id: string; sizeBytes: number; family: string }; type WorkloadId = + | 'chat' | 'reasoning' | 'agentic' | 'coding' @@ -110,6 +114,7 @@ const BUILTIN_PROVIDER_META: Record = { }; const WORKLOADS: Workload[] = [ + { id: 'chat', group: 'chat', label: 'Chat', description: 'Direct conversational back-and-forth' }, { id: 'reasoning', group: 'chat', @@ -173,6 +178,7 @@ const WORKLOADS: Workload[] = [ type AISettings = { cloudProviders: CloudProvider[]; routing: RoutingMap }; const EMPTY_ROUTING: RoutingMap = { + chat: { kind: 'openhuman' }, reasoning: { kind: 'openhuman' }, agentic: { kind: 'openhuman' }, coding: { kind: 'openhuman' }, @@ -217,6 +223,7 @@ function toPanelRoutingFromApi(api: ApiAISettings): { panel: AISettings } { // ApiProviderRef and ProviderRef share the same shape — pass through directly. const liftRef = (r: ApiProviderRef): ProviderRef => r; const routing: RoutingMap = { + chat: liftRef(api.routing.chat), reasoning: liftRef(api.routing.reasoning), agentic: liftRef(api.routing.agentic), coding: liftRef(api.routing.coding), @@ -240,6 +247,7 @@ function toApiSettings(panel: AISettings): ApiAISettings { has_api_key: p.maskedKey.startsWith('••••'), })), routing: { + chat: panel.routing.chat, reasoning: panel.routing.reasoning, agentic: panel.routing.agentic, coding: panel.routing.coding, @@ -275,9 +283,36 @@ function useAISettings() { }, []); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect void reload(); }, [reload]); + // Eagerly persist user-configured cloud providers whenever they diverge from + // the saved snapshot so listProviderModels can resolve by slug immediately + // after a provider is added, before the global Save. Reserved slugs + // ("openhuman", "ollama", "cloud", "pid") are built-ins that Rust rejects as + // custom providers — filter them out before flushing. + useEffect(() => { + if (loading) return; + const userProviders = draft.cloudProviders.filter( + p => !['', 'cloud', 'openhuman', 'ollama', 'pid'].includes(p.slug) + ); + const savedUserProviders = saved.cloudProviders.filter( + p => !['', 'cloud', 'openhuman', 'ollama', 'pid'].includes(p.slug) + ); + if (JSON.stringify(userProviders) === JSON.stringify(savedUserProviders)) return; + const wire = userProviders.map(p => ({ + id: p.id, + slug: p.slug, + label: p.label, + endpoint: p.endpoint, + auth_style: p.authStyle, + })); + flushCloudProviders(wire).catch(err => + console.warn('[ai-settings] eager cloud_providers flush failed:', err) + ); + }, [draft.cloudProviders, loading, saved.cloudProviders]); + const isDirty = JSON.stringify(saved) !== JSON.stringify(draft); const save = useCallback(async () => { @@ -315,6 +350,7 @@ function useOllamaStatus() { }, []); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect void refresh(); const id = window.setInterval(() => void refresh(), 5000); return () => window.clearInterval(id); @@ -768,6 +804,7 @@ const BackgroundLoopControls = ({ }, [commitSettings]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect void refresh(); }, [refresh]); @@ -1484,6 +1521,10 @@ interface CustomRoutingDialogProps { type CustomDialogSource = { kind: 'cloud'; providerSlug: string } | { kind: 'local' }; +function humanizeModelId(id: string): string { + return id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} + const CustomRoutingDialog = ({ workload, initial, @@ -1519,10 +1560,56 @@ const CustomRoutingDialog = ({ } return localModels[0]?.id ?? ''; }); + const [cloudModels, setCloudModels] = useState([]); + const [cloudModelsLoading, setCloudModelsLoading] = useState(false); + const [cloudModelsError, setCloudModelsError] = useState(null); + const [modelsKey, setModelsKey] = useState(0); const selectedCloud = source?.kind === 'cloud' ? customCloud.find(c => c.slug === source.providerSlug) : undefined; + // Fetch available models whenever the selected cloud provider changes. + const selectedSlug = source?.kind === 'cloud' ? source.providerSlug : null; + useEffect(() => { + if (!selectedSlug) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setCloudModels([]); + setCloudModelsError(null); + return; + } + const provider = customCloud.find(c => c.slug === selectedSlug); + if (!provider) { + setCloudModels([]); + setCloudModelsError(null); + return; + } + let active = true; + setCloudModelsLoading(true); + setCloudModels([]); + setCloudModelsError(null); + console.debug('[ai-settings] fetching models for provider', provider.slug); + listProviderModels(provider.slug) + .then(ms => { + if (!active) return; + console.debug('[ai-settings] fetched', ms.length, 'models for', provider.slug); + setCloudModels(ms); + setCloudModelsLoading(false); + }) + .catch((err: unknown) => { + if (!active) return; + const msg = err instanceof Error ? err.message : String(err); + console.error('[ai-settings] listProviderModels failed for', provider.slug, ':', msg); + setCloudModelsError(msg); + setCloudModelsLoading(false); + }); + return () => { + active = false; + }; + // customCloud is stable for the dialog's lifetime (prop doesn't change mid-open) + // modelsKey is the manual retry trigger + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedSlug, modelsKey]); + const canSave = source !== null && model.trim().length > 0; const handleSave = () => { @@ -1619,6 +1706,52 @@ const CustomRoutingDialog = ({ ))} + ) : cloudModelsLoading ? ( + + ) : cloudModelsError ? ( +
+
+ {cloudModelsError} +
+
+ + + or enter model id manually: + +
+ setModel(e.target.value)} + placeholder={selectedCloud ? `${selectedCloud.slug} model id` : 'model-id'} + className="w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + /> +
+ ) : cloudModels.length > 0 ? ( + ) : ( ({ ALL_WORKLOADS: [ + 'chat', 'reasoning', 'agentic', 'coding', @@ -41,6 +42,7 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({ : `${r.providerSlug}:${r.model}` ), localProvider: { download: vi.fn(), applyPreset: vi.fn() }, + flushCloudProviders: vi.fn().mockResolvedValue(undefined), })); vi.mock('../../hooks/useSettingsNavigation', () => ({ @@ -75,6 +77,7 @@ const baseSettings = { }, ], routing: { + chat: { kind: 'openhuman' as const }, reasoning: { kind: 'openhuman' as const }, agentic: { kind: 'openhuman' as const }, coding: { kind: 'openhuman' as const }, @@ -199,10 +202,11 @@ describe('AIPanel', () => { await waitFor(() => expect(screen.getAllByText(/OpenHuman/i).length).toBeGreaterThan(0)); }); - it('renders all eight workload labels', async () => { + it('renders all nine workload labels', async () => { renderWithProviders(); - await waitFor(() => expect(screen.getByText('Reasoning')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Chat')).toBeInTheDocument()); for (const label of [ + 'Chat', 'Reasoning', 'Agentic', 'Coding', @@ -231,6 +235,7 @@ describe('AIPanel', () => { }, ], routing: { + chat: { kind: 'openhuman' as const }, reasoning: { kind: 'cloud' as const, providerSlug: 'anthropic', @@ -255,9 +260,12 @@ describe('AIPanel', () => { await waitFor(() => expect(screen.getAllByText(/Anthropic/i).length).toBeGreaterThan(0)); // Trigger a routing change so the SaveBar appears, then save. - // Click the "Default" button on the Reasoning row to change routing. - const defaultButtons = screen.getAllByText('Default'); - fireEvent.click(defaultButtons[0]); + // Click the "Default" button specifically on the Reasoning row (which is + // currently set to custom cloud routing) to switch it back to openhuman. + const reasoningRow = screen + .getByText('Reasoning') + .closest('[class*="flex items-center justify-between"]'); + fireEvent.click(within(reasoningRow as HTMLElement).getByText('Default')); // SaveBar should appear. await waitFor(() => expect(screen.getByText(/unsaved change/i)).toBeInTheDocument()); @@ -330,6 +338,7 @@ describe('AIPanel', () => { }, ], routing: { + chat: { kind: 'openhuman' as const }, reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' }, agentic: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o-mini' }, coding: { kind: 'openhuman' as const }, diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index db7f1d717..16a8a020c 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -12,7 +12,7 @@ import { ToastContainer } from '../components/intelligence/Toast'; import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal'; import CreateSkillModal from '../components/skills/CreateSkillModal'; import InstallSkillDialog from '../components/skills/InstallSkillDialog'; -import MeetingBotsCard from '../components/skills/MeetingBotsCard'; +// import MeetingBotsCard from '../components/skills/MeetingBotsCard'; import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal'; import UnifiedSkillCard from '../components/skills/SkillCard'; import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories'; @@ -838,7 +838,7 @@ export default function Skills() {
)} - + {/* */}
diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index 673e27a2b..adfcedc9a 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type AISettings, clearCloudProviderKey, + flushCloudProviders, listProviderModels, loadAISettings, loadLocalProviderSnapshot, @@ -456,6 +457,7 @@ describe('saveAISettings', () => { }, ], routing: { + chat: { kind: 'openhuman' }, reasoning: { kind: 'cloud', providerSlug: 'openai', model: 'gpt-4o' }, agentic: { kind: 'openhuman' }, coding: { kind: 'openhuman' }, @@ -515,6 +517,7 @@ describe('saveAISettings', () => { const prev: AISettings = { cloudProviders: [], routing: { + chat: { kind: 'openhuman' }, reasoning: { kind: 'openhuman' }, agentic: { kind: 'openhuman' }, coding: { kind: 'openhuman' }, @@ -612,7 +615,7 @@ describe('listProviderModels', () => { mockIsTauri.mockReturnValue(true); }); - it('dispatches openhuman.inference_list_models with provider_id and returns models', async () => { + it('dispatches openhuman.inference_list_models with provider slug and returns models', async () => { mockCallCoreRpc.mockResolvedValue({ result: { models: [ @@ -622,11 +625,11 @@ describe('listProviderModels', () => { }, }); - const models = await listProviderModels('p_openai_1'); + const models = await listProviderModels('openai'); expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.inference_list_models', - params: { provider_id: 'p_openai_1' }, + params: { provider_id: 'openai' }, }); expect(models).toHaveLength(2); expect(models[0].id).toBe('gpt-4o'); @@ -636,25 +639,53 @@ describe('listProviderModels', () => { it('returns empty array when not running in Tauri', async () => { mockIsTauri.mockReturnValue(false); - const models = await listProviderModels('p_openai_1'); + const models = await listProviderModels('openai'); expect(models).toEqual([]); expect(mockCallCoreRpc).not.toHaveBeenCalled(); }); - it('returns empty array on RPC error (graceful degradation)', async () => { + it('throws on RPC error so callers can surface retry UI', async () => { mockCallCoreRpc.mockRejectedValue(new Error('network error')); - const models = await listProviderModels('p_openai_1'); - - expect(models).toEqual([]); + await expect(listProviderModels('openai')).rejects.toThrow('network error'); }); it('returns empty array when result has no models field', async () => { mockCallCoreRpc.mockResolvedValue({ result: {} }); - const models = await listProviderModels('p_openai_1'); + const models = await listProviderModels('openai'); expect(models).toEqual([]); }); }); + +// ─── flushCloudProviders ────────────────────────────────────────────────────── + +describe('flushCloudProviders', () => { + beforeEach(() => { + mockOpenhumanUpdateModelSettings.mockReset(); + mockIsTauri.mockReturnValue(true); + }); + + it('calls update_model_settings with the cloud_providers array', async () => { + mockOpenhumanUpdateModelSettings.mockResolvedValue({}); + const providers = [ + { + id: 'p_openai_1', + slug: 'openai', + label: 'OpenAI', + endpoint: 'https://api.openai.com/v1', + auth_style: 'bearer' as const, + }, + ]; + await flushCloudProviders(providers); + expect(mockOpenhumanUpdateModelSettings).toHaveBeenCalledWith({ cloud_providers: providers }); + }); + + it('no-ops when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + await flushCloudProviders([]); + expect(mockOpenhumanUpdateModelSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index bf5b7f544..2911bc55a 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -45,6 +45,7 @@ import { // ─── Domain types — what the AIPanel consumes ────────────────────────────── export type WorkloadId = + | 'chat' | 'reasoning' | 'agentic' | 'coding' @@ -54,7 +55,7 @@ export type WorkloadId = | 'learning' | 'subconscious'; -export const CHAT_WORKLOADS: WorkloadId[] = ['reasoning', 'agentic', 'coding']; +export const CHAT_WORKLOADS: WorkloadId[] = ['chat', 'reasoning', 'agentic', 'coding']; export const BACKGROUND_WORKLOADS: WorkloadId[] = [ 'memory', 'embeddings', @@ -173,6 +174,7 @@ export async function loadAISettings(): Promise { }); const routing: Record = { + chat: parseProviderString(config.chat_provider), reasoning: parseProviderString(config.reasoning_provider), agentic: parseProviderString(config.agentic_provider), coding: parseProviderString(config.coding_provider), @@ -260,23 +262,36 @@ export async function clearCloudProviderKey(slug: string): Promise { await authRemoveProviderCredentials({ provider: authKeyForSlug(slug), profile: 'default' }); } +/** + * Eagerly write the cloud_providers list to the core config. + * + * Called immediately when providers are added/edited/removed so that + * `listProviderModels` can resolve the provider by id without waiting for + * the user to click the global Save button. API keys are NOT included here + * (they're written via `setCloudProviderKey` on their own path). + */ +export async function flushCloudProviders(providers: CloudProviderCreds[]): Promise { + if (!isTauri()) return; + await openhumanUpdateModelSettings({ cloud_providers: providers }); +} + /** * Fetch the model list from a configured cloud provider's /models API. - * Returns an empty array on error (callers should handle gracefully). + * `providerId` may be either the provider's opaque id or its slug — Rust + * accepts both. Prefer passing the slug so lookup works before the provider + * config has been persisted to disk (i.e. before the user clicks Save). + * Throws on error so callers can surface retry UI. Returns [] when not + * running in Tauri (browser dev mode has no RPC bridge). */ export async function listProviderModels(providerId: string): Promise { if (!isTauri()) { return []; } - try { - const res = await callCoreRpc<{ result: { models: ModelInfo[] } }>({ - method: 'openhuman.inference_list_models', - params: { provider_id: providerId }, - }); - return res?.result?.models ?? []; - } catch { - return []; - } + const res = await callCoreRpc<{ result: { models: ModelInfo[] } }>({ + method: 'openhuman.inference_list_models', + params: { provider_id: providerId }, + }); + return res?.result?.models ?? []; } // ─── Local provider façade (Ollama install / detect / model manage) ─────── diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 625d7bb8f..c4bced0a0 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -73,6 +73,7 @@ export interface ModelSettingsUpdate { /** @deprecated No longer used — slug-based routing replaces primary_cloud. */ primary_cloud?: string | null; /** Per-workload provider strings — see Rust `providers::factory` grammar. */ + chat_provider?: string | null; reasoning_provider?: string | null; agentic_provider?: string | null; coding_provider?: string | null; @@ -210,6 +211,7 @@ export interface ClientConfig { /** Id of the `cloud_providers` entry resolved by the `"cloud"` sentinel. */ primary_cloud: string | null; /** Per-workload provider strings (e.g. `"cloud"`, `"ollama:llama3.1:8b"`, `"openai:gpt-4o"`). */ + chat_provider: string | null; reasoning_provider: string | null; agentic_provider: string | null; coding_provider: string | null; diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index e6593419f..126b0ad68 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -251,6 +251,7 @@ pub fn client_config_json(config: &Config) -> serde_json::Value { "model_routes": model_routes, "cloud_providers": cloud_providers, "primary_cloud": config.primary_cloud, + "chat_provider": config.chat_provider, "reasoning_provider": config.reasoning_provider, "agentic_provider": config.agentic_provider, "coding_provider": config.coding_provider, @@ -297,6 +298,7 @@ pub struct ModelSettingsPatch { /// Id of the `cloud_providers` entry used when a workload routes to /// `"cloud"`. Empty string clears (factory falls back to OpenHuman). pub primary_cloud: Option, + pub chat_provider: Option, pub reasoning_provider: Option, pub agentic_provider: Option, pub coding_provider: Option, @@ -465,6 +467,9 @@ pub async fn apply_model_settings( Some(t.to_string()) } }; + if let Some(s) = update.chat_provider { + config.chat_provider = normalise_provider(s); + } if let Some(s) = update.reasoning_provider { config.reasoning_provider = normalise_provider(s); } diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 36cdd2b3a..4bcd0f0ff 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -212,6 +212,10 @@ pub struct Config { #[serde(default)] pub primary_cloud: Option, + /// Provider string for direct conversational chat (simple back-and-forth). + #[serde(default)] + pub chat_provider: Option, + /// Provider string for the main reasoning / chat workload. #[serde(default)] pub reasoning_provider: Option, @@ -371,7 +375,7 @@ impl Config { /// when the workload is routed to Ollama. /// /// Recognised workload names: - /// `"reasoning"`, `"agentic"`, `"coding"`, `"memory"`, `"embeddings"`, + /// `"chat"`, `"reasoning"`, `"agentic"`, `"coding"`, `"memory"`, `"embeddings"`, /// `"heartbeat"`, `"learning"`, `"subconscious"`. /// /// Returns `None` when the provider isn't `"ollama:"` (including @@ -382,6 +386,7 @@ impl Config { /// for migration only. pub fn workload_local_model(&self, workload: &str) -> Option { let raw = match workload { + "chat" => self.chat_provider.as_deref(), "reasoning" => self.reasoning_provider.as_deref(), "agentic" => self.agentic_provider.as_deref(), "coding" => self.coding_provider.as_deref(), @@ -532,6 +537,7 @@ impl Default for Config { local_ai: LocalAiConfig::default(), cloud_providers: Vec::new(), primary_cloud: None, + chat_provider: None, reasoning_provider: None, agentic_provider: None, coding_provider: None, diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index 7713d8e27..af1869436 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -64,6 +64,7 @@ struct ModelSettingsUpdate { /// `cloud_provider_set_key` — they are NOT carried here. cloud_providers: Option>, primary_cloud: Option, + chat_provider: Option, reasoning_provider: Option, agentic_provider: Option, coding_provider: Option, @@ -422,6 +423,7 @@ pub fn schemas(function: &str) -> ControllerSchema { required: false, }, optional_string("primary_cloud", "id of the cloud_providers entry used when a workload routes to 'cloud'. Empty string clears."), + optional_string("chat_provider", "Provider string for direct conversational chat workloads."), optional_string("reasoning_provider", "Provider string for the main reasoning workload (e.g. 'cloud', 'ollama:llama3.1:8b', 'openai:gpt-4o')."), optional_string("agentic_provider", "Provider string for sub-agent / tool-loop workloads."), optional_string("coding_provider", "Provider string for code-generation workloads."), @@ -966,6 +968,7 @@ fn handle_update_model_settings(params: Map) -> ControllerFuture }) .transpose()?, primary_cloud: update.primary_cloud, + chat_provider: update.chat_provider, reasoning_provider: update.reasoning_provider, agentic_provider: update.agentic_provider, coding_provider: update.coding_provider, diff --git a/src/openhuman/inference/provider/ops.rs b/src/openhuman/inference/provider/ops.rs index cb3533de8..11100b1e2 100644 --- a/src/openhuman/inference/provider/ops.rs +++ b/src/openhuman/inference/provider/ops.rs @@ -42,9 +42,9 @@ pub async fn list_configured_models( let entry = config .cloud_providers .iter() - .find(|e| e.id == provider_id) + .find(|e| e.id == provider_id || e.slug == provider_id) .cloned() - .ok_or_else(|| format!("no cloud provider with id '{}' found", provider_id))?; + .ok_or_else(|| format!("no cloud provider with id or slug '{}' found", provider_id))?; let base = entry.endpoint.trim_end_matches('/'); let models_url = format!("{}/models", base); @@ -651,6 +651,45 @@ pub fn canonical_china_provider_name(_name: &str) -> Option<&'static str> { mod tests { use super::*; + #[test] + fn list_configured_models_accepts_slug() { + // list_configured_models should find a provider by slug when the caller + // passes a slug instead of the opaque random id. This lets the frontend + // call the RPC before the provider config has been persisted (where only + // the slug is stable). + use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds}; + use crate::openhuman::config::Config; + + let mut config = Config::default(); + config.cloud_providers.push(CloudProviderCreds { + id: "p_openai_xyz99".to_string(), + slug: "openai".to_string(), + label: "OpenAI".to_string(), + endpoint: "https://api.openai.com/v1".to_string(), + auth_style: AuthStyle::Bearer, + legacy_type: None, + default_model: None, + }); + + // The find predicate must match on slug. + let found_by_slug = config + .cloud_providers + .iter() + .find(|e| e.id == "openai" || e.slug == "openai"); + assert!( + found_by_slug.is_some(), + "slug lookup must find the provider" + ); + assert_eq!(found_by_slug.unwrap().id, "p_openai_xyz99"); + + // The find predicate must still match on id. + let found_by_id = config + .cloud_providers + .iter() + .find(|e| e.id == "p_openai_xyz99" || e.slug == "p_openai_xyz99"); + assert!(found_by_id.is_some(), "id lookup must still work"); + } + #[test] fn factory_backend() { assert!(create_backend_inference_provider( diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs index 7db5f85f7..c63568ef3 100644 --- a/src/openhuman/inference/schemas.rs +++ b/src/openhuman/inference/schemas.rs @@ -86,6 +86,7 @@ struct InferenceUpdateModelSettingsParams { model_routes: Option>, cloud_providers: Option>, primary_cloud: Option, + chat_provider: Option, reasoning_provider: Option, agentic_provider: Option, coding_provider: Option, @@ -239,6 +240,7 @@ pub fn schemas(function: &str) -> ControllerSchema { optional_json("model_routes", "Optional full replacement for legacy model routes."), optional_json("cloud_providers", "Optional full replacement for configured cloud providers."), optional_string("primary_cloud", "Optional primary cloud provider id."), + optional_string("chat_provider", "Optional chat workload provider string."), optional_string("reasoning_provider", "Optional reasoning workload provider string."), optional_string("agentic_provider", "Optional agentic workload provider string."), optional_string("coding_provider", "Optional coding workload provider string."), @@ -544,6 +546,7 @@ fn handle_inference_update_model_settings(params: Map) -> Control }) .transpose()?, primary_cloud: update.primary_cloud, + chat_provider: update.chat_provider, reasoning_provider: update.reasoning_provider, agentic_provider: update.agentic_provider, coding_provider: update.coding_provider,