@open-mercato/ai-assistant 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +82 -18
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
  6. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
  7. package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
  8. package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/route/route.js +38 -19
  12. package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
  15. package/dist/modules/ai_assistant/api/settings/route.js +537 -22
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
  33. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
  34. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
  35. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
  36. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
  37. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
  38. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
  39. package/dist/modules/ai_assistant/data/entities.js +123 -1
  40. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  41. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
  43. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
  44. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
  45. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
  46. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
  47. package/dist/modules/ai_assistant/i18n/de.json +90 -1
  48. package/dist/modules/ai_assistant/i18n/en.json +90 -1
  49. package/dist/modules/ai_assistant/i18n/es.json +90 -1
  50. package/dist/modules/ai_assistant/i18n/pl.json +90 -1
  51. package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
  52. package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  56. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
  57. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
  58. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
  59. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
  61. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
  62. package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
  63. package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
  64. package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
  65. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  66. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
  67. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  68. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
  69. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
  70. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
  71. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
  72. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
  73. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
  74. package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
  75. package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +26 -0
  78. package/jest.config.cjs +2 -0
  79. package/package.json +4 -4
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
  81. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
  82. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
  83. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
  84. package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
  85. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
  86. package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
  87. package/src/modules/ai_assistant/api/route/route.ts +49 -25
  88. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
  89. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
  90. package/src/modules/ai_assistant/api/settings/route.ts +721 -27
  91. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
  92. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
  93. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
  94. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
  95. package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
  96. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
  99. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
  100. package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
  101. package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
  102. package/src/modules/ai_assistant/data/entities.ts +164 -0
  103. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
  104. package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
  105. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
  106. package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
  107. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
  108. package/src/modules/ai_assistant/i18n/de.json +90 -1
  109. package/src/modules/ai_assistant/i18n/en.json +90 -1
  110. package/src/modules/ai_assistant/i18n/es.json +90 -1
  111. package/src/modules/ai_assistant/i18n/pl.json +90 -1
  112. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
  113. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
  114. package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
  115. package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
  116. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
  117. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
  118. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
  119. package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
  120. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
  121. package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
  122. package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
  123. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
  124. package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
  125. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
  126. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
  127. package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
  128. package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
  129. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
  131. package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
  133. package/src/modules/ai_assistant/migrations/Migration20260512130000.ts +13 -0
@@ -1,16 +1,20 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { useState } from 'react'
5
- import { useQuery } from '@tanstack/react-query'
6
- import { Bot, Loader2, CheckCircle2, XCircle, ChevronDown, ChevronRight, Server, Wrench, Eye, EyeOff, Database, Link2, Settings, Key } from 'lucide-react'
4
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
5
+ import { Bot, Loader2, CheckCircle2, XCircle, ChevronDown, ChevronRight, Server, Wrench, Eye, EyeOff, Database, Link2, Settings, Key, X } from 'lucide-react'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { AI_ASSISTANT_LAUNCHER_OPEN_EVENT } from '@open-mercato/ui/ai/AiAssistantLauncher'
9
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
10
  import { Button } from '@open-mercato/ui/primitives/button'
8
11
  import { Switch } from '@open-mercato/ui/primitives/switch'
12
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select'
13
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
9
14
  import { useAiAssistantVisibility } from '../../../frontend/hooks/useAiAssistantVisibility'
10
15
  import McpConfigDialog from './McpConfigDialog'
11
16
  import SessionKeyDialog from './SessionKeyDialog'
12
17
 
