Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/main/config/infomaniak-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Where the Infomaniak product id comes from.
*
* The base URL is account-specific — one AI product per organisation, and the
* id is part of the path — so it is configuration, never a constant. It used
* to be read from `process.env` alone, which meant a packaged, GUI-launched
* app never had it and the Swiss endpoint silently failed to register.
*
* It belongs in the settings, where a user can actually enter it. The
* environment stays as a fallback for development and for headless runs.
* Settings win: a value someone typed into the app beats one the process
* happened to inherit.
*/

import { getProviderRegistry, seedSwissProviders } from '../routing/provider-registry'
import { getClientManager } from '../llm-clients/client-manager'

const ENV_VAR = 'INFOMANIAK_PRODUCT_ID'

/** Blank, whitespace-only and unset all mean the same thing: not configured. */
function normalise(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}

/**
* Resolve the product id, settings first.
*
* `getConfigured` is injected so this stays testable without the settings
* store and its Electron dependencies.
*/
export function resolveInfomaniakProductId(
getConfigured: () => string | undefined
): string | undefined {
return normalise(getConfigured()) ?? normalise(process.env[ENV_VAR])
}

/**
* Bring the registry in line with the configured id.
*
* Called at startup and again whenever the setting changes, so a user does
* not have to restart the app to make a Swiss endpoint appear — or disappear.
* Clearing the id REMOVES the entry rather than leaving the previous URL in
* place; a stale entry would still claim Swiss residency and still receive
* the requests that claim earns it.
*/
export function applyInfomaniakConfig(productId: string | undefined): void {
const registry = getProviderRegistry()
const id = normalise(productId)

if (!id) {
registry.remove('infomaniak')
// Drop the in-memory key too: with no endpoint there is nothing to send,
// and a leftover client must not be able to. The keychain copy stays, so
// re-entering the id does not require re-entering the token.
getClientManager().clearApiKey('infomaniak')
return
}

seedSwissProviders(registry, id)

// The registry decides whether a provider MAY receive a request; the client
// manager is what can actually send one. Registering only the first would
// leave the endpoint selectable but unreachable.
const entry = registry.get('infomaniak')
if (entry) getClientManager().registerCustomProvider(entry.config)
}
12 changes: 12 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { getLocalAIBridge } from './network/local-ai-bridge'
import { getDocMindIntegration } from './integrations/docmind-integration'
import { getAutoUpdater } from './updater/auto-updater'
import { applyEnvFile } from './config/env-file'
import { applyInfomaniakConfig, resolveInfomaniakProductId } from './config/infomaniak-config'
import { SimpleStore } from './utils/simple-store'
import { createLogger } from '../shared/logger'

