mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
feat(ai-panel): add chat workload and cloud model picker with slug-based lookup fix (#2152)
This commit is contained in:
@@ -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(<IntelligenceCallsTab />);
|
||||
|
||||
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(<IntelligenceCallsTab onToast={onToast} />);
|
||||
|
||||
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(<IntelligenceCallsTab onToast={onToast} />);
|
||||
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(<IntelligenceCallsTab />);
|
||||
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(<IntelligenceCallsTab />);
|
||||
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(<IntelligenceCallsTab />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-500/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">Calls</h2>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-neutral-400 max-w-xs">
|
||||
AI-assisted calls are coming soon. Stay tuned.
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* Original Calls UI — re-enable when the feature is ready
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -189,4 +229,5 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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<string, { tone: string; label: string }> = {
|
||||
};
|
||||
|
||||
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<ModelInfo[]>([]);
|
||||
const [cloudModelsLoading, setCloudModelsLoading] = useState(false);
|
||||
const [cloudModelsError, setCloudModelsError] = useState<string | null>(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 = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : cloudModelsLoading ? (
|
||||
<select
|
||||
disabled
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-400 dark:text-neutral-500 opacity-60 cursor-wait">
|
||||
<option>Loading models…</option>
|
||||
</select>
|
||||
) : cloudModelsError ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="rounded-lg border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300 font-mono break-all">
|
||||
{cloudModelsError}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModelsKey(k => k + 1)}
|
||||
className="text-xs text-primary-600 dark:text-primary-400 hover:underline">
|
||||
Retry
|
||||
</button>
|
||||
<span className="text-xs text-stone-400 dark:text-neutral-500">
|
||||
or enter model id manually:
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
) : cloudModels.length > 0 ? (
|
||||
<select
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{!model && <option value="">Select a model…</option>}
|
||||
{/* Keep existing value selectable even if the provider no longer lists it */}
|
||||
{model && !cloudModels.some(m => m.id === model) && (
|
||||
<option value={model}>{model}</option>
|
||||
)}
|
||||
{cloudModels.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{humanizeModelId(m.id)} — {m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -19,6 +19,7 @@ import AIPanel from '../AIPanel';
|
||||
|
||||
vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
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(<AIPanel />);
|
||||
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 },
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MeetingBotsCard onToast={addToast} />
|
||||
{/* <MeetingBotsCard onToast={addToast} /> */}
|
||||
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AISettings> {
|
||||
});
|
||||
|
||||
const routing: Record<WorkloadId, ProviderRef> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ModelInfo[]> {
|
||||
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) ───────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub chat_provider: Option<String>,
|
||||
pub reasoning_provider: Option<String>,
|
||||
pub agentic_provider: Option<String>,
|
||||
pub coding_provider: Option<String>,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -212,6 +212,10 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub primary_cloud: Option<String>,
|
||||
|
||||
/// Provider string for direct conversational chat (simple back-and-forth).
|
||||
#[serde(default)]
|
||||
pub chat_provider: Option<String>,
|
||||
|
||||
/// Provider string for the main reasoning / chat workload.
|
||||
#[serde(default)]
|
||||
pub reasoning_provider: Option<String>,
|
||||
@@ -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:<model>"` (including
|
||||
@@ -382,6 +386,7 @@ impl Config {
|
||||
/// for migration only.
|
||||
pub fn workload_local_model(&self, workload: &str) -> Option<String> {
|
||||
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,
|
||||
|
||||
@@ -64,6 +64,7 @@ struct ModelSettingsUpdate {
|
||||
/// `cloud_provider_set_key` — they are NOT carried here.
|
||||
cloud_providers: Option<Vec<CloudProviderUpdate>>,
|
||||
primary_cloud: Option<String>,
|
||||
chat_provider: Option<String>,
|
||||
reasoning_provider: Option<String>,
|
||||
agentic_provider: Option<String>,
|
||||
coding_provider: Option<String>,
|
||||
@@ -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<String, Value>) -> 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -86,6 +86,7 @@ struct InferenceUpdateModelSettingsParams {
|
||||
model_routes: Option<Vec<InferenceModelRouteUpdate>>,
|
||||
cloud_providers: Option<Vec<InferenceCloudProviderUpdate>>,
|
||||
primary_cloud: Option<String>,
|
||||
chat_provider: Option<String>,
|
||||
reasoning_provider: Option<String>,
|
||||
agentic_provider: Option<String>,
|
||||
coding_provider: Option<String>,
|
||||
@@ -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<String, Value>) -> 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,
|
||||
|
||||
Reference in New Issue
Block a user