13
- // OpenCode health response type
14
18
  type OpenCodeHealthResponse = {
15
19
  status: 'ok' | 'error'
16
20
  opencode?: {
@@ -28,23 +32,48 @@ type OpenCodeHealthResponse = {
28
32
  message?: string
29
33
  }
30
34
 
31
- // Provider config type from settings API
32
35
  type ProviderConfig = {
33
36
  id: string
34
37
  name: string
35
38
  model: string
36
39
  defaultModel: string
37
- envKey: string
40
+ envKey: string | null
38
41
  configured: boolean
42
+ defaultModels: Array<{ id: string; name: string }>
43
+ }
44
+
45
+ type TenantOverride = {
46
+ providerId: string | null
47
+ modelId: string | null
48
+ baseURL: string | null
49
+ agentId: string | null
50
+ updatedAt: string
39
51
  }
40
52
 
41
53
  type SettingsResponse = {
42
54
  provider: ProviderConfig
43
55
  availableProviders: ProviderConfig[]
44
56
  mcpKeyConfigured: boolean
57
+ resolvedDefault: {
58
+ providerId: string
59
+ modelId: string
60
+ baseURL: string | null
61
+ source: string
62
+ } | null
63
+ tenantOverride: TenantOverride | null
64
+ agents: AgentResolution[]
65
+ }
66
+
67
+ type AgentResolution = {
68
+ agentId: string
69
+ moduleId: string
70
+ allowRuntimeModelOverride: boolean
71
+ providerId: string
72
+ modelId: string
73
+ baseURL: string | null
74
+ source: string
45
75
  }
46
76
 
47
- // Tool info type
48
77
  type ToolInfo = {
49
78
  name: string
50
79
  description: string
@@ -52,37 +81,401 @@ type ToolInfo = {
52
81
  inputSchema: Record<string, unknown>
53
82
  }
54
83
 
55
- // API fetch functions
84
+ type AiAssistantSettingsPageClientProps = {
85
+ launchMode?: 'selector' | 'legacy'
86
+ showVisibilityControl?: boolean
87
+ }
88
+
89
+ const LEGACY_AI_ASSISTANT_OPEN_EVENT = 'om:open-ai-chat'
90
+
56
91
  async function fetchHealth(): Promise<OpenCodeHealthResponse> {
57
- const res = await fetch('/api/ai_assistant/health')
58
- if (!res.ok) throw new Error('Failed to fetch health')
59
- return res.json()
92
+ const result = await apiCall<OpenCodeHealthResponse>('/api/ai_assistant/health')
93
+ if (!result.ok || !result.result) throw new Error('Failed to fetch health')
94
+ return result.result
60
95
  }
61
96
 
62
97
  async function fetchSettings(): Promise<SettingsResponse> {
63
- const res = await fetch('/api/ai_assistant/settings')
64
- if (!res.ok) throw new Error('Failed to fetch settings')
65
- return res.json()
98
+ const result = await apiCall<SettingsResponse>('/api/ai_assistant/settings')
99
+ if (!result.ok || !result.result) throw new Error('Failed to fetch settings')
100
+ return result.result
66
101
  }
67
102
 
68
103
  async function fetchTools(): Promise<{ tools: ToolInfo[] }> {
69
- const res = await fetch('/api/ai_assistant/tools')
70
- if (!res.ok) throw new Error('Failed to fetch tools')
71
- return res.json()
104
+ const result = await apiCall<{ tools: ToolInfo[] }>('/api/ai_assistant/tools')
105
+ if (!result.ok || !result.result) throw new Error('Failed to fetch tools')
106
+ return result.result
72
107
  }
73
108
 
74
- function AiAssistantSettingsContent() {
75
- const [toolsExpanded, setToolsExpanded] = useState(false)
76
- const [mcpConfigOpen, setMcpConfigOpen] = useState(false)
77
- const [sessionKeyOpen, setSessionKeyOpen] = useState(false)
109
+ function GlobalOverrideForm({
110
+ availableProviders,
111
+ tenantOverride,
112
+ onSaved,
113
+ }: {
114
+ availableProviders: ProviderConfig[]
115
+ tenantOverride: TenantOverride | null
116
+ onSaved: () => void
117
+ }) {
118
+ const t = useT()
119
+ const [selectedProviderId, setSelectedProviderId] = React.useState<string>(
120
+ tenantOverride?.providerId ?? '',
121
+ )
122
+ const [selectedModelId, setSelectedModelId] = React.useState<string>(
123
+ tenantOverride?.modelId ?? '',
124
+ )
125
+
126
+ const selectedProvider = availableProviders.find((p) => p.id === selectedProviderId)
127
+
128
+ const { runMutation: runSaveMutation } = useGuardedMutation({ contextId: 'ai-settings-save-override' })
129
+ const { runMutation: runClearMutation } = useGuardedMutation({ contextId: 'ai-settings-clear-override' })
130
+ const [isSaving, setIsSaving] = React.useState(false)
131
+ const [isClearing, setIsClearing] = React.useState(false)
132
+
133
+ const handleSave = React.useCallback(async () => {
134
+ setIsSaving(true)
135
+ try {
136
+ await runSaveMutation({
137
+ operation: async () => {
138
+ const result = await apiCall('/api/ai_assistant/settings', {
139
+ method: 'PUT',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({
142
+ providerId: selectedProviderId || null,
143
+ modelId: selectedModelId || null,
144
+ }),
145
+ })
146
+ if (!result.ok) {
147
+ const err = (result.result as { error?: string } | null)?.error
148
+ throw new Error(err ?? t('ai_assistant.settings.saveError', 'Failed to save override.'))
149
+ }
150
+ },
151
+ context: {},
152
+ })
153
+ flash(t('ai_assistant.settings.saveSuccess', 'Default model override saved.'), 'success')
154
+ onSaved()
155
+ } finally {
156
+ setIsSaving(false)
157
+ }
158
+ }, [onSaved, runSaveMutation, selectedModelId, selectedProviderId, t])
159
+
160
+ const handleClear = React.useCallback(async () => {
161
+ setIsClearing(true)
162
+ try {
163
+ await runClearMutation({
164
+ operation: async () => {
165
+ const result = await apiCall('/api/ai_assistant/settings', {
166
+ method: 'DELETE',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({}),
169
+ })
170
+ if (!result.ok) {
171
+ const err = (result.result as { error?: string } | null)?.error
172
+ throw new Error(err ?? t('ai_assistant.settings.clearError', 'Failed to clear override.'))
173
+ }
174
+ },
175
+ context: {},
176
+ })
177
+ flash(t('ai_assistant.settings.clearSuccess', 'Default model override cleared.'), 'success')
178
+ setSelectedProviderId('')
179
+ setSelectedModelId('')
180
+ onSaved()
181
+ } finally {
182
+ setIsClearing(false)
183
+ }
184
+ }, [runClearMutation, onSaved, t])
185
+
186
+ const isBusy = isSaving || isClearing
187
+
188
+ const configuredProviders = availableProviders.filter((p) => p.configured)
189
+
190
+ return (
191
+ <div className="rounded-lg border bg-card p-6" data-ai-settings-override-form="">
192
+ <h2 className="mb-1 text-sm font-semibold">
193
+ {t('ai_assistant.settings.defaultOverrideTitle', 'Default model override')}
194
+ </h2>
195
+ <p className="mb-4 text-xs text-muted-foreground">
196
+ {t(
197
+ 'ai_assistant.settings.defaultOverrideDescription',
198
+ 'Set a tenant-wide default provider and model. Agents with a per-agent override or specific defaultModel will take precedence.',
199
+ )}
200
+ </p>
201
+ {tenantOverride && (tenantOverride.providerId || tenantOverride.modelId) ? (
202
+ <div className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
203
+ <span className="text-muted-foreground">
204
+ {t('ai_assistant.settings.currentOverride', 'Current override:')}
205
+ </span>
206
+ <span className="font-medium">
207
+ {tenantOverride.providerId ?? '—'} / {tenantOverride.modelId ?? '—'}
208
+ </span>
209
+ <Button
210
+ type="button"
211
+ variant="ghost"
212
+ size="sm"
213
+ className="ml-auto h-6 gap-1 text-xs"
214
+ disabled={isBusy}
215
+ onClick={handleClear}
216
+ data-ai-settings-clear-override=""
217
+ >
218
+ <X className="size-3" aria-hidden />
219
+ {t('ai_assistant.settings.clearOverride', 'Clear override')}
220
+ </Button>
221
+ </div>
222
+ ) : null}
223
+ <div className="flex flex-wrap items-end gap-3">
224
+ <div className="flex flex-col gap-1">
225
+ <span className="text-xs font-medium text-muted-foreground">
226
+ {t('ai_assistant.settings.providerLabel', 'Provider')}
227
+ </span>
228
+ <Select
229
+ value={selectedProviderId}
230
+ onValueChange={(val) => {
231
+ setSelectedProviderId(val)
232
+ setSelectedModelId('')
233
+ }}
234
+ disabled={isBusy}
235
+ >
236
+ <SelectTrigger
237
+ className="w-[180px]"
238
+ data-ai-settings-provider-select=""
239
+ >
240
+ <SelectValue placeholder={t('ai_assistant.settings.selectProvider', 'Select provider')} />
241
+ </SelectTrigger>
242
+ <SelectContent>
243
+ {configuredProviders.map((provider) => (
244
+ <SelectItem key={provider.id} value={provider.id}>
245
+ {provider.name}
246
+ </SelectItem>
247
+ ))}
248
+ </SelectContent>
249
+ </Select>
250
+ </div>
251
+ <div className="flex flex-col gap-1">
252
+ <span className="text-xs font-medium text-muted-foreground">
253
+ {t('ai_assistant.settings.modelLabel', 'Model')}
254
+ </span>
255
+ <Select
256
+ value={selectedModelId}
257
+ onValueChange={setSelectedModelId}
258
+ disabled={isBusy || !selectedProviderId}
259
+ >
260
+ <SelectTrigger
261
+ className="w-[220px]"
262
+ data-ai-settings-model-select=""
263
+ >
264
+ <SelectValue placeholder={t('ai_assistant.settings.selectModel', 'Select model')} />
265
+ </SelectTrigger>
266
+ <SelectContent>
267
+ {(selectedProvider?.defaultModels ?? []).map((model) => (
268
+ <SelectItem key={model.id} value={model.id}>
269
+ {model.name}
270
+ </SelectItem>
271
+ ))}
272
+ </SelectContent>
273
+ </Select>
274
+ </div>
275
+ <Button
276
+ type="button"
277
+ size="sm"
278
+ disabled={isBusy || !selectedProviderId || !selectedModelId}
279
+ onClick={handleSave}
280
+ data-ai-settings-save-override=""
281
+ >
282
+ {isSaving ? (
283
+ <Loader2 className="size-3.5 animate-spin" aria-hidden />
284
+ ) : null}
285
+ {t('ai_assistant.settings.saveOverride', 'Save override')}
286
+ </Button>
287
+ </div>
288
+ </div>
289
+ )
290
+ }
291
+
292
+ function PerAgentOverrideList({
293
+ agents,
294
+ onCleared,
295
+ }: {
296
+ agents: AgentResolution[]
297
+ onCleared: () => void
298
+ }) {
299
+ const t = useT()
300
+ const { runMutation: runClearAgentMutation } = useGuardedMutation({ contextId: 'ai-settings-clear-agent-override' })
301
+ const [clearingAgentId, setClearingAgentId] = React.useState<string | null>(null)
302
+
303
+ const handleClearAgentOverride = React.useCallback(
304
+ async (agentId: string) => {
305
+ setClearingAgentId(agentId)
306
+ try {
307
+ await runClearAgentMutation({
308
+ operation: async () => {
309
+ const result = await apiCall('/api/ai_assistant/settings', {
310
+ method: 'DELETE',
311
+ headers: { 'Content-Type': 'application/json' },
312
+ body: JSON.stringify({ agentId }),
313
+ })
314
+ if (!result.ok) {
315
+ const err = (result.result as { error?: string } | null)?.error
316
+ throw new Error(err ?? t('ai_assistant.settings.clearAgentError', 'Failed to clear agent override.'))
317
+ }
318
+ },
319
+ context: {},
320
+ })
321
+ flash(t('ai_assistant.settings.clearAgentSuccess', 'Agent override cleared.'), 'success')
322
+ onCleared()
323
+ } finally {
324
+ setClearingAgentId(null)
325
+ }
326
+ },
327
+ [runClearAgentMutation, onCleared, t],
328
+ )
329
+
330
+ if (agents.length === 0) return null
331
+
332
+ const overriddenAgents = agents.filter((agent) => agent.source !== 'env_default' && agent.source !== 'provider_default')
333
+
334
+ return (
335
+ <div className="rounded-lg border bg-card p-6" data-ai-settings-agent-overrides="">
336
+ <h2 className="mb-1 text-sm font-semibold">
337
+ {t('ai_assistant.settings.agentOverridesTitle', 'Per-agent model resolution')}
338
+ </h2>
339
+ <p className="mb-4 text-xs text-muted-foreground">
340
+ {t(
341
+ 'ai_assistant.settings.agentOverridesDescription',
342
+ 'Resolved model for each registered agent. Agents with a custom override show a Clear button.',
343
+ )}
344
+ </p>
345
+ <div className="overflow-x-auto">
346
+ <table className="w-full text-xs">
347
+ <thead>
348
+ <tr className="border-b border-border text-left text-muted-foreground">
349
+ <th className="pb-2 pr-4 font-medium">
350
+ {t('ai_assistant.settings.agentIdColumn', 'Agent')}
351
+ </th>
352
+ <th className="pb-2 pr-4 font-medium">
353
+ {t('ai_assistant.settings.providerColumn', 'Provider')}
354
+ </th>
355
+ <th className="pb-2 pr-4 font-medium">
356
+ {t('ai_assistant.settings.modelColumn', 'Model')}
357
+ </th>
358
+ <th className="pb-2 pr-4 font-medium">
359
+ {t('ai_assistant.settings.sourceColumn', 'Source')}
360
+ </th>
361
+ <th className="pb-2 font-medium" />
362
+ </tr>
363
+ </thead>
364
+ <tbody>
365
+ {agents.map((agent) => {
366
+ const hasOverride = overriddenAgents.some((a) => a.agentId === agent.agentId)
367
+ return (
368
+ <tr
369
+ key={agent.agentId}
370
+ className="border-b border-border/50 last:border-0"
371
+ data-ai-settings-agent-row={agent.agentId}
372
+ >
373
+ <td className="py-2 pr-4 font-mono">{agent.agentId}</td>
374
+ <td className="py-2 pr-4">{agent.providerId}</td>
375
+ <td className="py-2 pr-4">{agent.modelId}</td>
376
+ <td className="py-2 pr-4 text-muted-foreground">{agent.source}</td>
377
+ <td className="py-2">
378
+ {hasOverride ? (
379
+ <Button
380
+ type="button"
381
+ variant="ghost"
382
+ size="sm"
383
+ className="h-6 gap-1 text-xs"
384
+ disabled={clearingAgentId !== null}
385
+ onClick={() => void handleClearAgentOverride(agent.agentId)}
386
+ data-ai-settings-clear-agent-override={agent.agentId}
387
+ >
388
+ <X className="size-3" aria-hidden />
389
+ {t('ai_assistant.settings.clearOverride', 'Clear override')}
390
+ </Button>
391
+ ) : null}
392
+ </td>
393
+ </tr>
394
+ )
395
+ })}
396
+ </tbody>
397
+ </table>
398
+ </div>
399
+ </div>
400
+ )
401
+ }
402
+
403
+ function AiAssistantLauncherCard({
404
+ launchMode,
405
+ showVisibilityControl,
406
+ }: {
407
+ launchMode: 'selector' | 'legacy'
408
+ showVisibilityControl: boolean
409
+ }) {
410
+ const t = useT()
78
411
  const { isEnabled, toggleEnabled, isLoaded } = useAiAssistantVisibility()
79
412
 
80
- // Open AI Assistant by dispatching global event (triggers main layout's DockableChat)
81
413
  const openAiAssistant = () => {
82
- window.dispatchEvent(new CustomEvent('om:open-ai-chat'))
414
+ const eventName = launchMode === 'legacy'
415
+ ? LEGACY_AI_ASSISTANT_OPEN_EVENT
416
+ : AI_ASSISTANT_LAUNCHER_OPEN_EVENT
417
+ window.dispatchEvent(new CustomEvent(eventName))
83
418
  }
84
419
 
85
- // Health query - polls every 10 seconds
420
+ return (
421
+ <div className="rounded-lg border bg-card p-6">
422
+ <div className="flex items-start justify-between gap-4">
423
+ <div className="space-y-1">
424
+ <h2 className="flex items-center gap-2 text-lg font-semibold">
425
+ <Bot className="size-5" />
426
+ {t('ai_assistant.settings.visibilityTitle', 'AI Assistant')}
427
+ </h2>
428
+ <p className="text-sm text-muted-foreground">
429
+ {showVisibilityControl
430
+ ? isEnabled
431
+ ? t('ai_assistant.settings.visibilityEnabled', 'Visible in header with Cmd+J shortcut enabled.')
432
+ : t('ai_assistant.settings.visibilityDisabled', 'Hidden from header. Enable to show the button and Cmd+J shortcut.')
433
+ : t('ai_assistant.settings.launchDescription', 'Open the AI assistant from this page.')}
434
+ </p>
435
+ </div>
436
+ <div className="flex items-center gap-4">
437
+ {showVisibilityControl ? (
438
+ <div className="flex items-center gap-3 rounded-md border bg-muted/30 px-4 py-2">
439
+ <span className="text-sm font-medium">
440
+ {t('ai_assistant.settings.visibilityToggleLabel', 'Visibility')}
441
+ </span>
442
+ {isEnabled ? (
443
+ <Eye className="size-4 text-muted-foreground" />
444
+ ) : (
445
+ <EyeOff className="size-4 text-muted-foreground" />
446
+ )}
447
+ <Switch
448
+ checked={isEnabled}
449
+ onCheckedChange={toggleEnabled}
450
+ disabled={!isLoaded}
451
+ />
452
+ </div>
453
+ ) : null}
454
+ <Button type="button" onClick={openAiAssistant} size="default" className="gap-2">
455
+ <Bot className="size-4" />
456
+ {launchMode === 'legacy'
457
+ ? t('ai_assistant.settings.openButton', 'Open AI Assistant')
458
+ : t('ai_assistant.settings.openSelectorButton', 'Open AI assistants')}
459
+ </Button>
460
+ </div>
461
+ </div>
462
+ </div>
463
+ )
464
+ }
465
+
466
+ function AiAssistantSettingsContent({
467
+ launchMode,
468
+ showVisibilityControl,
469
+ }: {
470
+ launchMode: 'selector' | 'legacy'
471
+ showVisibilityControl: boolean
472
+ }) {
473
+ const t = useT()
474
+ const queryClient = useQueryClient()
475
+ const [toolsExpanded, setToolsExpanded] = React.useState(false)
476
+ const [mcpConfigOpen, setMcpConfigOpen] = React.useState(false)
477
+ const [sessionKeyOpen, setSessionKeyOpen] = React.useState(false)
478
+
86
479
  const healthQuery = useQuery({
87
480
  queryKey: ['ai-assistant', 'health'],
88
481
  queryFn: fetchHealth,
@@ -90,36 +483,37 @@ function AiAssistantSettingsContent() {
90
483
  staleTime: 5000,
91
484
  })
92
485
 
93
- // Settings query - no polling needed (static config)
94
486
  const settingsQuery = useQuery({
95
487
  queryKey: ['ai-assistant', 'settings'],
96
488
  queryFn: fetchSettings,
97
489
  staleTime: 60000,
98
490
  })
99
491
 
100
- // Tools query - no polling needed
101
492
  const toolsQuery = useQuery({
102
493
  queryKey: ['ai-assistant', 'tools'],
103
494
  queryFn: fetchTools,
104
495
  staleTime: 60000,
105
496
  })
106
497
 
498
+ const handleOverrideSaved = React.useCallback(() => {
499
+ void queryClient.invalidateQueries({ queryKey: ['ai-assistant', 'settings'] })
500
+ }, [queryClient])
501
+
107
502
  const isLoading = healthQuery.isLoading || settingsQuery.isLoading || toolsQuery.isLoading
108
503
 
109
504
  if (isLoading) {
110
505
  return (
111
- <div className="flex items-center gap-2 text-muted-foreground py-8">
112
- <Loader2 className="h-4 w-4 animate-spin" />
113
- Loading settings...
506
+ <div className="flex items-center gap-2 py-8 text-muted-foreground">
507
+ <Loader2 className="size-4 animate-spin" />
508
+ {t('ai_assistant.settings.loading', 'Loading settings...')}
114
509
  </div>
115
510
  )
116
511
  }
117
512
 
118
513
  const health = healthQuery.data
119
514
  const settings = settingsQuery.data
120
- const tools = toolsQuery.data?.tools || []
515
+ const tools = toolsQuery.data?.tools ?? []
121
516
 
122
- // Group tools by module
123
517
  const toolsByModule = tools.reduce<Record<string, ToolInfo[]>>((acc, tool) => {
124
518
  const module = tool.module || 'other'
125
519
  if (!acc[module]) acc[module] = []
@@ -130,264 +524,266 @@ function AiAssistantSettingsContent() {
130
524
  const provider = settings?.provider
131
525
 
132
526
  return (
133
- <div className="flex flex-col gap-6">
134
- {/* Header */}
527
+ <div className="flex flex-col gap-6" data-ai-assistant-settings="">
135
528
  <div className="space-y-1">
136
- <h1 className="text-2xl font-bold flex items-center gap-2">
137
- <Bot className="h-6 w-6" />
138
- AI Assistant Settings
529
+ <h1 className="flex items-center gap-2 text-2xl font-bold">
530
+ <Bot className="size-6" />
531
+ {t('ai_assistant.settings.pageTitle', 'AI Assistant Settings')}
139
532
  </h1>
140
533
  <p className="text-muted-foreground">
141
- Configure and monitor the AI assistant
534
+ {t('ai_assistant.settings.pageDescription', 'Configure and monitor the AI assistant')}
142
535
  </p>
143
536
  </div>
144
537
 
145
- {/* AI Assistant Section - Visibility toggle + Test button */}
146
- <div className="rounded-lg border bg-card p-6">
147
- <div className="flex items-start justify-between">
148
- <div className="space-y-1">
149
- <h2 className="text-lg font-semibold flex items-center gap-2">
150
- <Bot className="h-5 w-5" />
151
- AI Assistant
152
- </h2>
153
- <p className="text-sm text-muted-foreground">
154
- {isEnabled
155
- ? 'Visible in header with Cmd+J shortcut enabled.'
156
- : 'Hidden from header. Enable to show the button and Cmd+J shortcut.'}
157
- </p>
158
- </div>
159
- <div className="flex items-center gap-4">
160
- <div className="flex items-center gap-3 px-4 py-2 rounded-md border bg-muted/30">
161
- <span className="text-sm font-medium">Visibility</span>
162
- {isEnabled ? <Eye className="h-4 w-4 text-muted-foreground" /> : <EyeOff className="h-4 w-4 text-muted-foreground" />}
163
- <Switch
164
- checked={isEnabled}
165
- onCheckedChange={toggleEnabled}
166
- disabled={!isLoaded}
167
- />
168
- </div>
169
- <Button onClick={openAiAssistant} size="default" className="gap-2">
170
- <Bot className="h-4 w-4" />
171
- Open AI Assistant
172
- </Button>
173
- </div>
174
- </div>
175
- </div>
538
+ <AiAssistantLauncherCard
539
+ launchMode={launchMode}
540
+ showVisibilityControl={showVisibilityControl}
541
+ />
542
+
543
+ {settings ? (
544
+ <GlobalOverrideForm
545
+ availableProviders={settings.availableProviders}
546
+ tenantOverride={settings.tenantOverride}
547
+ onSaved={handleOverrideSaved}
548
+ />
549
+ ) : null}
550
+
551
+ {settings?.agents && settings.agents.length > 0 ? (
552
+ <PerAgentOverrideList
553
+ agents={settings.agents}
554
+ onCleared={handleOverrideSaved}
555
+ />
556
+ ) : null}
176
557
 
177
- {/* Connections Section */}
178
558
  <div className="rounded-lg border bg-card p-6">
179
- <div className="flex items-center justify-between mb-4">
180
- <h2 className="text-sm font-semibold flex items-center gap-2">
181
- <Link2 className="h-4 w-4" />
182
- Connections
559
+ <div className="mb-4 flex items-center justify-between">
560
+ <h2 className="flex items-center gap-2 text-sm font-semibold">
561
+ <Link2 className="size-4" />
562
+ {t('ai_assistant.settings.connectionsTitle', 'Connections')}
183
563
  </h2>
184
- {healthQuery.isFetching && !healthQuery.isLoading && (
185
- <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
186
- )}
564
+ {healthQuery.isFetching && !healthQuery.isLoading ? (
565
+ <Loader2 className="size-3 animate-spin text-muted-foreground" />
566
+ ) : null}
187
567
  </div>
188
568
  <div className="grid gap-4 sm:grid-cols-3">
189
- {/* OpenCode Server Status */}
190
- <div className={`p-4 rounded-lg border-2 ${
191
- health?.status === 'ok' && health.opencode?.healthy
192
- ? 'border-status-success-border bg-status-success-bg'
193
- : 'border-destructive/50 bg-destructive/5'
194
- }`}>
569
+ <div
570
+ className={`rounded-lg border-2 p-4 ${
571
+ health?.status === 'ok' && health.opencode?.healthy
572
+ ? 'border-status-success-border bg-status-success-bg'
573
+ : 'border-destructive/50 bg-destructive/5'
574
+ }`}
575
+ >
195
576
  <div className="flex items-start justify-between">
196
- <div className="flex-1 min-w-0">
577
+ <div className="min-w-0 flex-1">
197
578
  <div className="flex items-center gap-2">
198
- <Server className="h-4 w-4 text-muted-foreground shrink-0" />
579
+ <Server className="size-4 shrink-0 text-muted-foreground" />
199
580
  <p className="text-sm font-medium">OpenCode</p>
200
581
  </div>
201
- <p className="text-xs text-muted-foreground mt-1">
582
+ <p className="mt-1 text-xs text-muted-foreground">
202
583
  {health?.status === 'ok' && health.opencode?.healthy ? (
203
584
  <span className="flex items-center gap-1 text-status-success-text">
204
- <CheckCircle2 className="h-3 w-3" />
205
- Connected
585
+ <CheckCircle2 className="size-3" />
586
+ {t('ai_assistant.settings.connected', 'Connected')}
206
587
  </span>
207
588
  ) : (
208
589
  <span className="flex items-center gap-1 text-destructive">
209
- <XCircle className="h-3 w-3" />
210
- {health?.message || 'Disconnected'}
590
+ <XCircle className="size-3" />
591
+ {health?.message ?? t('ai_assistant.settings.disconnected', 'Disconnected')}
211
592
  </span>
212
593
  )}
213
594
  </p>
214
- {health?.opencode?.version && (
215
- <p className="text-xs text-muted-foreground mt-1">
216
- v{health.opencode.version}
217
- </p>
218
- )}
219
- <p className="text-xs text-muted-foreground mt-1 truncate">
220
- {health?.url || 'Not configured'}
595
+ {health?.opencode?.version ? (
596
+ <p className="mt-1 text-xs text-muted-foreground">v{health.opencode.version}</p>
597
+ ) : null}
598
+ <p className="mt-1 truncate text-xs text-muted-foreground">
599
+ {health?.url ?? t('ai_assistant.settings.notConfigured', 'Not configured')}
221
600
  </p>
222
601
  </div>
223
- {health?.status === 'ok' && health.opencode?.healthy && (
224
- <div className="flex h-5 w-5 items-center justify-center rounded-full bg-status-success-bg text-status-success-icon shrink-0">
225
- <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
602
+ {health?.status === 'ok' && health.opencode?.healthy ? (
603
+ <div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-status-success-bg text-status-success-icon">
604
+ <svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
226
605
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
227
606
  </svg>
228
607
  </div>
229
- )}
608
+ ) : null}
230
609
  </div>
231
610
  </div>
232
611
 
233
- {/* MCP Server Status */}
234
612
  {(() => {
235
- const mcpConnected = health?.mcp && Object.values(health.mcp).some(s => s.status === 'connected')
236
- const mcpConnecting = health?.mcp && Object.values(health.mcp).some(s => s.status === 'connecting')
237
- const mcpError = health?.mcp && Object.values(health.mcp).find(s => s.error)?.error
238
-
613
+ const mcpConnected =
614
+ health?.mcp && Object.values(health.mcp).some((s) => s.status === 'connected')
615
+ const mcpConnecting =
616
+ health?.mcp && Object.values(health.mcp).some((s) => s.status === 'connecting')
617
+ const mcpError =
618
+ health?.mcp && Object.values(health.mcp).find((s) => s.error)?.error
239
619
  return (
240
- <div className={`p-4 rounded-lg border-2 ${
241
- mcpConnected
242
- ? 'border-status-success-border bg-status-success-bg'
243
- : mcpConnecting
244
- ? 'border-status-warning-border bg-status-warning-bg'
245
- : 'border-destructive/50 bg-destructive/5'
246
- }`}>
620
+ <div
621
+ className={`rounded-lg border-2 p-4 ${
622
+ mcpConnected
623
+ ? 'border-status-success-border bg-status-success-bg'
624
+ : mcpConnecting
625
+ ? 'border-status-warning-border bg-status-warning-bg'
626
+ : 'border-destructive/50 bg-destructive/5'
627
+ }`}
628
+ >
247
629
  <div className="flex items-start justify-between">
248
- <div className="flex-1 min-w-0">
630
+ <div className="min-w-0 flex-1">
249
631
  <div className="flex items-center gap-2">
250
- <Wrench className="h-4 w-4 text-muted-foreground shrink-0" />
632
+ <Wrench className="size-4 shrink-0 text-muted-foreground" />
251
633
  <p className="text-sm font-medium">MCP Server</p>
252
634
  </div>
253
- <p className="text-xs text-muted-foreground mt-1">
635
+ <p className="mt-1 text-xs text-muted-foreground">
254
636
  {mcpConnected ? (
255
637
  <span className="flex items-center gap-1 text-status-success-text">
256
- <CheckCircle2 className="h-3 w-3" />
257
- Connected
638
+ <CheckCircle2 className="size-3" />
639
+ {t('ai_assistant.settings.connected', 'Connected')}
258
640
  </span>
259
641
  ) : mcpConnecting ? (
260
642
  <span className="flex items-center gap-1 text-status-warning-text">
261
- <Loader2 className="h-3 w-3 animate-spin" />
262
- Connecting...
643
+ <Loader2 className="size-3 animate-spin" />
644
+ {t('ai_assistant.settings.connecting', 'Connecting...')}
263
645
  </span>
264
646
  ) : (
265
647
  <span className="flex items-center gap-1 text-destructive">
266
- <XCircle className="h-3 w-3" />
267
- {mcpError || 'Disconnected'}
648
+ <XCircle className="size-3" />
649
+ {mcpError ?? t('ai_assistant.settings.disconnected', 'Disconnected')}
268
650
  </span>
269
651
  )}
270
652
  </p>
271
- <p className="text-xs text-muted-foreground mt-1 truncate">
272
- {health?.mcpUrl || 'Not configured'}
653
+ <p className="mt-1 truncate text-xs text-muted-foreground">
654
+ {health?.mcpUrl ?? t('ai_assistant.settings.notConfigured', 'Not configured')}
273
655
  </p>
274
656
  </div>
275
- {mcpConnected && (
276
- <div className="flex h-5 w-5 items-center justify-center rounded-full bg-status-success-bg text-status-success-icon shrink-0">
277
- <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
657
+ {mcpConnected ? (
658
+ <div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-status-success-bg text-status-success-icon">
659
+ <svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
278
660
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
279
661
  </svg>
280
662
  </div>
281
- )}
663
+ ) : null}
282
664
  </div>
283
665
  </div>
284
666
  )
285
667
  })()}
286
668
 
287
- {/* Meilisearch Status */}
288
- <div className={`p-4 rounded-lg border-2 ${
289
- health?.search?.available
290
- ? 'border-status-success-border bg-status-success-bg'
291
- : 'border-destructive/50 bg-destructive/5'
292
- }`}>
669
+ <div
670
+ className={`rounded-lg border-2 p-4 ${
671
+ health?.search?.available
672
+ ? 'border-status-success-border bg-status-success-bg'
673
+ : 'border-destructive/50 bg-destructive/5'
674
+ }`}
675
+ >
293
676
  <div className="flex items-start justify-between">
294
- <div className="flex-1 min-w-0">
677
+ <div className="min-w-0 flex-1">
295
678
  <div className="flex items-center gap-2">
296
- <Database className="h-4 w-4 text-muted-foreground shrink-0" />
679
+ <Database className="size-4 shrink-0 text-muted-foreground" />
297
680
  <p className="text-sm font-medium">Meilisearch</p>
298
681
  </div>
299
- <p className="text-xs text-muted-foreground mt-1">
682
+ <p className="mt-1 text-xs text-muted-foreground">
300
683
  {health?.search?.available ? (
301
684
  <span className="flex items-center gap-1 text-status-success-text">
302
- <CheckCircle2 className="h-3 w-3" />
303
- Connected
685
+ <CheckCircle2 className="size-3" />
686
+ {t('ai_assistant.settings.connected', 'Connected')}
304
687
  </span>
305
688
  ) : (
306
689
  <span className="flex items-center gap-1 text-destructive">
307
- <XCircle className="h-3 w-3" />
308
- Not available
690
+ <XCircle className="size-3" />
691
+ {t('ai_assistant.settings.notAvailable', 'Not available')}
309
692
  </span>
310
693
  )}
311
694
  </p>
312
- <p className="text-xs text-muted-foreground mt-1 truncate">
313
- {health?.search?.url || 'Not configured'}
695
+ <p className="mt-1 truncate text-xs text-muted-foreground">
696
+ {health?.search?.url ?? t('ai_assistant.settings.notConfigured', 'Not configured')}
314
697
  </p>
315
698
  </div>
316
- {health?.search?.available && (
317
- <div className="flex h-5 w-5 items-center justify-center rounded-full bg-status-success-bg text-status-success-icon shrink-0">
318
- <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
699
+ {health?.search?.available ? (
700
+ <div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-status-success-bg text-status-success-icon">
701
+ <svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
319
702
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
320
703
  </svg>
321
704
  </div>
322
- )}
705
+ ) : null}
323
706
  </div>
324
707
  </div>
325
708
  </div>
326
709
 
327
- {/* MCP Authentication Status */}
328
- <div className="mt-4 p-3 rounded-md bg-muted/30 border">
710
+ <div className="mt-4 rounded-md border bg-muted/30 p-3">
329
711
  <div className="flex items-center gap-2 text-sm">
330
- <Key className="h-4 w-4 text-muted-foreground" />
331
- <span className="font-medium">MCP Authentication:</span>
712
+ <Key className="size-4 text-muted-foreground" />
713
+ <span className="font-medium">
714
+ {t('ai_assistant.settings.mcpAuthLabel', 'MCP Authentication:')}
715
+ </span>
332
716
  {settings?.mcpKeyConfigured ? (
333
- <span className="flex items-center gap-1 text-status-success-text text-xs">
334
- <CheckCircle2 className="h-3 w-3" />
335
- MCP_SERVER_API_KEY configured
717
+ <span className="flex items-center gap-1 text-xs text-status-success-text">
718
+ <CheckCircle2 className="size-3" />
719
+ {t('ai_assistant.settings.mcpKeyConfigured', 'MCP_SERVER_API_KEY configured')}
336
720
  </span>
337
721
  ) : (
338
- <span className="flex items-center gap-1 text-status-warning-text text-xs">
339
- <XCircle className="h-3 w-3" />
340
- MCP_SERVER_API_KEY not set
722
+ <span className="flex items-center gap-1 text-xs text-status-warning-text">
723
+ <XCircle className="size-3" />
724
+ {t('ai_assistant.settings.mcpKeyMissing', 'MCP_SERVER_API_KEY not set')}
341
725
  </span>
342
726
  )}
343
727
  </div>
344
- <p className="text-xs text-muted-foreground mt-1 ml-6">
345
- Required for AI to access platform tools via MCP server.
728
+ <p className="ml-6 mt-1 text-xs text-muted-foreground">
729
+ {t(
730
+ 'ai_assistant.settings.mcpAuthNote',
731
+ 'Required for AI to access platform tools via MCP server.',
732
+ )}
346
733
  </p>
347
734
  </div>
348
735
 
349
- {/* LLM Provider Status */}
350
- <div className="mt-4 p-3 rounded-md bg-muted/30 border">
736
+ <div className="mt-4 rounded-md border bg-muted/30 p-3">
351
737
  <div className="flex items-center gap-2 text-sm">
352
- <Server className="h-4 w-4 text-muted-foreground" />
353
- <span className="font-medium">LLM Provider:</span>
354
- <span className="font-medium">{provider?.name || 'Anthropic'}</span>
738
+ <Server className="size-4 text-muted-foreground" />
739
+ <span className="font-medium">
740
+ {t('ai_assistant.settings.llmProviderLabel', 'LLM Provider:')}
741
+ </span>
742
+ <span className="font-medium">{provider?.name ?? 'Anthropic'}</span>
355
743
  {provider?.configured ? (
356
- <span className="flex items-center gap-1 text-status-success-text text-xs">
357
- <CheckCircle2 className="h-3 w-3" />
358
- {provider?.envKey} configured
744
+ <span className="flex items-center gap-1 text-xs text-status-success-text">
745
+ <CheckCircle2 className="size-3" />
746
+ {provider?.envKey
747
+ ? t('ai_assistant.settings.envKeyConfigured', '{{key}} configured', { key: provider.envKey })
748
+ : t('ai_assistant.settings.configured', 'Configured')}
359
749
  </span>
360
750
  ) : (
361
- <span className="flex items-center gap-1 text-status-warning-text text-xs">
362
- <XCircle className="h-3 w-3" />
363
- {provider?.envKey || 'ANTHROPIC_API_KEY'} not set
751
+ <span className="flex items-center gap-1 text-xs text-status-warning-text">
752
+ <XCircle className="size-3" />
753
+ {t('ai_assistant.settings.envKeyMissing', '{{key}} not set', { key: provider?.envKey ?? 'ANTHROPIC_API_KEY' })}
364
754
  </span>
365
755
  )}
366
756
  </div>
367
757
  </div>
368
758
 
369
- <p className="text-xs text-muted-foreground mt-4">
370
- Meilisearch is required for API endpoint discovery. Endpoints are indexed automatically when the MCP server starts.
759
+ <p className="mt-4 text-xs text-muted-foreground">
760
+ {t(
761
+ 'ai_assistant.settings.meilisearchNote',
762
+ 'Meilisearch is required for API endpoint discovery. Endpoints are indexed automatically when the MCP server starts.',
763
+ )}
371
764
  </p>
372
765
  </div>
373
766
 
374
- {/* Developer Tools Section */}
375
767
  <div className="rounded-lg border bg-card p-6">
376
- <h2 className="text-sm font-semibold mb-4 flex items-center gap-2">
377
- <Settings className="h-4 w-4" />
378
- Developer Tools
768
+ <h2 className="mb-4 flex items-center gap-2 text-sm font-semibold">
769
+ <Settings className="size-4" />
770
+ {t('ai_assistant.settings.developerToolsTitle', 'Developer Tools')}
379
771
  </h2>
380
772
  <div className="grid gap-4 sm:grid-cols-2">
381
- {/* MCP Config Generator */}
382
- <div className="p-4 rounded-lg border bg-muted/30">
773
+ <div className="rounded-lg border bg-muted/30 p-4">
383
774
  <div className="flex items-start gap-3">
384
- <div className="p-2 rounded-md bg-primary/10">
385
- <Settings className="h-5 w-5 text-primary" />
775
+ <div className="rounded-md bg-primary/10 p-2">
776
+ <Settings className="size-5 text-primary" />
386
777
  </div>
387
- <div className="flex-1 min-w-0">
388
- <h3 className="text-sm font-medium">MCP Configuration</h3>
389
- <p className="text-xs text-muted-foreground mt-1">
390
- Generate config for Claude Code or other MCP clients.
778
+ <div className="min-w-0 flex-1">
779
+ <h3 className="text-sm font-medium">
780
+ {t('ai_assistant.settings.mcpConfigTitle', 'MCP Configuration')}
781
+ </h3>
782
+ <p className="mt-1 text-xs text-muted-foreground">
783
+ {t(
784
+ 'ai_assistant.settings.mcpConfigDescription',
785
+ 'Generate config for Claude Code or other MCP clients.',
786
+ )}
391
787
  </p>
392
788
  <Button
393
789
  size="sm"
@@ -395,22 +791,26 @@ function AiAssistantSettingsContent() {
395
791
  className="mt-3"
396
792
  onClick={() => setMcpConfigOpen(true)}
397
793
  >
398
- Generate MCP Config
794
+ {t('ai_assistant.settings.generateMcpConfig', 'Generate MCP Config')}
399
795
  </Button>
400
796
  </div>
401
797
  </div>
402
798
  </div>
403
799
 
404
- {/* Session Key Generator */}
405
- <div className="p-4 rounded-lg border bg-muted/30">
800
+ <div className="rounded-lg border bg-muted/30 p-4">
406
801
  <div className="flex items-start gap-3">
407
- <div className="p-2 rounded-md bg-primary/10">
408
- <Key className="h-5 w-5 text-primary" />
802
+ <div className="rounded-md bg-primary/10 p-2">
803
+ <Key className="size-5 text-primary" />
409
804
  </div>
410
- <div className="flex-1 min-w-0">
411
- <h3 className="text-sm font-medium">Session API Key</h3>
412
- <p className="text-xs text-muted-foreground mt-1">
413
- Generate a temporary token for programmatic LLM access. Expires after 2 hours.
805
+ <div className="min-w-0 flex-1">
806
+ <h3 className="text-sm font-medium">
807
+ {t('ai_assistant.settings.sessionKeyTitle', 'Session API Key')}
808
+ </h3>
809
+ <p className="mt-1 text-xs text-muted-foreground">
810
+ {t(
811
+ 'ai_assistant.settings.sessionKeyDescription',
812
+ 'Generate a temporary token for programmatic LLM access. Expires after 2 hours.',
813
+ )}
414
814
  </p>
415
815
  <Button
416
816
  size="sm"
@@ -418,7 +818,7 @@ function AiAssistantSettingsContent() {
418
818
  className="mt-3"
419
819
  onClick={() => setSessionKeyOpen(true)}
420
820
  >
421
- Generate Session Key
821
+ {t('ai_assistant.settings.generateSessionKey', 'Generate Session Key')}
422
822
  </Button>
423
823
  </div>
424
824
  </div>
@@ -426,44 +826,44 @@ function AiAssistantSettingsContent() {
426
826
  </div>
427
827
  </div>
428
828
 
429
- {/* Dialogs */}
430
829
  <McpConfigDialog
431
830
  open={mcpConfigOpen}
432
831
  onOpenChange={setMcpConfigOpen}
433
- mcpUrl={health?.mcpUrl || 'http://localhost:3001'}
832
+ mcpUrl={health?.mcpUrl ?? 'http://localhost:3001'}
434
833
  />
435
834
  <SessionKeyDialog
436
835
  open={sessionKeyOpen}
437
836
  onOpenChange={setSessionKeyOpen}
438
837
  />
439
838
 
440
- {/* MCP Tools Section */}
441
839
  <div className="rounded-lg border bg-card p-6">
442
840
  <button
443
- onClick={() => setToolsExpanded(!toolsExpanded)}
444
- className="w-full flex items-center justify-between text-left"
841
+ type="button"
842
+ onClick={() => setToolsExpanded((prev) => !prev)}
843
+ className="flex w-full items-center justify-between text-left"
445
844
  >
446
- <h2 className="text-sm font-semibold flex items-center gap-2">
447
- <Wrench className="h-4 w-4" />
448
- MCP Tools ({tools.length} tools)
845
+ <h2 className="flex items-center gap-2 text-sm font-semibold">
846
+ <Wrench className="size-4" />
847
+ {t('ai_assistant.settings.mcpToolsTitle', 'MCP Tools')} ({tools.length}{' '}
848
+ {t('ai_assistant.settings.mcpToolsCount', 'tools')})
449
849
  </h2>
450
850
  {toolsExpanded ? (
451
- <ChevronDown className="h-4 w-4 text-muted-foreground" />
851
+ <ChevronDown className="size-4 text-muted-foreground" />
452
852
  ) : (
453
- <ChevronRight className="h-4 w-4 text-muted-foreground" />
853
+ <ChevronRight className="size-4 text-muted-foreground" />
454
854
  )}
455
855
  </button>
456
856
 
457
- {toolsExpanded && (
857
+ {toolsExpanded ? (
458
858
  <div className="mt-4 space-y-4">
459
859
  {Object.entries(toolsByModule).map(([module, moduleTools]) => (
460
860
  <div key={module} className="space-y-2">
461
- <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
861
+ <h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
462
862
  {module}
463
863
  </h3>
464
864
  <div className="space-y-1">
465
865
  {moduleTools.map((tool) => (
466
- <div key={tool.name} className="pl-2 border-l-2 border-muted py-1">
866
+ <div key={tool.name} className="border-l-2 border-muted py-1 pl-2">
467
867
  <p className="text-sm font-medium">{tool.name}</p>
468
868
  <p className="text-xs text-muted-foreground">{tool.description}</p>
469
869
  </div>
@@ -472,14 +872,22 @@ function AiAssistantSettingsContent() {
472
872
  </div>
473
873
  ))}
474
874
  </div>
475
- )}
875
+ ) : null}
476
876
  </div>
477
877
  </div>
478
878
  )
479
879
  }
480
880
 
481
- export function AiAssistantSettingsPageClient() {
482
- return <AiAssistantSettingsContent />
881
+ export function AiAssistantSettingsPageClient({
882
+ launchMode = 'selector',
883
+ showVisibilityControl = false,
884
+ }: AiAssistantSettingsPageClientProps) {
885
+ return (
886
+ <AiAssistantSettingsContent
887
+ launchMode={launchMode}
888
+ showVisibilityControl={showVisibilityControl}
889
+ />
890
+ )
483
891
  }
484
892
 
485
893
  export default AiAssistantSettingsPageClient