@actuate-media/cms-admin 0.32.0 → 0.34.0
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.
- package/dist/__tests__/views/ai-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/ai-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/ai-settings.render.test.js +182 -0
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +152 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts +15 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +17 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +3 -47
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts +6 -0
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -0
- package/dist/views/settings/AISettingsTab.js +138 -0
- package/dist/views/settings/AISettingsTab.js.map +1 -0
- package/dist/views/settings/AiFeaturesCard.d.ts +12 -0
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
- package/dist/views/settings/AiFeaturesCard.js +14 -0
- package/dist/views/settings/AiFeaturesCard.js.map +1 -0
- package/dist/views/settings/CapabilityBadges.d.ts +5 -0
- package/dist/views/settings/CapabilityBadges.d.ts.map +1 -0
- package/dist/views/settings/CapabilityBadges.js +26 -0
- package/dist/views/settings/CapabilityBadges.js.map +1 -0
- package/dist/views/settings/ModelSelector.d.ts +12 -0
- package/dist/views/settings/ModelSelector.d.ts.map +1 -0
- package/dist/views/settings/ModelSelector.js +29 -0
- package/dist/views/settings/ModelSelector.js.map +1 -0
- package/dist/views/settings/MonthlyUsageCard.d.ts +14 -0
- package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -0
- package/dist/views/settings/MonthlyUsageCard.js +30 -0
- package/dist/views/settings/MonthlyUsageCard.js.map +1 -0
- package/dist/views/settings/ProviderConnectionCard.d.ts +13 -0
- package/dist/views/settings/ProviderConnectionCard.d.ts.map +1 -0
- package/dist/views/settings/ProviderConnectionCard.js +48 -0
- package/dist/views/settings/ProviderConnectionCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/SeoSettingsTab.js +126 -0
- package/dist/views/settings/SeoSettingsTab.js.map +1 -0
- package/dist/views/settings/useAiSettings.d.ts +143 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -0
- package/dist/views/settings/useAiSettings.js +278 -0
- package/dist/views/settings/useAiSettings.js.map +1 -0
- package/dist/views/settings/useSeoSettings.d.ts +79 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
- package/dist/views/settings/useSeoSettings.js +278 -0
- package/dist/views/settings/useSeoSettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/ai-settings.render.test.tsx +225 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
- package/src/components/MediaPickerModal.tsx +39 -2
- package/src/views/Settings.tsx +4 -141
- package/src/views/settings/AISettingsTab.tsx +411 -0
- package/src/views/settings/AiFeaturesCard.tsx +49 -0
- package/src/views/settings/CapabilityBadges.tsx +66 -0
- package/src/views/settings/ModelSelector.tsx +115 -0
- package/src/views/settings/MonthlyUsageCard.tsx +118 -0
- package/src/views/settings/ProviderConnectionCard.tsx +245 -0
- package/src/views/settings/SeoSettingsTab.tsx +658 -0
- package/src/views/settings/useAiSettings.ts +448 -0
- package/src/views/settings/useSeoSettings.ts +374 -0
package/src/views/Settings.tsx
CHANGED
|
@@ -22,8 +22,9 @@ import { toast } from 'sonner'
|
|
|
22
22
|
import { useApiData } from '../lib/useApiData.js'
|
|
23
23
|
import { cmsApi } from '../lib/api.js'
|
|
24
24
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
25
|
-
import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
|
|
26
25
|
import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
|
|
26
|
+
import { SeoSettingsTab } from './settings/SeoSettingsTab.js'
|
|
27
|
+
import { AISettingsTab } from './settings/AISettingsTab.js'
|
|
27
28
|
import { AppearanceTab } from './settings/AppearanceTab.js'
|
|
28
29
|
import { SecurityTab } from './settings/SecurityTab.js'
|
|
29
30
|
import { Users as UsersView } from './Users.js'
|
|
@@ -314,11 +315,11 @@ export function Settings({
|
|
|
314
315
|
</Tabs.Content>
|
|
315
316
|
|
|
316
317
|
<Tabs.Content value="seo" className="space-y-4">
|
|
317
|
-
<
|
|
318
|
+
<SeoSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
|
|
318
319
|
</Tabs.Content>
|
|
319
320
|
|
|
320
321
|
<Tabs.Content value="ai" className="space-y-4">
|
|
321
|
-
<
|
|
322
|
+
<AISettingsTab role={session?.user?.role} />
|
|
322
323
|
</Tabs.Content>
|
|
323
324
|
|
|
324
325
|
<Tabs.Content value="users">
|
|
@@ -350,144 +351,6 @@ export function Settings({
|
|
|
350
351
|
)
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
// ---------------------------------------------------------------------------
|
|
354
|
-
// AI Status (read-only)
|
|
355
|
-
// ---------------------------------------------------------------------------
|
|
356
|
-
|
|
357
|
-
interface AiStatus {
|
|
358
|
-
installed: boolean
|
|
359
|
-
configured: boolean
|
|
360
|
-
provider: string | null
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const PROVIDER_LABELS: Record<string, string> = {
|
|
364
|
-
anthropic: 'Anthropic (Claude)',
|
|
365
|
-
openai: 'OpenAI (GPT)',
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Honest, read-only view of the AI integration. AI is configured server-side
|
|
370
|
-
* via the `@actuate-media/plugin-ai` plugin reading environment variables —
|
|
371
|
-
* there is intentionally no API-key field here, because the dashboard never
|
|
372
|
-
* stores provider keys. This panel just reports current status.
|
|
373
|
-
*/
|
|
374
|
-
function AiStatusTab() {
|
|
375
|
-
const { data, loading, error, refetch } = useApiData<AiStatus>('/ai/status')
|
|
376
|
-
|
|
377
|
-
if (loading) {
|
|
378
|
-
return (
|
|
379
|
-
<div className="flex h-40 items-center justify-center">
|
|
380
|
-
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
|
381
|
-
</div>
|
|
382
|
-
)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (error) {
|
|
386
|
-
return (
|
|
387
|
-
<div className="flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
388
|
-
<AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
|
|
389
|
-
<span className="flex-1 text-sm text-red-800">{error}</span>
|
|
390
|
-
<button
|
|
391
|
-
onClick={refetch}
|
|
392
|
-
className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
|
|
393
|
-
>
|
|
394
|
-
Retry
|
|
395
|
-
</button>
|
|
396
|
-
</div>
|
|
397
|
-
)
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const installed = data?.installed ?? false
|
|
401
|
-
const configured = data?.configured ?? false
|
|
402
|
-
const providerLabel = data?.provider ? (PROVIDER_LABELS[data.provider] ?? data.provider) : null
|
|
403
|
-
|
|
404
|
-
const status: { tone: 'ok' | 'warn' | 'off'; title: string; detail: string } = !installed
|
|
405
|
-
? {
|
|
406
|
-
tone: 'off',
|
|
407
|
-
title: 'AI features are not installed',
|
|
408
|
-
detail:
|
|
409
|
-
'Add the @actuate-media/plugin-ai plugin to your CMS config to enable AI-powered content features.',
|
|
410
|
-
}
|
|
411
|
-
: configured
|
|
412
|
-
? {
|
|
413
|
-
tone: 'ok',
|
|
414
|
-
title: providerLabel ? `AI is active — ${providerLabel}` : 'AI is active',
|
|
415
|
-
detail:
|
|
416
|
-
'A provider is configured and ready. AI-powered features in the editor and media library will use it.',
|
|
417
|
-
}
|
|
418
|
-
: {
|
|
419
|
-
tone: 'warn',
|
|
420
|
-
title: 'AI plugin installed, but no provider is configured',
|
|
421
|
-
detail:
|
|
422
|
-
'Set a provider and API key where you register the plugin, e.g. aiPlugin({ provider: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY }).',
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const toneStyles: Record<typeof status.tone, string> = {
|
|
426
|
-
ok: 'border-green-200 bg-green-50',
|
|
427
|
-
warn: 'border-amber-200 bg-amber-50',
|
|
428
|
-
off: 'border-gray-200 bg-gray-50',
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
return (
|
|
432
|
-
<div className="space-y-4">
|
|
433
|
-
<div className={`flex items-start gap-3 rounded-lg border p-4 ${toneStyles[status.tone]}`}>
|
|
434
|
-
{status.tone === 'ok' ? (
|
|
435
|
-
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-green-600" />
|
|
436
|
-
) : status.tone === 'warn' ? (
|
|
437
|
-
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
|
438
|
-
) : (
|
|
439
|
-
<Bot className="mt-0.5 h-5 w-5 shrink-0 text-gray-500" />
|
|
440
|
-
)}
|
|
441
|
-
<div className="flex-1">
|
|
442
|
-
<h3 className="text-sm font-semibold text-gray-900">{status.title}</h3>
|
|
443
|
-
<p className="mt-0.5 text-xs text-gray-600">{status.detail}</p>
|
|
444
|
-
</div>
|
|
445
|
-
</div>
|
|
446
|
-
|
|
447
|
-
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
448
|
-
<h3 className="mb-1 text-sm font-semibold text-gray-900">How AI is configured</h3>
|
|
449
|
-
<p className="mb-3 text-xs text-gray-500">
|
|
450
|
-
AI is set up in code, not from this dashboard. Provider credentials live in environment
|
|
451
|
-
variables and are never entered or stored here.
|
|
452
|
-
</p>
|
|
453
|
-
<ul className="space-y-1.5 text-xs text-gray-600">
|
|
454
|
-
<li className="flex items-start gap-2">
|
|
455
|
-
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-blue-100 text-[10px] font-bold text-blue-600">
|
|
456
|
-
1
|
|
457
|
-
</span>
|
|
458
|
-
<span>
|
|
459
|
-
Install the AI plugin:{' '}
|
|
460
|
-
<code className="rounded bg-gray-100 px-1 font-mono">@actuate-media/plugin-ai</code>.
|
|
461
|
-
</span>
|
|
462
|
-
</li>
|
|
463
|
-
<li className="flex items-start gap-2">
|
|
464
|
-
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-blue-100 text-[10px] font-bold text-blue-600">
|
|
465
|
-
2
|
|
466
|
-
</span>
|
|
467
|
-
<span>
|
|
468
|
-
Set <code className="rounded bg-gray-100 px-1 font-mono">ANTHROPIC_API_KEY</code> or{' '}
|
|
469
|
-
<code className="rounded bg-gray-100 px-1 font-mono">OPENAI_API_KEY</code> in your
|
|
470
|
-
environment.
|
|
471
|
-
</span>
|
|
472
|
-
</li>
|
|
473
|
-
<li className="flex items-start gap-2">
|
|
474
|
-
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-blue-100 text-[10px] font-bold text-blue-600">
|
|
475
|
-
3
|
|
476
|
-
</span>
|
|
477
|
-
<span>
|
|
478
|
-
Register it in your CMS config:{' '}
|
|
479
|
-
<code className="rounded bg-gray-100 px-1 font-mono">
|
|
480
|
-
aiPlugin({ provider, apiKey })
|
|
481
|
-
</code>
|
|
482
|
-
.
|
|
483
|
-
</span>
|
|
484
|
-
</li>
|
|
485
|
-
</ul>
|
|
486
|
-
</div>
|
|
487
|
-
</div>
|
|
488
|
-
)
|
|
489
|
-
}
|
|
490
|
-
|
|
491
354
|
// ---------------------------------------------------------------------------
|
|
492
355
|
// Updates Panel
|
|
493
356
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Bot, Info, Loader2, Plus } from 'lucide-react'
|
|
4
|
+
import { useId, useMemo, useState } from 'react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { AiFeaturesCard } from './AiFeaturesCard.js'
|
|
7
|
+
import { ModelSelector } from './ModelSelector.js'
|
|
8
|
+
import { MonthlyUsageCard } from './MonthlyUsageCard.js'
|
|
9
|
+
import { ProviderConnectionCard } from './ProviderConnectionCard.js'
|
|
10
|
+
import { SettingsSaveBar } from './components.js'
|
|
11
|
+
import {
|
|
12
|
+
useAiSettings,
|
|
13
|
+
type AiProviderKey,
|
|
14
|
+
type ProviderInput,
|
|
15
|
+
type UseAiSettings,
|
|
16
|
+
} from './useAiSettings.js'
|
|
17
|
+
|
|
18
|
+
const INPUT_CLASS =
|
|
19
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
20
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
21
|
+
|
|
22
|
+
const PROVIDER_OPTIONS: Array<{ value: AiProviderKey; label: string }> = [
|
|
23
|
+
{ value: 'anthropic', label: 'Anthropic (Claude)' },
|
|
24
|
+
{ value: 'openai', label: 'OpenAI (GPT)' },
|
|
25
|
+
{ value: 'xai', label: 'xAI (Grok)' },
|
|
26
|
+
{ value: 'custom', label: 'Custom (OpenAI-compatible)' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export interface AISettingsTabProps {
|
|
30
|
+
/** Current user's role. Only ADMIN can persist changes. */
|
|
31
|
+
role?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AISettingsTab({ role }: AISettingsTabProps) {
|
|
35
|
+
const canEdit = role === 'ADMIN'
|
|
36
|
+
const ai = useAiSettings(canEdit)
|
|
37
|
+
|
|
38
|
+
if (ai.loading) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-4" aria-busy="true" aria-live="polite">
|
|
41
|
+
<span className="sr-only">Loading AI settings…</span>
|
|
42
|
+
{[0, 1, 2].map((i) => (
|
|
43
|
+
<div key={i} className="border-border bg-card rounded-lg border p-4">
|
|
44
|
+
<div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
|
|
45
|
+
<div className="space-y-3">
|
|
46
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
47
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="space-y-4">
|
|
57
|
+
{ai.loadError && (
|
|
58
|
+
<div
|
|
59
|
+
role="alert"
|
|
60
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
|
|
61
|
+
>
|
|
62
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
63
|
+
<span className="flex-1">{ai.loadError}</span>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={ai.refetch}
|
|
67
|
+
className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
|
|
68
|
+
>
|
|
69
|
+
Retry
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{!canEdit && (
|
|
75
|
+
<div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
|
|
76
|
+
<Info size={16} className="shrink-0" aria-hidden="true" />
|
|
77
|
+
You have read-only access to AI settings. Only administrators can make changes.
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<AssistantCard ai={ai} canEdit={canEdit} />
|
|
82
|
+
<ProviderConnectionsSection ai={ai} canEdit={canEdit} />
|
|
83
|
+
<AiFeaturesCard features={ai.form.features} canEdit={canEdit} onToggle={ai.toggleFeature} />
|
|
84
|
+
<MonthlyUsageCard
|
|
85
|
+
usage={ai.usage}
|
|
86
|
+
monthlyTokenLimit={ai.form.monthlyTokenLimit}
|
|
87
|
+
onChangeLimit={ai.setMonthlyTokenLimit}
|
|
88
|
+
budgetError={ai.budgetError}
|
|
89
|
+
canEdit={canEdit}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{ai.saveState === 'error' && ai.saveError && (
|
|
93
|
+
<div
|
|
94
|
+
role="alert"
|
|
95
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
|
|
96
|
+
>
|
|
97
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
98
|
+
<span className="flex-1">{ai.saveError}</span>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<SettingsSaveBar
|
|
103
|
+
dirty={ai.dirty}
|
|
104
|
+
canEdit={canEdit}
|
|
105
|
+
hasErrors={Boolean(ai.budgetError)}
|
|
106
|
+
saveState={ai.saveState}
|
|
107
|
+
onSave={ai.save}
|
|
108
|
+
onReset={ai.reset}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// AI Assistant — default provider + default model
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function AssistantCard({ ai, canEdit }: { ai: UseAiSettings; canEdit: boolean }) {
|
|
119
|
+
const headingId = useId()
|
|
120
|
+
const providerSelectId = useId()
|
|
121
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
122
|
+
|
|
123
|
+
const defaultProvider = ai.providers.find((p) => p.id === ai.form.defaultProviderId)
|
|
124
|
+
const modelsForDefault = useMemo(() => {
|
|
125
|
+
if (!defaultProvider) return ai.models
|
|
126
|
+
return ai.models.filter((m) => m.provider === defaultProvider.provider)
|
|
127
|
+
}, [ai.models, defaultProvider])
|
|
128
|
+
|
|
129
|
+
async function handleRefresh() {
|
|
130
|
+
if (!defaultProvider) return
|
|
131
|
+
setRefreshing(true)
|
|
132
|
+
try {
|
|
133
|
+
const res = await ai.syncModels(defaultProvider.id)
|
|
134
|
+
if (!res.ok) toast.error(res.error ?? 'Failed to refresh models.')
|
|
135
|
+
else if (res.fromFallback)
|
|
136
|
+
toast.warning(res.warning ?? 'Showing the curated model list (live listing unavailable).')
|
|
137
|
+
else toast.success('Model list refreshed.')
|
|
138
|
+
} finally {
|
|
139
|
+
setRefreshing(false)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
145
|
+
<div className="mb-4 flex items-center gap-2">
|
|
146
|
+
<span className="bg-brand/10 text-brand flex h-9 w-9 items-center justify-center rounded-lg">
|
|
147
|
+
<Bot size={18} aria-hidden="true" />
|
|
148
|
+
</span>
|
|
149
|
+
<div>
|
|
150
|
+
<h3 id={headingId} className="text-foreground text-base font-medium">
|
|
151
|
+
AI Assistant
|
|
152
|
+
</h3>
|
|
153
|
+
<p className="text-muted-foreground text-sm">
|
|
154
|
+
The default provider and model used across AI features.
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{ai.providers.length === 0 ? (
|
|
160
|
+
<div className="border-border bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-sm">
|
|
161
|
+
No AI providers are connected yet. Add one below to enable AI features.
|
|
162
|
+
</div>
|
|
163
|
+
) : (
|
|
164
|
+
<div className="space-y-4">
|
|
165
|
+
<div>
|
|
166
|
+
<label htmlFor={providerSelectId} className={LABEL_CLASS}>
|
|
167
|
+
Default provider
|
|
168
|
+
</label>
|
|
169
|
+
<select
|
|
170
|
+
id={providerSelectId}
|
|
171
|
+
value={ai.form.defaultProviderId}
|
|
172
|
+
disabled={!canEdit}
|
|
173
|
+
onChange={(e) => ai.setDefaultProvider(e.target.value)}
|
|
174
|
+
className={INPUT_CLASS}
|
|
175
|
+
>
|
|
176
|
+
<option value="">Select a provider…</option>
|
|
177
|
+
{ai.providers.map((p) => (
|
|
178
|
+
<option key={p.id} value={p.id}>
|
|
179
|
+
{p.displayName}
|
|
180
|
+
</option>
|
|
181
|
+
))}
|
|
182
|
+
</select>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<ModelSelector
|
|
186
|
+
models={modelsForDefault}
|
|
187
|
+
value={ai.form.defaultModelId}
|
|
188
|
+
onChange={ai.setDefaultModel}
|
|
189
|
+
onRefresh={handleRefresh}
|
|
190
|
+
refreshing={refreshing}
|
|
191
|
+
disabled={!canEdit || !defaultProvider}
|
|
192
|
+
noModelsHint={
|
|
193
|
+
defaultProvider
|
|
194
|
+
? 'No models cached yet. Use “Refresh models” to load them.'
|
|
195
|
+
: 'Select a default provider to choose a model.'
|
|
196
|
+
}
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</section>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Provider connections (manage multiple)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
function ProviderConnectionsSection({ ai, canEdit }: { ai: UseAiSettings; canEdit: boolean }) {
|
|
209
|
+
const headingId = useId()
|
|
210
|
+
const [adding, setAdding] = useState(false)
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<section aria-labelledby={headingId} className="space-y-3">
|
|
214
|
+
<div className="flex items-center justify-between">
|
|
215
|
+
<h3 id={headingId} className="text-foreground text-base font-medium">
|
|
216
|
+
Provider connections
|
|
217
|
+
</h3>
|
|
218
|
+
{canEdit && !adding && (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={() => setAdding(true)}
|
|
222
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
|
223
|
+
>
|
|
224
|
+
<Plus size={14} aria-hidden="true" />
|
|
225
|
+
Add provider
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{adding && (
|
|
231
|
+
<AddProviderForm
|
|
232
|
+
ai={ai}
|
|
233
|
+
onDone={() => setAdding(false)}
|
|
234
|
+
onCancel={() => setAdding(false)}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{ai.providers.map((p) => (
|
|
239
|
+
<ProviderConnectionCard
|
|
240
|
+
key={p.id}
|
|
241
|
+
provider={p}
|
|
242
|
+
canEdit={canEdit}
|
|
243
|
+
busy={ai.busyProviderId === p.id}
|
|
244
|
+
onTest={async () => {
|
|
245
|
+
const res = await ai.testProvider(p.id)
|
|
246
|
+
if (!res.ok) toast.error(res.error ?? res.message ?? 'Connection test failed.')
|
|
247
|
+
else toast.success(res.message ?? 'Connection successful.')
|
|
248
|
+
}}
|
|
249
|
+
onSyncModels={async () => {
|
|
250
|
+
const res = await ai.syncModels(p.id)
|
|
251
|
+
if (!res.ok) toast.error(res.error ?? 'Failed to refresh models.')
|
|
252
|
+
else if (res.fromFallback)
|
|
253
|
+
toast.warning(res.warning ?? 'Showing the curated model list.')
|
|
254
|
+
else toast.success('Model list refreshed.')
|
|
255
|
+
}}
|
|
256
|
+
onUpdateKey={async (apiKey) => {
|
|
257
|
+
const res = await ai.updateProvider(p.id, { apiKey })
|
|
258
|
+
if (!res.ok) toast.error(res.error ?? 'Failed to update key.')
|
|
259
|
+
else toast.success('API key updated.')
|
|
260
|
+
}}
|
|
261
|
+
onUpdateBaseUrl={async (baseUrl) => {
|
|
262
|
+
const res = await ai.updateProvider(p.id, { baseUrl })
|
|
263
|
+
if (!res.ok) toast.error(res.error ?? 'Failed to update base URL.')
|
|
264
|
+
else toast.success('Base URL updated.')
|
|
265
|
+
}}
|
|
266
|
+
onDisconnect={async () => {
|
|
267
|
+
const res = await ai.deleteProvider(p.id)
|
|
268
|
+
if (!res.ok) toast.error(res.error ?? 'Failed to disconnect.')
|
|
269
|
+
else toast.success(`${p.displayName} disconnected.`)
|
|
270
|
+
}}
|
|
271
|
+
/>
|
|
272
|
+
))}
|
|
273
|
+
|
|
274
|
+
{ai.providers.length === 0 && !adding && (
|
|
275
|
+
<p className="text-muted-foreground text-sm">No providers connected.</p>
|
|
276
|
+
)}
|
|
277
|
+
</section>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function AddProviderForm({
|
|
282
|
+
ai,
|
|
283
|
+
onDone,
|
|
284
|
+
onCancel,
|
|
285
|
+
}: {
|
|
286
|
+
ai: UseAiSettings
|
|
287
|
+
onDone: () => void
|
|
288
|
+
onCancel: () => void
|
|
289
|
+
}) {
|
|
290
|
+
const providerId = useId()
|
|
291
|
+
const nameId = useId()
|
|
292
|
+
const keyId = useId()
|
|
293
|
+
const baseUrlId = useId()
|
|
294
|
+
const [provider, setProvider] = useState<AiProviderKey>('anthropic')
|
|
295
|
+
const [displayName, setDisplayName] = useState('')
|
|
296
|
+
const [apiKey, setApiKey] = useState('')
|
|
297
|
+
const [baseUrl, setBaseUrl] = useState('')
|
|
298
|
+
const [submitting, setSubmitting] = useState(false)
|
|
299
|
+
|
|
300
|
+
const needsBaseUrl = provider === 'custom'
|
|
301
|
+
const canSubmit = !submitting && (!needsBaseUrl || baseUrl.trim() !== '')
|
|
302
|
+
|
|
303
|
+
async function submit() {
|
|
304
|
+
setSubmitting(true)
|
|
305
|
+
try {
|
|
306
|
+
const input: ProviderInput = {
|
|
307
|
+
provider,
|
|
308
|
+
displayName: displayName.trim() || undefined,
|
|
309
|
+
apiKey: apiKey.trim() || undefined,
|
|
310
|
+
baseUrl: needsBaseUrl ? baseUrl.trim() : undefined,
|
|
311
|
+
test: true,
|
|
312
|
+
}
|
|
313
|
+
const res = await ai.createProvider(input)
|
|
314
|
+
if (!res.ok) {
|
|
315
|
+
toast.error(res.error ?? 'Failed to add provider.')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
toast.success('Provider connected.')
|
|
319
|
+
onDone()
|
|
320
|
+
} finally {
|
|
321
|
+
setSubmitting(false)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
327
|
+
<h4 className="text-foreground mb-3 text-base font-medium">Add a provider</h4>
|
|
328
|
+
<div className="space-y-3">
|
|
329
|
+
<div>
|
|
330
|
+
<label htmlFor={providerId} className={LABEL_CLASS}>
|
|
331
|
+
Provider
|
|
332
|
+
</label>
|
|
333
|
+
<select
|
|
334
|
+
id={providerId}
|
|
335
|
+
value={provider}
|
|
336
|
+
onChange={(e) => setProvider(e.target.value as AiProviderKey)}
|
|
337
|
+
className={INPUT_CLASS}
|
|
338
|
+
>
|
|
339
|
+
{PROVIDER_OPTIONS.map((o) => (
|
|
340
|
+
<option key={o.value} value={o.value}>
|
|
341
|
+
{o.label}
|
|
342
|
+
</option>
|
|
343
|
+
))}
|
|
344
|
+
</select>
|
|
345
|
+
</div>
|
|
346
|
+
<div>
|
|
347
|
+
<label htmlFor={nameId} className={LABEL_CLASS}>
|
|
348
|
+
Display name (optional)
|
|
349
|
+
</label>
|
|
350
|
+
<input
|
|
351
|
+
id={nameId}
|
|
352
|
+
type="text"
|
|
353
|
+
value={displayName}
|
|
354
|
+
placeholder="e.g. Production Anthropic"
|
|
355
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
356
|
+
className={INPUT_CLASS}
|
|
357
|
+
/>
|
|
358
|
+
</div>
|
|
359
|
+
{needsBaseUrl && (
|
|
360
|
+
<div>
|
|
361
|
+
<label htmlFor={baseUrlId} className={LABEL_CLASS}>
|
|
362
|
+
Base URL
|
|
363
|
+
</label>
|
|
364
|
+
<input
|
|
365
|
+
id={baseUrlId}
|
|
366
|
+
type="url"
|
|
367
|
+
value={baseUrl}
|
|
368
|
+
placeholder="https://api.example.com/v1"
|
|
369
|
+
onChange={(e) => setBaseUrl(e.target.value)}
|
|
370
|
+
className={INPUT_CLASS}
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
<div>
|
|
375
|
+
<label htmlFor={keyId} className={LABEL_CLASS}>
|
|
376
|
+
API key
|
|
377
|
+
</label>
|
|
378
|
+
<input
|
|
379
|
+
id={keyId}
|
|
380
|
+
type="password"
|
|
381
|
+
autoComplete="off"
|
|
382
|
+
spellCheck={false}
|
|
383
|
+
value={apiKey}
|
|
384
|
+
placeholder="Paste API key (or leave blank if set via environment)"
|
|
385
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
386
|
+
className={INPUT_CLASS}
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
<div className="flex items-center justify-end gap-3 pt-1">
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
onClick={onCancel}
|
|
393
|
+
disabled={submitting}
|
|
394
|
+
className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-sm transition-colors disabled:opacity-50"
|
|
395
|
+
>
|
|
396
|
+
Cancel
|
|
397
|
+
</button>
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
onClick={submit}
|
|
401
|
+
disabled={!canSubmit}
|
|
402
|
+
className="bg-brand text-brand-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
403
|
+
>
|
|
404
|
+
{submitting && <Loader2 size={14} className="animate-spin" aria-hidden="true" />}
|
|
405
|
+
Connect & test
|
|
406
|
+
</button>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId } from 'react'
|
|
4
|
+
import { SettingsToggleRow } from './components.js'
|
|
5
|
+
import type { AiFeatureView } from './useAiSettings.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Global AI feature toggles. Each switch persists (via the Save bar) to the
|
|
9
|
+
* shared `ai.features.*` flags. Enforcement across modules lands in a later
|
|
10
|
+
* phase; here we surface and store the operator's intent.
|
|
11
|
+
*/
|
|
12
|
+
export function AiFeaturesCard({
|
|
13
|
+
features,
|
|
14
|
+
canEdit,
|
|
15
|
+
onToggle,
|
|
16
|
+
}: {
|
|
17
|
+
features: AiFeatureView[]
|
|
18
|
+
canEdit: boolean
|
|
19
|
+
onToggle: (key: string, enabled: boolean) => void
|
|
20
|
+
}) {
|
|
21
|
+
const headingId = useId()
|
|
22
|
+
return (
|
|
23
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
24
|
+
<h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
|
|
25
|
+
AI Features
|
|
26
|
+
</h3>
|
|
27
|
+
<p className="text-muted-foreground mb-4 text-sm">
|
|
28
|
+
Turn individual AI-powered features on or off across the CMS.
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
{features.length === 0 ? (
|
|
32
|
+
<p className="text-muted-foreground text-sm">No AI features are available.</p>
|
|
33
|
+
) : (
|
|
34
|
+
<div className="space-y-4">
|
|
35
|
+
{features.map((f) => (
|
|
36
|
+
<SettingsToggleRow
|
|
37
|
+
key={f.key}
|
|
38
|
+
label={f.label}
|
|
39
|
+
description={f.description}
|
|
40
|
+
checked={f.enabled}
|
|
41
|
+
disabled={!canEdit}
|
|
42
|
+
onChange={(v) => onToggle(f.key, v)}
|
|
43
|
+
/>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</section>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Binary,
|
|
5
|
+
Braces,
|
|
6
|
+
Brain,
|
|
7
|
+
Eye,
|
|
8
|
+
Image as ImageIcon,
|
|
9
|
+
type LucideIcon,
|
|
10
|
+
Type,
|
|
11
|
+
Wrench,
|
|
12
|
+
Zap,
|
|
13
|
+
} from 'lucide-react'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders a model's capabilities as labelled badges. Text + icon (never
|
|
17
|
+
* color-only) so the information is accessible to color-blind users and screen
|
|
18
|
+
* readers.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface CapabilityMeta {
|
|
22
|
+
label: string
|
|
23
|
+
icon: LucideIcon
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Ordered for stable rendering. Keys match `AIModelCapabilities`.
|
|
27
|
+
const CAPABILITY_META: Record<string, CapabilityMeta> = {
|
|
28
|
+
text: { label: 'Text', icon: Type },
|
|
29
|
+
vision: { label: 'Vision', icon: Eye },
|
|
30
|
+
imageGeneration: { label: 'Image gen', icon: ImageIcon },
|
|
31
|
+
embeddings: { label: 'Embeddings', icon: Binary },
|
|
32
|
+
reasoning: { label: 'Reasoning', icon: Brain },
|
|
33
|
+
toolCalling: { label: 'Tools', icon: Wrench },
|
|
34
|
+
structuredOutput: { label: 'Structured', icon: Braces },
|
|
35
|
+
longContext: { label: 'Long context', icon: Zap },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function CapabilityBadges({
|
|
39
|
+
capabilities,
|
|
40
|
+
className,
|
|
41
|
+
}: {
|
|
42
|
+
capabilities: Record<string, boolean>
|
|
43
|
+
className?: string
|
|
44
|
+
}) {
|
|
45
|
+
const active = Object.keys(CAPABILITY_META).filter((k) => capabilities?.[k])
|
|
46
|
+
if (active.length === 0) {
|
|
47
|
+
return <span className="text-muted-foreground text-sm">No capability data</span>
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<ul className={`flex flex-wrap gap-1.5 ${className ?? ''}`} aria-label="Model capabilities">
|
|
51
|
+
{active.map((key) => {
|
|
52
|
+
const meta = CAPABILITY_META[key]!
|
|
53
|
+
const Icon = meta.icon
|
|
54
|
+
return (
|
|
55
|
+
<li
|
|
56
|
+
key={key}
|
|
57
|
+
className="border-border bg-muted text-muted-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs"
|
|
58
|
+
>
|
|
59
|
+
<Icon size={11} aria-hidden="true" />
|
|
60
|
+
{meta.label}
|
|
61
|
+
</li>
|
|
62
|
+
)
|
|
63
|
+
})}
|
|
64
|
+
</ul>
|
|
65
|
+
)
|
|
66
|
+
}
|