const logger = createLogger('Main')
Expand Down Expand Up @@ -121,6 +123,16 @@ app.whenReady().then(async () => {
// Register all IPC handlers
await registerIPCHandlers()

// Register the Swiss endpoint from the stored settings (falling back to the
// environment). Without this the registry would only ever see the env var,
// which a packaged app does not have.
try {
const stored = SimpleStore.create().get('settings') as { infomaniakProductId?: string } | undefined
applyInfomaniakConfig(resolveInfomaniakProductId(() => stored?.infomaniakProductId))
} catch (error) {
logger.warn('Could not apply the Infomaniak configuration', { error: String(error) })
}

// Initialize deployment manager (auto-starts server if in server mode)
try {
await getDeploymentManager().initialize()
Expand Down
8 changes: 5 additions & 3 deletions src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@ import { registerMCPHandlers } from './ipc/mcp-handlers'
import { registerRAGHandlers } from './ipc/rag-handlers'
import { registerPrivacyHandlers } from './ipc/privacy-handlers'
import { registerAgentHandlers } from './ipc/agent-handlers'
// Same rule as the one gating credential storage: a provider qualifies by
// being built in or registered. Two copies of this check with two different
// lists is how a configured endpoint ends up able to hold a key and unable to
// receive a message.
import { validateProvider } from './ipc/ipc-utils'

// ============================================================
// Helpers (only needed for SEND_MESSAGE orchestration)
// ============================================================

/** Validate provider string is valid LLMProvider */
function validateProvider(provider: string): provider is LLMProvider {
return ['anthropic', 'openai', 'google', 'local'].includes(provider)
}

const clientManager = getClientManager()
const systemPromptManager = getSystemPromptManager()
Expand Down
6 changes: 6 additions & 0 deletions src/main/ipc/conversation-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MessageModel } from '../database/models/message'
import { SimpleStore } from '../utils/simple-store'
import { getFeatureGateManager } from '../services/feature-gate-manager'
import { wrapHandler, requirePermission, requireFeature } from './ipc-utils'
import { applyInfomaniakConfig, resolveInfomaniakProductId } from '../config/infomaniak-config'

export function registerConversationHandlers(): void {
const store = SimpleStore.create()
Expand Down Expand Up @@ -88,6 +89,11 @@ export function registerConversationHandlers(): void {
const currentSettings = (store.get('settings') || {}) as AppSettings
const merged = { ...currentSettings, ...settings }
store.set('settings', merged)

// Bring the provider registry in line immediately: entering — or clearing
// — the product id must take effect without restarting the app.
applyInfomaniakConfig(resolveInfomaniakProductId(() => merged.infomaniakProductId))

return { success: true, settings: merged }
})
}
23 changes: 22 additions & 1 deletion src/main/ipc/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ipcMain } from 'electron'
import { getRateLimiter } from '../security/rate-limiter'
import { getRBACManager } from '../security/rbac-manager'
import { getFeatureGateManager } from '../services/feature-gate-manager'
import { getProviderRegistry } from '../routing/provider-registry'

/**
* Wrap an IPC handler with consistent error handling + rate limiting.
Expand Down Expand Up @@ -53,6 +54,26 @@ export function requireFeature(feature: string): void {
}

/** Validate provider string is valid LLMProvider */
/** Built-ins are explicit so a registry change cannot lock a user out of them. */
const BUILT_IN_PROVIDERS = ['anthropic', 'openai', 'google', 'local']

/**
* May this provider hold credentials?
*
* This gates both saving an API key and loading one back at startup. It used
* to be the four names above and nothing else, so a provider the user had
* just configured — the Swiss endpoint, say — could not hold a token, and the
* settings field for it failed silently.
*
* The rule is now earned rather than enumerated: a provider qualifies by
* being in the provider registry. That covers endpoints we registered and
* endpoints a tenant added (bring-your-own-key is the point of those), while
* an arbitrary string still cannot reach the keychain. Invariant I2 governs
* what such an endpoint may CLAIM about its residency — a separate question
* from whether the user may store a key for it.
*/
export function validateProvider(provider: string): boolean {
return ['anthropic', 'openai', 'google', 'local'].includes(provider)
if (BUILT_IN_PROVIDERS.includes(provider)) return true
if (!provider) return false
return getProviderRegistry().get(provider) !== undefined
}
12 changes: 12 additions & 0 deletions src/main/routing/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ export class ProviderRegistry {
return this.entries.get(id)
}

/**
* Forget a provider.
*
* Needed because configuration can be withdrawn: when a user clears the
* Infomaniak product id, the entry must go. Leaving it would keep an
* endpoint claiming Swiss residency for a URL nobody stands behind any
* more, and the policy would keep routing sensitive requests to it.
*/
remove(id: string): void {
this.entries.delete(id)
}

all(): RegistryEntry[] {
return Array.from(this.entries.values())
}
Expand Down
107 changes: 107 additions & 0 deletions src/renderer/components/PrivacySettingsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect, memo, useCallback } from 'react'
import { usePrivacyStore } from '../stores/privacy-store'
import { useChatStore } from '../stores/chat-store'
import { useSettingsStore } from '../stores/settings-store'

type PrivacyMode = 'shield' | 'vault' | 'transparent' | 'local_only'

