Extract AI settings runtime owners

This commit is contained in:
rcourtman
2026-03-20 18:19:50 +00:00
parent 56bca4001f
commit 8916fa0a5a
9 changed files with 1204 additions and 1509 deletions
@@ -26,28 +26,31 @@ work extends shared components instead of creating new local variants.
4. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
5. `frontend-modern/src/components/Settings/APIAccessPanel.tsx`
6. `frontend-modern/src/components/Settings/AISettings.tsx`
7. `frontend-modern/src/components/Settings/AuditLogPanel.tsx`
8. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx`
9. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
10. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx`
11. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx`
12. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
13. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
14. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`
15. `frontend-modern/src/components/Settings/SettingsPageShell.tsx`
16. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
17. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
18. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
19. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
20. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
21. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
22. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
23. `frontend-modern/src/features/`
24. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
25. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
26. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
27. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
28. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
7. `frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx`
8. `frontend-modern/src/components/Settings/AISettingsDialogs.tsx`
9. `frontend-modern/src/components/Settings/aiSettingsModel.ts`
10. `frontend-modern/src/components/Settings/AuditLogPanel.tsx`
11. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx`
12. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
13. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx`
14. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx`
15. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
16. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
17. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`
18. `frontend-modern/src/components/Settings/SettingsPageShell.tsx`
19. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
20. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
21. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
22. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
23. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
24. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
25. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
26. `frontend-modern/src/features/`
27. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
28. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
29. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
30. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
31. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
## Shared Boundaries
@@ -256,6 +259,12 @@ well: `frontend-modern/src/components/Settings/settingsHeaderMeta.ts` and
representative top-level panels such as
`frontend-modern/src/components/Settings/APIAccessPanel.tsx`,
`frontend-modern/src/components/Settings/AISettings.tsx`,
`frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx`,
`frontend-modern/src/components/Settings/AISettingsDialogs.tsx`, and
`frontend-modern/src/components/Settings/aiSettingsModel.ts` now also define
the canonical AI settings runtime boundary: `AISettings.tsx` is the shell and
controller, while provider configuration, modal flows, and provider metadata
must extend those extracted owners instead of being re-inlined into the shell.
`frontend-modern/src/components/Settings/AuditLogPanel.tsx`,
`frontend-modern/src/components/Settings/AuditWebhookPanel.tsx`,
`frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`,
@@ -1579,7 +1579,10 @@
"frontend-modern/src/components/shared/"
],
"owned_files": [
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
"frontend-modern/src/components/Settings/AISettings.tsx",
"frontend-modern/src/components/Settings/AISettingsDialogs.tsx",
"frontend-modern/src/components/Settings/aiSettingsModel.ts",
"frontend-modern/src/components/Settings/APIAccessPanel.tsx",
"frontend-modern/src/components/Settings/AuditLogPanel.tsx",
"frontend-modern/src/components/Settings/AuditWebhookPanel.tsx",
@@ -1631,7 +1634,10 @@
"label": "settings shell framing proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
"frontend-modern/src/components/Settings/AISettings.tsx",
"frontend-modern/src/components/Settings/AISettingsDialogs.tsx",
"frontend-modern/src/components/Settings/aiSettingsModel.ts",
"frontend-modern/src/components/Settings/APIAccessPanel.tsx",
"frontend-modern/src/components/Settings/AuditLogPanel.tsx",
"frontend-modern/src/components/Settings/AuditWebhookPanel.tsx",
@@ -0,0 +1,264 @@
import { For, Show, type Accessor, type Component, type Setter } from 'solid-js';
import type { SetStoreFunction } from 'solid-js/store';
import { HelpIcon } from '@/components/shared/HelpIcon';
import { controlClass } from '@/components/shared/Form';
import { getAIProviderHealthPresentation } from '@/utils/aiProviderHealthPresentation';
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
import { getAIProviderTestResultTextClass } from '@/utils/aiSettingsPresentation';
import type { AIProvider, AISettings as AISettingsType } from '@/types/ai';
import {
AI_PROVIDER_CONFIGS,
AI_PROVIDERS,
type AIProviderCredentialsFormState,
type ProviderHealthState,
type ProviderTestResult,
isAIProviderConfigured,
} from '@/components/Settings/aiSettingsModel';
export interface AIProviderConfigurationSectionProps {
settings: Accessor<AISettingsType | null>;
form: AIProviderCredentialsFormState;
setForm: SetStoreFunction<AIProviderCredentialsFormState>;
expandedProviders: Accessor<Set<AIProvider>>;
setExpandedProviders: Setter<Set<AIProvider>>;
providerHealth: Record<AIProvider, ProviderHealthState>;
preflightRunning: Accessor<boolean>;
preflightLastCheckedAt: Accessor<number | null>;
providerIssueCount: Accessor<number>;
testingProvider: Accessor<AIProvider | null>;
providerTestResult: Accessor<ProviderTestResult | null>;
saving: Accessor<boolean>;
runProviderPreflight: () => Promise<void>;
handleTestProvider: (provider: AIProvider) => Promise<void>;
handleClearProvider: (provider: AIProvider) => Promise<void>;
}
export const AIProviderConfigurationSection: Component<AIProviderConfigurationSectionProps> = (
props,
) => {
const toggleProvider = (provider: AIProvider) => {
const next = new Set(props.expandedProviders());
if (next.has(provider)) {
next.delete(provider);
} else {
next.add(provider);
}
props.setExpandedProviders(next);
};
const isConfigured = (provider: AIProvider) => isAIProviderConfigured(provider, props.settings());
const providerIssueProviders = () =>
AI_PROVIDERS.filter((provider) => props.providerHealth[provider].status === 'error');
return (
<div class="p-5 rounded-md border border-border bg-surface-alt">
<div class="mb-3 space-y-1.5">
<div class="flex items-center justify-between gap-2">
<h4 class="font-medium text-base-content flex items-center gap-2">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Provider Configuration
</h4>
<button
type="button"
onClick={() => void props.runProviderPreflight()}
disabled={props.preflightRunning() || props.saving()}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-3 py-2 text-sm bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
>
{props.preflightRunning() ? 'Checking...' : 'Run Preflight'}
</button>
</div>
<p class="text-xs text-muted mt-1">
Configure API keys for each provider you want to use. Models from all configured providers
will appear in the model selectors.
</p>
<Show when={props.preflightLastCheckedAt()}>
<p class="text-[11px] text-muted">
Last checked: {new Date(props.preflightLastCheckedAt()!).toLocaleTimeString()}
</p>
</Show>
<Show when={props.providerIssueCount() > 0}>
<div class="rounded border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900 px-2 py-1.5">
<p class="text-xs text-red-700 dark:text-red-300">
{props.providerIssueCount()} provider{props.providerIssueCount() === 1 ? '' : 's'}{' '}
configured but currently not usable.
</p>
<For each={providerIssueProviders()}>
{(provider) => (
<p class="text-[11px] text-red-600 dark:text-red-300">
<span class="font-medium">{getAIProviderDisplayName(provider) || provider}:</span>{' '}
{props.providerHealth[provider].message}
</p>
)}
</For>
</div>
</Show>
</div>
<div class="space-y-2">
<For each={AI_PROVIDER_CONFIGS}>
{(config) => {
const configured = () => isConfigured(config.provider);
const expanded = () => props.expandedProviders().has(config.provider);
const health = () => props.providerHealth[config.provider];
const testResult = () =>
props.providerTestResult()?.provider === config.provider
? props.providerTestResult()
: null;
return (
<div
class={`border rounded-md overflow-hidden ${configured() ? 'border-green-300 dark:border-green-700' : 'border-border'}`}
>
<button
type="button"
class="w-full min-h-10 sm:min-h-9 px-3 py-2.5 flex items-center justify-between bg-surface hover:bg-surface-hover transition-colors"
onClick={() => toggleProvider(config.provider)}
>
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{config.title}</span>
<Show when={configured()}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
{config.configuredLabel}
</span>
</Show>
<Show when={configured()}>
<span
class={`px-1.5 py-0.5 text-[10px] font-semibold rounded ${getAIProviderHealthPresentation(health().status).badgeClass}`}
>
{getAIProviderHealthPresentation(health().status).label}
</span>
</Show>
</div>
<svg
class={`w-4 h-4 transition-transform ${expanded() ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Show when={expanded()}>
<div class="px-3 py-3 bg-surface-alt border-t border-border space-y-2">
<Show when={config.extraField}>
{(extraField) => (
<div class="space-y-1">
<label class="text-xs text-muted inline-flex items-center gap-1">
{extraField().label}
<Show when={extraField().helpContentId}>
<HelpIcon contentId={extraField().helpContentId!} size="xs" />
</Show>
</label>
<input
type="url"
value={props.form[extraField().inputField]}
onInput={(event) =>
props.setForm(extraField().inputField, event.currentTarget.value)
}
placeholder={extraField().placeholder}
class={controlClass()}
disabled={props.saving()}
/>
</div>
)}
</Show>
<Show when={config.provider === 'ollama'}>
<label class="text-xs text-muted inline-flex items-center gap-1">
Server URL
<HelpIcon contentId="ai.ollama.baseUrl" size="xs" />
</label>
</Show>
<input
type={config.inputType}
value={props.form[config.inputField]}
onInput={(event) =>
props.setForm(config.inputField, event.currentTarget.value)
}
placeholder={
configured() && config.configuredPlaceholder
? config.configuredPlaceholder
: config.placeholder
}
class={controlClass()}
disabled={props.saving()}
/>
<Show when={config.helperText}>
<p class="text-xs text-slate-500">{config.helperText}</p>
</Show>
<div class="flex items-center justify-between">
<p class="text-xs text-slate-500">
<a
href={config.actionLinkHref}
target="_blank"
rel="noopener"
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-1 py-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{config.actionLinkLabel}
</a>
<Show when={config.actionLinkSuffix}>
<span class="text-slate-400">{config.actionLinkSuffix}</span>
</Show>
</p>
<Show when={configured()}>
<div class="flex gap-1">
<button
type="button"
onClick={() => void props.handleTestProvider(config.provider)}
disabled={props.testingProvider() === config.provider || props.saving()}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-3 py-2 text-sm bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
>
{props.testingProvider() === config.provider ? 'Testing...' : 'Test'}
</button>
<button
type="button"
onClick={() => void props.handleClearProvider(config.provider)}
disabled={props.saving()}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-3 py-2 text-sm bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800 disabled:opacity-50"
title={config.clearTitle}
>
Clear
</button>
</div>
</Show>
</div>
<Show when={testResult()}>
<p
class={`text-xs ${getAIProviderTestResultTextClass(Boolean(testResult()?.success))}`}
>
{testResult()?.message}
</p>
</Show>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
);
};
export default AIProviderConfigurationSection;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
import { For, Show, type Accessor, type Component, type Setter } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog';
import { SelectionCardGroup } from '@/components/shared/SelectionCardGroup';
import { getAISessionDiffStatusPresentation } from '@/utils/aiSessionDiffPresentation';
import type { FileChange } from '@/api/aiChat';
import type { AIProvider } from '@/types/ai';
import {
AI_SETUP_PROVIDER_OPTIONS,
getAIProviderConfig,
} from '@/components/Settings/aiSettingsModel';
export interface AISettingsDialogsProps {
showDiffModal: Accessor<boolean>;
setShowDiffModal: Setter<boolean>;
diffFiles: Accessor<FileChange[]>;
diffSummary: Accessor<string>;
diffSessionLabel: Accessor<string>;
formatDiffStats: (change: FileChange) => string;
showSetupModal: Accessor<boolean>;
setupProvider: Accessor<AIProvider>;
setSetupProvider: Setter<AIProvider>;
setupApiKey: Accessor<string>;
setSetupApiKey: Setter<string>;
setupOllamaUrl: Accessor<string>;
setSetupOllamaUrl: Setter<string>;
setupSaving: Accessor<boolean>;
handleCloseSetupModal: () => void;
handleSetupSubmit: () => Promise<void>;
}
export const AISettingsDialogs: Component<AISettingsDialogsProps> = (props) => {
const setupProviderConfig = () => getAIProviderConfig(props.setupProvider());
return (
<>
<Show when={props.showDiffModal()}>
<Dialog
isOpen={true}
onClose={() => props.setShowDiffModal(false)}
panelClass="max-w-2xl"
ariaLabel="Session file changes"
>
<div class="w-full overflow-hidden">
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-border">
<div>
<h3 class="text-lg font-semibold text-base-content">Session File Changes</h3>
<p class="text-xs text-muted">{props.diffSessionLabel() || 'Selected session'}</p>
</div>
<button
type="button"
class="text-sm text-muted hover:text-base-content"
onClick={() => props.setShowDiffModal(false)}
>
Close
</button>
</div>
<div class="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
<Show when={props.diffSummary()}>
<div class="rounded-md border border-border bg-surface-alt p-3">
<p class="text-xs font-semibold text-base-content">Summary</p>
<p class="text-xs text-muted mt-1 whitespace-pre-wrap">{props.diffSummary()}</p>
</div>
</Show>
<div class="space-y-2">
<For each={props.diffFiles()}>
{(file) => {
const diffStatus = getAISessionDiffStatusPresentation(file.status);
return (
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between rounded-md border border-border px-3 py-2 text-xs">
<div class="flex items-center gap-2 min-w-0">
<span
class={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase ${diffStatus.badgeClasses}`}
>
{diffStatus.label}
</span>
<span class="text-base-content truncate">{file.path}</span>
</div>
<span class="text-muted sm:flex-shrink-0">
{props.formatDiffStats(file)}
</span>
</div>
);
}}
</For>
</div>
</div>
</div>
</Dialog>
</Show>
<Show when={props.showSetupModal()}>
<Dialog
isOpen={true}
onClose={props.handleCloseSetupModal}
panelClass="max-w-md"
closeOnBackdrop={false}
ariaLabel="Set up Pulse Assistant"
>
<div class="w-full overflow-hidden">
<div class="bg-blue-600 px-6 py-4">
<h3 class="text-lg font-semibold text-white">Set Up Pulse Assistant</h3>
<p class="text-blue-100 text-sm mt-1">Choose a provider to get started</p>
</div>
<div class="p-6 space-y-4">
<SelectionCardGroup
options={AI_SETUP_PROVIDER_OPTIONS}
value={props.setupProvider()}
onChange={props.setSetupProvider}
variant="compact"
/>
<Show
when={props.setupProvider() === 'ollama'}
fallback={
<div>
<label class="block text-sm font-medium text-base-content mb-1.5">
{setupProviderConfig().title} API Key
</label>
<input
type="password"
value={props.setupApiKey()}
onInput={(event) => props.setSetupApiKey(event.currentTarget.value)}
placeholder={setupProviderConfig().placeholder}
class="w-full px-3 py-2 border border-border rounded-md bg-surface focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p class="text-xs text-slate-500 mt-1.5">
<a
href={setupProviderConfig().actionLinkHref}
target="_blank"
rel="noopener"
class="text-blue-600 hover:underline"
>
Get your API key →
</a>
</p>
</div>
}
>
<div>
<label class="block text-sm font-medium text-base-content mb-1.5">
Ollama Server URL
</label>
<input
type="url"
value={props.setupOllamaUrl()}
onInput={(event) => props.setSetupOllamaUrl(event.currentTarget.value)}
placeholder="http://localhost:11434"
class="w-full px-3 py-2 border border-border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p class="text-xs text-slate-500 mt-1.5">
Ollama runs locally - no API key needed
</p>
</div>
</Show>
</div>
<div class="px-6 py-4 bg-surface-alt border-t border-border flex justify-end gap-3">
<button
type="button"
onClick={props.handleCloseSetupModal}
class="px-4 py-2 text-base-content hover:bg-surface-hover rounded-md"
disabled={props.setupSaving()}
>
Cancel
</button>
<button
type="button"
onClick={() => void props.handleSetupSubmit()}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
disabled={
props.setupSaving() ||
(props.setupProvider() !== 'ollama' && !props.setupApiKey().trim()) ||
(props.setupProvider() === 'ollama' && !props.setupOllamaUrl().trim())
}
>
{props.setupSaving() && (
<span class="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
Enable Pulse Assistant
</button>
</div>
</div>
</Dialog>
</Show>
</>
);
};
export default AISettingsDialogs;
@@ -26,7 +26,10 @@ import alertThresholdsPresentationSource from '@/utils/alertThresholdsPresentati
import alertThresholdsSectionPresentationSource from '@/utils/alertThresholdsSectionPresentation.ts?raw';
import loginSource from '@/components/Login.tsx?raw';
import settingsSource from '../Settings.tsx?raw';
import aiSettingsSource from '../AISettings.tsx?raw';
import aiSettingsShellSource from '../AISettings.tsx?raw';
import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw';
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
import aiSettingsModelSource from '../aiSettingsModel.ts?raw';
import reportingPanelSource from '../ReportingPanel.tsx?raw';
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
import suggestProfileModalSource from '../SuggestProfileModal.tsx?raw';
@@ -78,6 +81,13 @@ import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.t
import problemResourcesTableSource from '@/pages/DashboardPanels/ProblemResourcesTable.tsx?raw';
import workloadTypeBadgesSource from '@/components/shared/workloadTypeBadges.ts?raw';
import workloadTypePresentationSource from '@/utils/workloadTypePresentation.ts?raw';
const aiSettingsSource = [
aiSettingsShellSource,
aiProviderConfigurationSectionSource,
aiSettingsDialogsSource,
aiSettingsModelSource,
].join('\n');
import dashboardGuestPresentationSource from '@/utils/dashboardGuestPresentation.ts?raw';
import containerUpdatesSource from '@/stores/containerUpdates.ts?raw';
import websocketStoreSource from '@/stores/websocket.ts?raw';
@@ -127,9 +137,7 @@ describe('monitored-system model guardrails', () => {
expect(agentProfilesPanelSource).not.toContain(
'No agents connected. Install an agent to assign profiles.',
);
expect(agentProfilesPresentationSource).toContain(
'export function getAgentProfilesEmptyState',
);
expect(agentProfilesPresentationSource).toContain('export function getAgentProfilesEmptyState');
expect(agentProfilesPresentationSource).toContain(
'export function getAgentProfileAssignmentsEmptyState',
);
@@ -162,7 +170,9 @@ describe('monitored-system model guardrails', () => {
"const hostResources = createMemo(() => byType('host'))",
);
expect(apiTokenManagerSource).not.toContain('isAppContainerDiscoveryResourceType');
expect(apiTokenManagerSource).not.toContain("notificationStore.error('Failed to load API tokens')");
expect(apiTokenManagerSource).not.toContain(
"notificationStore.error('Failed to load API tokens')",
);
expect(apiTokenManagerSource).not.toContain(
"notificationStore.error('Failed to generate API token')",
);
@@ -176,7 +186,9 @@ describe('monitored-system model guardrails', () => {
expect(infrastructureOperationsStateSource).not.toContain('previousHostTypes');
expect(infrastructureOperationsStateSource).not.toContain('const allHosts = createMemo(');
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
expect(infrastructureOperationsStateSource).not.toContain('const MONITORING_STOPPED_STATUS_LABEL =');
expect(infrastructureOperationsStateSource).not.toContain(
'const MONITORING_STOPPED_STATUS_LABEL =',
);
expect(infrastructureOperationsStateSource).not.toContain('const ALLOW_RECONNECT_LABEL =');
expect(infrastructureOperationsStateSource).toContain('withPrivilegeEscalation');
expect(infrastructureOperationsStateSource).toContain('@/utils/agentCapabilityPresentation');
@@ -195,7 +207,9 @@ describe('monitored-system model guardrails', () => {
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClass =');
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClasses =');
expect(infrastructureOperationsStateSource).toContain('getUnifiedAgentLookupStatusPresentation');
expect(infrastructureOperationsStateSource).toContain(
'getUnifiedAgentLookupStatusPresentation',
);
expect(unifiedAgentStatusPresentationSource).toContain(
'export function getUnifiedAgentStatusPresentation',
);
@@ -338,34 +352,20 @@ describe('monitored-system model guardrails', () => {
expect(aiSettingsPresentationSource).toContain(
'export function getAISettingsReadinessPresentation',
);
expect(aiSettingsPresentationSource).toContain(
'export function getAISettingsLoadingState',
);
expect(aiSettingsPresentationSource).toContain(
'export function getAISettingsLoadErrorMessage',
);
expect(aiSettingsPresentationSource).toContain(
'export function getAISettingsRetryLabel',
);
expect(aiSettingsPresentationSource).toContain('export function getAISettingsLoadingState');
expect(aiSettingsPresentationSource).toContain('export function getAISettingsLoadErrorMessage');
expect(aiSettingsPresentationSource).toContain('export function getAISettingsRetryLabel');
expect(aiSettingsSource).toContain('SelectionCardGroup');
expect(aiSettingsSource).toContain('variant="compact"');
expect(updatesSettingsPanelSource).toContain('SelectionCardGroup');
expect(updatesSettingsPanelSource).toContain('variant="detail"');
expect(aiSettingsPresentationSource).toContain(
'export function getAIChatSessionsLoadingState',
);
expect(aiSettingsPresentationSource).toContain(
'export function getAIChatSessionsEmptyState',
);
expect(aiSettingsPresentationSource).toContain(
'export function getAIModelsLoadErrorMessage',
);
expect(aiSettingsPresentationSource).toContain('export function getAIChatSessionsLoadingState');
expect(aiSettingsPresentationSource).toContain('export function getAIChatSessionsEmptyState');
expect(aiSettingsPresentationSource).toContain('export function getAIModelsLoadErrorMessage');
expect(aiSettingsPresentationSource).toContain(
'export function getAIChatSessionsLoadErrorMessage',
);
expect(aiSettingsPresentationSource).toContain(
'export function getAIOAuthErrorMessage',
);
expect(aiSettingsPresentationSource).toContain('export function getAIOAuthErrorMessage');
expect(suggestProfileModalSource).toContain('@/utils/agentProfileSuggestionPresentation');
expect(suggestProfileModalSource).toContain('AGENT_PROFILE_SUGGESTION_EXAMPLE_PROMPTS');
expect(suggestProfileModalSource).toContain('getAgentProfileSuggestionLoadingState');
@@ -494,7 +494,9 @@ describe('monitored-system model guardrails', () => {
expect(organizationOverviewPanelSource).toContain('@/utils/organizationSettingsPresentation');
expect(organizationAccessPanelSource).toContain('ORGANIZATION_MEMBER_ROLE_OPTIONS');
expect(organizationAccessPanelSource).not.toContain("{ value: 'viewer', label: 'Viewer' }");
expect(organizationAccessPanelSource).not.toContain("notificationStore.error('User ID is required')");
expect(organizationAccessPanelSource).not.toContain(
"notificationStore.error('User ID is required')",
);
expect(organizationAccessPanelSource).not.toContain(
"notificationStore.error(error instanceof Error ? error.message : 'Failed to add member')",
);
@@ -512,7 +514,7 @@ describe('monitored-system model guardrails', () => {
expect(organizationSharingPanelSource).toContain('ORGANIZATION_SHARE_ROLE_OPTIONS');
expect(organizationSharingPanelSource).toContain('normalizeOrganizationShareRole');
expect(organizationSharingPanelSource).not.toContain(
"const accessRoleOptions: Array<{ value: ShareAccessRole; label: string }> = [",
'const accessRoleOptions: Array<{ value: ShareAccessRole; label: string }> = [',
);
expect(organizationSharingPanelSource).not.toContain('const normalizeShareRole =');
expect(organizationSharingPanelSource).not.toContain(
@@ -701,15 +703,11 @@ describe('monitored-system model guardrails', () => {
);
expect(resourcePickerSource).not.toContain('No resources match your filters');
expect(reportableResourceTypesSource).toContain('export const REPORTABLE_RESOURCE_TYPES =');
expect(reportableResourceTypesSource).toContain(
'export const RESOURCE_PICKER_TYPE_FILTERS',
);
expect(reportableResourceTypesSource).toContain('export const RESOURCE_PICKER_TYPE_FILTERS');
expect(reportableResourceTypesSource).toContain(
'export const getResourcePickerTypeFilterLabel',
);
expect(reportableResourceTypesSource).toContain(
'export const getResourcePickerEmptyState',
);
expect(reportableResourceTypesSource).toContain('export const getResourcePickerEmptyState');
expect(reportableResourceTypesSource).toContain(
'export const matchesReportableResourceTypeFilter =',
);
@@ -758,9 +756,7 @@ describe('monitored-system model guardrails', () => {
expect(systemSettingsPresentationSource).toContain(
'export function getBackupIntervalSelectValue',
);
expect(systemSettingsPresentationSource).toContain(
'export function getBackupIntervalSummary',
);
expect(systemSettingsPresentationSource).toContain('export function getBackupIntervalSummary');
expect(systemSettingsPresentationSource).toContain(
'export function getSystemSettingsSaveErrorMessage',
);
@@ -787,15 +783,25 @@ describe('monitored-system model guardrails', () => {
expect(ssoProvidersPanelSource).toContain('SSOProviderTypeIcon');
expect(ssoProvidersPanelSource).not.toContain('No SSO providers configured');
expect(ssoProvidersPanelSource).not.toContain('Loading SSO providers...');
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Failed to load SSO providers')");
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.error('Failed to load SSO providers')",
);
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.error('Failed to load provider details')",
);
expect(ssoProvidersPanelSource).not.toContain("notificationStore.success('Provider deleted')");
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Failed to delete provider')");
expect(ssoProvidersPanelSource).not.toContain("notificationStore.success('Connection test successful')");
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Failed to test connection')");
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Please enter an IdP Metadata URL')");
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.error('Failed to delete provider')",
);
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.success('Connection test successful')",
);
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.error('Failed to test connection')",
);
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.error('Please enter an IdP Metadata URL')",
);
expect(ssoProvidersPanelSource).not.toContain('provider.type.toUpperCase()');
expect(ssoProvidersPanelSource).not.toContain("provider.type === 'oidc' ? (");
expect(ssoProvidersPanelSource).not.toContain(
@@ -807,9 +813,7 @@ describe('monitored-system model guardrails', () => {
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderTypeLabel');
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderSummary');
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderCardClass');
expect(ssoProviderPresentationSource).toContain(
'export function getSSOProviderAddButtonLabel',
);
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderAddButtonLabel');
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderModalTitle');
expect(ssoProviderPresentationSource).toContain(
'export function getSSOProviderEmptyStateTitle',
@@ -817,12 +821,8 @@ describe('monitored-system model guardrails', () => {
expect(ssoProviderPresentationSource).toContain(
'export function getSSOProviderEmptyStateDescription',
);
expect(ssoProviderPresentationSource).toContain(
'export function getSSOProvidersLoadingState',
);
expect(ssoProviderPresentationSource).toContain(
'export function getSSOTestResultPresentation',
);
expect(ssoProviderPresentationSource).toContain('export function getSSOProvidersLoadingState');
expect(ssoProviderPresentationSource).toContain('export function getSSOTestResultPresentation');
expect(ssoProviderPresentationSource).toContain(
'export function getSSOCertificatePresentation',
);
@@ -854,8 +854,12 @@ describe('monitored-system model guardrails', () => {
expect(rolesPanelSource).toContain('createDefaultRBACPermission');
expect(rolesPanelSource).toContain('getRBACFeatureGateCopy');
expect(rolesPanelSource).toContain('getRolesEmptyState');
expect(rolesPanelSource).not.toContain("const ACTIONS = ['read', 'write', 'delete', 'admin', '*']");
expect(rolesPanelSource).not.toContain("const RESOURCES = ['settings', 'audit_logs', 'nodes', 'users', 'license', '*']");
expect(rolesPanelSource).not.toContain(
"const ACTIONS = ['read', 'write', 'delete', 'admin', '*']",
);
expect(rolesPanelSource).not.toContain(
"const RESOURCES = ['settings', 'audit_logs', 'nodes', 'users', 'license', '*']",
);
expect(rolesPanelSource).not.toContain('Custom Roles (Pro)');
expect(rolesPanelSource).not.toContain('No roles available.');
expect(rbacPermissionsSource).toContain('export const RBAC_PERMISSION_ACTIONS');
@@ -865,12 +869,8 @@ describe('monitored-system model guardrails', () => {
expect(rbacPresentationSource).toContain('export function getRolesEmptyState');
expect(rbacPresentationSource).toContain('export function getRolesLoadErrorMessage');
expect(rbacPresentationSource).toContain('export function getRolesSaveErrorMessage');
expect(rbacPresentationSource).toContain(
'export function getUserAssignmentsLoadErrorMessage',
);
expect(rbacPresentationSource).toContain(
'export function getUserAssignmentsEmptyStateCopy',
);
expect(rbacPresentationSource).toContain('export function getUserAssignmentsLoadErrorMessage');
expect(rbacPresentationSource).toContain('export function getUserAssignmentsEmptyStateCopy');
expect(userAssignmentsPanelSource).toContain('@/utils/rbacPresentation');
expect(userAssignmentsPanelSource).toContain('getRBACFeatureGateCopy');
expect(userAssignmentsPanelSource).toContain('getUserAssignmentsEmptyStateCopy');
@@ -924,9 +924,7 @@ describe('monitored-system model guardrails', () => {
expect(thresholdsTableSource).toContain('CONTAINERS_FILTER_EMPTY_STATE');
expect(thresholdsTableSource).toContain('getAlertThresholdsGuestFilterPresentation');
expect(thresholdsTableSource).toContain('getAlertThresholdsBackupOrphanedPresentation');
expect(thresholdsTableSource).toContain(
'getAlertThresholdsDockerIgnoredPrefixesPresentation',
);
expect(thresholdsTableSource).toContain('getAlertThresholdsDockerIgnoredPrefixesPresentation');
expect(thresholdsTableSource).not.toContain('No PBS servers configured.');
expect(thresholdsTableSource).not.toContain('No VMs or containers found.');
expect(thresholdsTableSource).not.toContain('No nodes match the current filters.');
@@ -994,9 +992,7 @@ describe('monitored-system model guardrails', () => {
expect(alertThresholdsPresentationSource).toContain(
'export const NODE_THRESHOLDS_FILTER_EMPTY_STATE',
);
expect(alertThresholdsPresentationSource).toContain(
'export const PMG_THRESHOLDS_EMPTY_STATE',
);
expect(alertThresholdsPresentationSource).toContain('export const PMG_THRESHOLDS_EMPTY_STATE');
expect(alertThresholdsPresentationSource).toContain(
'export const ALERT_THRESHOLDS_SEARCH_PLACEHOLDER',
);
@@ -1120,7 +1116,9 @@ describe('monitored-system model guardrails', () => {
expect(settingsSource).not.toContain(
"stateHosts={(state.resources ?? []).filter((r) => r.type === 'host')}",
);
expect(infrastructureSettingsStateSource).toContain('@/utils/infrastructureSettingsPresentation');
expect(infrastructureSettingsStateSource).toContain(
'@/utils/infrastructureSettingsPresentation',
);
expect(infrastructureSettingsStateSource).toContain('getDiscoveryScanStartErrorMessage');
expect(infrastructureSettingsStateSource).toContain('getDiscoverySettingUpdateErrorMessage');
expect(infrastructureSettingsStateSource).toContain('getDiscoverySubnetUpdateErrorMessage');
@@ -13,6 +13,9 @@ import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw';
import billingAdminPanelSource from '../BillingAdminPanel.tsx?raw';
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
import aiSettingsPanelSource from '../AISettings.tsx?raw';
import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw';
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
import aiSettingsModelSource from '../aiSettingsModel.ts?raw';
import networkSettingsPanelSource from '../NetworkSettingsPanel.tsx?raw';
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
import recoverySettingsPanelSource from '../RecoverySettingsPanel.tsx?raw';
@@ -45,6 +48,9 @@ const extractedModules = [
'../InfrastructureWorkspace.tsx',
'../InfrastructureInstallPanel.tsx',
'../InfrastructureReportingPanel.tsx',
'../AIProviderConfigurationSection.tsx',
'../AISettingsDialogs.tsx',
'../aiSettingsModel.ts',
'../ProxmoxSettingsPanel.tsx',
'../SettingsDialogs.tsx',
'../SettingsPageShell.tsx',
@@ -222,8 +228,8 @@ describe('Settings architecture guardrails', () => {
expect(commercialBillingModelSource).toContain('buildSelfHostedCommercialPlanModel');
expect(commercialBillingModelSource).toContain('buildHostedCommercialPlanModel');
expect(commercialBillingModelSource).toContain('buildHostedCommercialUsageModel');
expect(proLicensePanelSource).toContain("./CommercialBillingSections");
expect(proLicensePanelSource).toContain("buildSelfHostedCommercialPlanModel");
expect(proLicensePanelSource).toContain('./CommercialBillingSections');
expect(proLicensePanelSource).toContain('buildSelfHostedCommercialPlanModel');
expect(proLicensePanelSource).toContain('SelfHostedCommercialActivationSection');
expect(proLicensePanelSource).toContain('MonitoredSystemLedgerPanel');
expect(proLicensePanelSource).toContain('CommercialBillingShell');
@@ -231,9 +237,9 @@ describe('Settings architecture guardrails', () => {
expect(proLicensePanelSource).toContain('CommercialStatGrid');
expect(selfHostedCommercialActivationSectionSource).toContain('License / Activation Key');
expect(selfHostedCommercialActivationSectionSource).toContain('Start 14-day Pro Trial');
expect(organizationBillingPanelSource).toContain("./CommercialBillingSections");
expect(organizationBillingPanelSource).toContain("buildHostedCommercialPlanModel");
expect(organizationBillingPanelSource).toContain("buildHostedCommercialUsageModel");
expect(organizationBillingPanelSource).toContain('./CommercialBillingSections');
expect(organizationBillingPanelSource).toContain('buildHostedCommercialPlanModel');
expect(organizationBillingPanelSource).toContain('buildHostedCommercialUsageModel');
expect(organizationBillingPanelSource).toContain('CommercialBillingShell');
expect(organizationBillingPanelSource).toContain('CommercialSection');
expect(organizationBillingPanelSource).toContain('CommercialUsageMeters');
@@ -284,13 +290,15 @@ describe('Settings architecture guardrails', () => {
expect(infrastructureInstallPanelSource).toContain('useInfrastructureOperationsState');
expect(infrastructureReportingPanelSource).toContain('useInfrastructureOperationsState');
expect(infrastructureOperationsControllerSource).toContain('useInfrastructureOperationsState');
expect(infrastructureOperationsStateSource).toContain('export const useInfrastructureOperationsState');
expect(infrastructureOperationsStateSource).toContain(
'export const useInfrastructureOperationsState',
);
expect(infrastructureInstallPanelSource).not.toContain('<PageHeader');
expect(infrastructureReportingPanelSource).not.toContain('<PageHeader');
});
it('keeps the infrastructure operations model extracted from the reactive hook owner', () => {
expect(infrastructureOperationsStateSource).toContain("./infrastructureOperationsModel");
expect(infrastructureOperationsStateSource).toContain('./infrastructureOperationsModel');
expect(infrastructureOperationsStateSource).not.toContain('const INSTALL_PROFILE_OPTIONS');
expect(infrastructureOperationsStateSource).not.toContain('const buildCommandsByPlatform =');
expect(infrastructureOperationsStateSource).not.toContain(
@@ -303,6 +311,16 @@ describe('Settings architecture guardrails', () => {
expect(infrastructureOperationsModelSource).toContain('export const buildCommandsByPlatform');
});
it('keeps AI settings sub-surfaces behind extracted runtime owners', () => {
expect(aiSettingsPanelSource).toContain('@/components/Settings/AIProviderConfigurationSection');
expect(aiSettingsPanelSource).toContain('@/components/Settings/AISettingsDialogs');
expect(aiSettingsPanelSource).toContain('@/components/Settings/aiSettingsModel');
expect(aiProviderConfigurationSectionSource).toContain('@/components/Settings/aiSettingsModel');
expect(aiSettingsDialogsSource).toContain('@/components/Settings/aiSettingsModel');
expect(aiSettingsModelSource).toContain('export const AI_PROVIDER_CONFIGS');
expect(aiSettingsModelSource).toContain('export const AI_SETUP_PROVIDER_OPTIONS');
});
it('routes every top-level settings surface through the canonical panel shell framing', () => {
for (const [panelName, source] of Object.entries(topLevelSettingsPanelSources)) {
const usesCanonicalShell =
@@ -0,0 +1,213 @@
import type { SelectionCardOption } from '@/components/shared/SelectionCardGroup';
import type { AIProviderHealthStatus } from '@/utils/aiProviderHealthPresentation';
import { getProviderFromModelId } from '@/utils/aiProviderPresentation';
import type { AIProvider, AISettings as AISettingsType } from '@/types/ai';
export type AIProviderCredentialsFormState = {
anthropicApiKey: string;
openaiApiKey: string;
openrouterApiKey: string;
deepseekApiKey: string;
geminiApiKey: string;
ollamaBaseUrl: string;
openaiBaseUrl: string;
};
export type AIProviderConfig = {
provider: AIProvider;
title: string;
configuredLabel?: string;
inputType: 'password' | 'url';
inputField: keyof AIProviderCredentialsFormState;
placeholder: string;
configuredPlaceholder?: string;
actionLinkLabel: string;
actionLinkHref: string;
actionLinkSuffix?: string;
helperText?: string;
extraField?: {
label: string;
helpContentId?: string;
inputField: 'openaiBaseUrl';
placeholder: string;
};
clearTitle: string;
};
export type ProviderHealthState = {
status: AIProviderHealthStatus;
message: string;
};
export type ProviderTestResult = {
provider: AIProvider;
success: boolean;
message: string;
};
export type AIAvailableModel = {
id: string;
name: string;
description?: string;
};
export const AI_PROVIDERS: AIProvider[] = [
'anthropic',
'openai',
'openrouter',
'deepseek',
'gemini',
'ollama',
];
export const AI_SETUP_PROVIDER_OPTIONS: SelectionCardOption<AIProvider>[] = [
{ value: 'anthropic', title: 'Anthropic', description: 'Claude' },
{ value: 'openai', title: 'OpenAI', description: 'ChatGPT' },
{ value: 'openrouter', title: 'OpenRouter', description: 'Gateway' },
{ value: 'deepseek', title: 'DeepSeek', description: 'V3' },
{ value: 'gemini', title: 'Gemini', description: 'Google' },
{ value: 'ollama', title: 'Ollama', description: 'Local' },
];
export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
{
provider: 'anthropic',
title: 'Anthropic',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'anthropicApiKey',
placeholder: 'sk-ant-...',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://console.anthropic.com/settings/keys',
clearTitle: 'Clear API key',
},
{
provider: 'openai',
title: 'OpenAI',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'openaiApiKey',
placeholder: 'sk-...',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://platform.openai.com/api-keys',
clearTitle: 'Clear API key',
extraField: {
label: 'Custom Base URL',
helpContentId: 'ai.openai.baseUrl',
inputField: 'openaiBaseUrl',
placeholder: 'https://api.together.xyz/v1 (optional)',
},
},
{
provider: 'openrouter',
title: 'OpenRouter',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'openrouterApiKey',
placeholder: 'sk-or-...',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://openrouter.ai/keys',
helperText: 'Uses https://openrouter.ai/api/v1 automatically.',
clearTitle: 'Clear API key',
},
{
provider: 'deepseek',
title: 'DeepSeek',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'deepseekApiKey',
placeholder: 'sk-...',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://platform.deepseek.com/api_keys',
clearTitle: 'Clear API key',
},
{
provider: 'gemini',
title: 'Google Gemini',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'geminiApiKey',
placeholder: 'AIza...',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://aistudio.google.com/app/apikey',
clearTitle: 'Clear API key',
},
{
provider: 'ollama',
title: 'Ollama',
configuredLabel: 'Available',
inputType: 'url',
inputField: 'ollamaBaseUrl',
placeholder: 'http://localhost:11434',
actionLinkLabel: 'Learn about Ollama →',
actionLinkHref: 'https://ollama.ai',
actionLinkSuffix: ' · Free & local',
clearTitle: 'Clear Ollama URL',
},
];
export const createInitialProviderHealth = (): Record<AIProvider, ProviderHealthState> => ({
anthropic: { status: 'not_configured', message: '' },
openai: { status: 'not_configured', message: '' },
openrouter: { status: 'not_configured', message: '' },
deepseek: { status: 'not_configured', message: '' },
gemini: { status: 'not_configured', message: '' },
ollama: { status: 'not_configured', message: '' },
});
export function getAIProviderConfig(provider: AIProvider): AIProviderConfig {
const config = AI_PROVIDER_CONFIGS.find((entry) => entry.provider === provider);
if (!config) {
throw new Error(`Unknown AI provider: ${provider}`);
}
return config;
}
export function isAIProviderConfigured(
provider: AIProvider | string,
settings: AISettingsType | null,
): boolean {
if (!settings) return false;
switch (provider) {
case 'anthropic':
return settings.anthropic_configured;
case 'openai':
return settings.openai_configured;
case 'openrouter':
return settings.openrouter_configured;
case 'deepseek':
return settings.deepseek_configured;
case 'gemini':
return settings.gemini_configured;
case 'ollama':
return settings.ollama_configured;
default:
return false;
}
}
export function isModelProviderConfigured(
modelId: string,
settings: AISettingsType | null,
): boolean {
const provider = getProviderFromModelId(modelId);
return isAIProviderConfigured(provider, settings);
}
export function groupModelsByProvider(models: AIAvailableModel[]): Map<string, AIAvailableModel[]> {
const grouped = new Map<string, AIAvailableModel[]>();
for (const model of models) {
const provider = getProviderFromModelId(model.id);
const existing = grouped.get(provider) || [];
existing.push(model);
grouped.set(provider, existing);
}
return grouped;
}
File diff suppressed because it is too large Load Diff