Expand All @@ -11,6 +12,109 @@ const PRIVACY_MODES: { mode: PrivacyMode; label: string; description: string; co
{ mode: 'local_only', label: 'Local Only', description: 'Nachrichten nur an lokale LLMs senden', color: 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' }
]

/**
* Swiss endpoint (Infomaniak).
*
* Two inputs, not one: the base URL is account-specific — Infomaniak exposes
* one AI product per organisation and its id is part of the path — so the id
* is configuration, not a constant. Without it nothing is registered, and the
* policy has no Swiss endpoint to route sensitive requests to.
*/
const SwissEndpointSection = memo(function SwissEndpointSection() {
const settings = useSettingsStore((s) => s.settings)
const updateSettings = useSettingsStore((s) => s.updateSettings)
const saveAPIKey = useSettingsStore((s) => s.saveAPIKey)
const checkAPIKeys = useSettingsStore((s) => s.checkAPIKeys)
const apiKeysConfigured = useSettingsStore((s) => s.apiKeysConfigured)

const [productId, setProductId] = useState('')
const [token, setToken] = useState('')
const [saving, setSaving] = useState(false)

useEffect(() => {
setProductId(settings?.infomaniakProductId ?? '')
}, [settings?.infomaniakProductId])

const configured = Boolean(settings?.infomaniakProductId)
const hasToken = Boolean(apiKeysConfigured?.infomaniak)

const handleSave = useCallback(async () => {
setSaving(true)
try {
await updateSettings({ infomaniakProductId: productId.trim() })
if (token.trim()) {
await saveAPIKey('infomaniak', token.trim())
setToken('')
await checkAPIKeys()
}
} finally {
setSaving(false)
}
}, [productId, token, updateSettings, saveAPIKey, checkAPIKeys])

return (
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="mb-1 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Schweizer Endpunkt (Infomaniak)
</h4>
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${
configured
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{configured ? 'registriert' : 'nicht eingerichtet'}
</span>
</div>

<p className="mb-3 text-xs text-gray-500 dark:text-gray-400">
Ohne Produkt-ID wird kein Schweizer Endpunkt registriert — Anfragen mit
hohem Schutzbedarf bleiben dann auf dem lokalen Modell oder werden
abgelehnt. Die ID steht im Infomaniak Manager beim Produkt «AI Tools».
</p>

<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
Produkt-ID
</label>
<input
type="text"
value={productId}
onChange={(e) => setProductId(e.target.value)}
placeholder="z. B. 110908"
className="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>

<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
API-Token {hasToken && <span className="text-green-600 dark:text-green-400">· hinterlegt</span>}
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={hasToken ? 'Zum Ersetzen neuen Token eingeben' : 'Token aus dem Infomaniak Manager'}
className="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>

<button
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Speichern…' : 'Speichern'}
</button>

{configured && !hasToken && (
<p className="mt-3 text-xs text-amber-600 dark:text-amber-400">
Produkt-ID gesetzt, aber kein Token hinterlegt — der Endpunkt ist
registriert und wird Anfragen nicht beantworten koennen.
</p>
)}
</div>
)
})

/** PII Privacy Mode Switcher */
const PrivacyModeSwitcher = memo(function PrivacyModeSwitcher() {
const mode = usePrivacyStore((s) => s.mode)
Expand Down Expand Up @@ -282,6 +386,9 @@ export function PrivacySettingsTab() {
{/* NER Model Management */}
<NERModelSection />

{/* Swiss endpoint */}
<SwissEndpointSection />

{/* Statistics */}
{stats && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
Expand Down
7 changes: 7 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,13 @@ export interface AppSettings {
routingMode?: 'manual' | 'auto'
/** Custom display name for the external RAG server (default: "RAG-Wissen") */
ragServerName?: string
/**
* Infomaniak AI product id. The Swiss endpoint's base URL contains it
* (`/2/ai/{id}/openai/v1`), so it is account-specific configuration, not a
* constant. Empty means no Swiss endpoint is registered — see
* `src/main/config/infomaniak-config.ts`.
*/
infomaniakProductId?: string
}

// Prompt Template Types
Expand Down
Loading
Loading