@actuate-media/cms-admin 0.33.0 → 0.35.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 +229 -0
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +2 -46
- 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 +17 -0
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
- package/dist/views/settings/AiFeaturesCard.js +35 -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/featureModel.d.ts +25 -0
- package/dist/views/settings/featureModel.d.ts.map +1 -0
- package/dist/views/settings/featureModel.js +45 -0
- package/dist/views/settings/featureModel.js.map +1 -0
- package/dist/views/settings/useAiSettings.d.ts +149 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -0
- package/dist/views/settings/useAiSettings.js +297 -0
- package/dist/views/settings/useAiSettings.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/views/ai-settings.render.test.tsx +286 -0
- package/src/views/Settings.tsx +2 -139
- package/src/views/settings/AISettingsTab.tsx +418 -0
- package/src/views/settings/AiFeaturesCard.tsx +154 -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/featureModel.ts +64 -0
- package/src/views/settings/useAiSettings.ts +476 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react'
|
|
4
|
+
import { useId } from 'react'
|
|
5
|
+
import { CapabilityBadges } from './CapabilityBadges.js'
|
|
6
|
+
import type { AiModelView } from './useAiSettings.js'
|
|
7
|
+
|
|
8
|
+
const INPUT_CLASS =
|
|
9
|
+
'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'
|
|
10
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
11
|
+
|
|
12
|
+
function priceLabel(model: AiModelView): string | null {
|
|
13
|
+
const p = model.pricing
|
|
14
|
+
if (!p) return null
|
|
15
|
+
if (typeof p.inputPerMillion === 'number' && typeof p.outputPerMillion === 'number') {
|
|
16
|
+
return `$${p.inputPerMillion}/$${p.outputPerMillion} per 1M tokens`
|
|
17
|
+
}
|
|
18
|
+
if (typeof p.inputPerMillion === 'number') return `$${p.inputPerMillion} per 1M tokens`
|
|
19
|
+
if (typeof p.image === 'number') return `$${p.image} per image`
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ModelSelector({
|
|
24
|
+
models,
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
onRefresh,
|
|
28
|
+
refreshing,
|
|
29
|
+
disabled,
|
|
30
|
+
noModelsHint,
|
|
31
|
+
}: {
|
|
32
|
+
models: AiModelView[]
|
|
33
|
+
value: string
|
|
34
|
+
onChange: (id: string) => void
|
|
35
|
+
onRefresh: () => void
|
|
36
|
+
refreshing: boolean
|
|
37
|
+
disabled: boolean
|
|
38
|
+
/** Shown when there are no models to choose from. */
|
|
39
|
+
noModelsHint?: string
|
|
40
|
+
}) {
|
|
41
|
+
const selectId = useId()
|
|
42
|
+
const selected = models.find((m) => m.id === value || m.providerModelId === value)
|
|
43
|
+
// The configured model is no longer in the catalog — keep the choice, warn.
|
|
44
|
+
const selectedUnavailable = Boolean(value) && !selected
|
|
45
|
+
const price = selected ? priceLabel(selected) : null
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div>
|
|
49
|
+
<label htmlFor={selectId} className={LABEL_CLASS}>
|
|
50
|
+
Default model
|
|
51
|
+
</label>
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<select
|
|
54
|
+
id={selectId}
|
|
55
|
+
value={value}
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
onChange={(e) => onChange(e.target.value)}
|
|
58
|
+
className={INPUT_CLASS}
|
|
59
|
+
>
|
|
60
|
+
<option value="">Select a model…</option>
|
|
61
|
+
{selectedUnavailable && <option value={value}>{value} (unavailable)</option>}
|
|
62
|
+
{models.map((m) => (
|
|
63
|
+
<option key={m.id} value={m.id} disabled={m.status === 'unavailable'}>
|
|
64
|
+
{m.displayName}
|
|
65
|
+
{m.status === 'deprecated' ? ' (deprecated)' : ''}
|
|
66
|
+
{m.status === 'unavailable' ? ' (unavailable)' : ''}
|
|
67
|
+
</option>
|
|
68
|
+
))}
|
|
69
|
+
</select>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={onRefresh}
|
|
73
|
+
disabled={disabled || refreshing}
|
|
74
|
+
aria-label="Refresh models"
|
|
75
|
+
className="border-border text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
76
|
+
>
|
|
77
|
+
{refreshing ? (
|
|
78
|
+
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
|
|
79
|
+
) : (
|
|
80
|
+
<RefreshCw size={16} aria-hidden="true" />
|
|
81
|
+
)}
|
|
82
|
+
<span className="sr-only sm:not-sr-only">Refresh models</span>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{models.length === 0 && (
|
|
87
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
88
|
+
{noModelsHint ?? 'No models available yet. Connect a provider and refresh.'}
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{selectedUnavailable && (
|
|
93
|
+
<p className="text-warning mt-2 flex items-start gap-1.5 text-sm" role="status">
|
|
94
|
+
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
95
|
+
The selected model is no longer offered by the provider. Pick another or refresh the list.
|
|
96
|
+
</p>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{selected && (
|
|
100
|
+
<div className="mt-2 space-y-1.5">
|
|
101
|
+
<CapabilityBadges capabilities={selected.capabilities} />
|
|
102
|
+
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
|
103
|
+
{typeof selected.contextWindow === 'number' && (
|
|
104
|
+
<span>{selected.contextWindow.toLocaleString()} token context</span>
|
|
105
|
+
)}
|
|
106
|
+
{price && <span>{price}</span>}
|
|
107
|
+
{selected.status === 'deprecated' && (
|
|
108
|
+
<span className="text-warning">Deprecated by provider</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId } from 'react'
|
|
4
|
+
import type { AiUsageView } from './useAiSettings.js'
|
|
5
|
+
|
|
6
|
+
const INPUT_CLASS =
|
|
7
|
+
'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'
|
|
8
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
9
|
+
|
|
10
|
+
function formatTokens(n: number): string {
|
|
11
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
|
|
12
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`
|
|
13
|
+
return String(n)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Monthly token usage meter + budget limit input. Usage is REAL (aggregated
|
|
18
|
+
* from audit-log token totals) — it shows 0 until AI features have run, never a
|
|
19
|
+
* fabricated number.
|
|
20
|
+
*/
|
|
21
|
+
export function MonthlyUsageCard({
|
|
22
|
+
usage,
|
|
23
|
+
monthlyTokenLimit,
|
|
24
|
+
onChangeLimit,
|
|
25
|
+
budgetError,
|
|
26
|
+
canEdit,
|
|
27
|
+
}: {
|
|
28
|
+
usage: AiUsageView | null
|
|
29
|
+
monthlyTokenLimit: string
|
|
30
|
+
onChangeLimit: (value: string) => void
|
|
31
|
+
budgetError: string | null
|
|
32
|
+
canEdit: boolean
|
|
33
|
+
}) {
|
|
34
|
+
const headingId = useId()
|
|
35
|
+
const limitId = useId()
|
|
36
|
+
|
|
37
|
+
const used = usage?.totalTokens ?? 0
|
|
38
|
+
const limit = monthlyTokenLimit === '' ? undefined : Number(monthlyTokenLimit)
|
|
39
|
+
const pct = limit && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : null
|
|
40
|
+
const over = pct !== null && used > (limit ?? 0)
|
|
41
|
+
|
|
42
|
+
const periodLabel = usage?.periodStart
|
|
43
|
+
? new Date(usage.periodStart).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
|
44
|
+
: 'This month'
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
48
|
+
<h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
|
|
49
|
+
Monthly Usage
|
|
50
|
+
</h3>
|
|
51
|
+
<p className="text-muted-foreground mb-4 text-sm">
|
|
52
|
+
Token usage for {periodLabel}. Aggregated from completed AI runs.
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
<div className="space-y-4">
|
|
56
|
+
<div>
|
|
57
|
+
<div className="mb-1 flex items-center justify-between text-sm">
|
|
58
|
+
<span className="text-foreground font-medium">{formatTokens(used)} tokens used</span>
|
|
59
|
+
{limit && limit > 0 && (
|
|
60
|
+
<span className="text-muted-foreground">of {formatTokens(limit)}</span>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
{pct !== null ? (
|
|
64
|
+
<div
|
|
65
|
+
className="bg-muted h-2 w-full overflow-hidden rounded-full"
|
|
66
|
+
role="progressbar"
|
|
67
|
+
aria-valuenow={pct}
|
|
68
|
+
aria-valuemin={0}
|
|
69
|
+
aria-valuemax={100}
|
|
70
|
+
aria-valuetext={`${pct}% of monthly token budget used`}
|
|
71
|
+
>
|
|
72
|
+
<div
|
|
73
|
+
className={`h-full rounded-full transition-all ${over ? 'bg-destructive' : 'bg-brand'}`}
|
|
74
|
+
style={{ width: `${pct}%` }}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
) : (
|
|
78
|
+
<p className="text-muted-foreground text-sm">
|
|
79
|
+
No monthly limit set. Usage is tracked but not capped.
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
82
|
+
{over && (
|
|
83
|
+
<p className="text-destructive mt-1 text-sm" role="status">
|
|
84
|
+
Usage has exceeded the monthly limit.
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div>
|
|
90
|
+
<label htmlFor={limitId} className={LABEL_CLASS}>
|
|
91
|
+
Monthly token limit
|
|
92
|
+
</label>
|
|
93
|
+
<input
|
|
94
|
+
id={limitId}
|
|
95
|
+
type="text"
|
|
96
|
+
inputMode="numeric"
|
|
97
|
+
value={monthlyTokenLimit}
|
|
98
|
+
disabled={!canEdit}
|
|
99
|
+
placeholder="No limit"
|
|
100
|
+
aria-invalid={Boolean(budgetError)}
|
|
101
|
+
aria-describedby={budgetError ? `${limitId}-error` : undefined}
|
|
102
|
+
onChange={(e) => onChangeLimit(e.target.value)}
|
|
103
|
+
className={INPUT_CLASS}
|
|
104
|
+
/>
|
|
105
|
+
{budgetError ? (
|
|
106
|
+
<p id={`${limitId}-error`} className="text-destructive mt-1 text-sm">
|
|
107
|
+
{budgetError}
|
|
108
|
+
</p>
|
|
109
|
+
) : (
|
|
110
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
111
|
+
Leave blank for no limit. Enforcement and alerts arrive with usage metering.
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</section>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
CheckCircle2,
|
|
6
|
+
KeyRound,
|
|
7
|
+
Loader2,
|
|
8
|
+
Lock,
|
|
9
|
+
Plug,
|
|
10
|
+
RefreshCw,
|
|
11
|
+
Trash2,
|
|
12
|
+
} from 'lucide-react'
|
|
13
|
+
import { useId, useState } from 'react'
|
|
14
|
+
import { ConfirmDangerousSettingDialog } from './components.js'
|
|
15
|
+
import type { AiConnectionStatus, AiProviderView } from './useAiSettings.js'
|
|
16
|
+
|
|
17
|
+
const INPUT_CLASS =
|
|
18
|
+
'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'
|
|
19
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
20
|
+
|
|
21
|
+
const STATUS_META: Record<
|
|
22
|
+
AiConnectionStatus,
|
|
23
|
+
{ label: string; tone: 'ok' | 'warn' | 'error' | 'idle' }
|
|
24
|
+
> = {
|
|
25
|
+
connected: { label: 'Connected', tone: 'ok' },
|
|
26
|
+
invalid_key: { label: 'Invalid key', tone: 'error' },
|
|
27
|
+
rate_limited: { label: 'Rate limited', tone: 'warn' },
|
|
28
|
+
provider_down: { label: 'Provider unavailable', tone: 'warn' },
|
|
29
|
+
needs_setup: { label: 'Needs API key', tone: 'idle' },
|
|
30
|
+
unknown: { label: 'Not tested', tone: 'idle' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function StatusPill({ status }: { status: AiConnectionStatus }) {
|
|
34
|
+
const meta = STATUS_META[status]
|
|
35
|
+
const toneClass =
|
|
36
|
+
meta.tone === 'ok'
|
|
37
|
+
? 'border-success/30 bg-success/10 text-success'
|
|
38
|
+
: meta.tone === 'error'
|
|
39
|
+
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
|
40
|
+
: meta.tone === 'warn'
|
|
41
|
+
? 'border-warning/30 bg-warning/10 text-warning'
|
|
42
|
+
: 'border-border bg-muted text-muted-foreground'
|
|
43
|
+
return (
|
|
44
|
+
<span
|
|
45
|
+
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${toneClass}`}
|
|
46
|
+
>
|
|
47
|
+
{meta.tone === 'ok' ? (
|
|
48
|
+
<CheckCircle2 size={12} aria-hidden="true" />
|
|
49
|
+
) : meta.tone === 'error' ? (
|
|
50
|
+
<AlertTriangle size={12} aria-hidden="true" />
|
|
51
|
+
) : (
|
|
52
|
+
<Plug size={12} aria-hidden="true" />
|
|
53
|
+
)}
|
|
54
|
+
{meta.label}
|
|
55
|
+
</span>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ProviderConnectionCardProps {
|
|
60
|
+
provider: AiProviderView
|
|
61
|
+
canEdit: boolean
|
|
62
|
+
busy: boolean
|
|
63
|
+
onTest: () => void
|
|
64
|
+
onSyncModels: () => void
|
|
65
|
+
onUpdateKey: (apiKey: string | null) => Promise<void>
|
|
66
|
+
onUpdateBaseUrl: (baseUrl: string) => Promise<void>
|
|
67
|
+
onDisconnect: () => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ProviderConnectionCard({
|
|
71
|
+
provider,
|
|
72
|
+
canEdit,
|
|
73
|
+
busy,
|
|
74
|
+
onTest,
|
|
75
|
+
onSyncModels,
|
|
76
|
+
onUpdateKey,
|
|
77
|
+
onUpdateBaseUrl,
|
|
78
|
+
onDisconnect,
|
|
79
|
+
}: ProviderConnectionCardProps) {
|
|
80
|
+
const keyId = useId()
|
|
81
|
+
const baseUrlId = useId()
|
|
82
|
+
const [keyInput, setKeyInput] = useState('')
|
|
83
|
+
const [baseUrlInput, setBaseUrlInput] = useState(provider.baseUrl ?? '')
|
|
84
|
+
const [confirmKey, setConfirmKey] = useState(false)
|
|
85
|
+
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
|
86
|
+
|
|
87
|
+
const envManaged = provider.keyManagedExternally
|
|
88
|
+
const keyEditable = canEdit && !envManaged
|
|
89
|
+
|
|
90
|
+
async function applyKey() {
|
|
91
|
+
setConfirmKey(false)
|
|
92
|
+
await onUpdateKey(keyInput.trim() === '' ? null : keyInput.trim())
|
|
93
|
+
setKeyInput('')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
98
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
99
|
+
<div>
|
|
100
|
+
<div className="flex items-center gap-2">
|
|
101
|
+
<span className="text-foreground text-base font-medium">{provider.displayName}</span>
|
|
102
|
+
<StatusPill status={provider.status} />
|
|
103
|
+
</div>
|
|
104
|
+
<p className="text-muted-foreground mt-0.5 text-sm capitalize">{provider.provider}</p>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={onTest}
|
|
110
|
+
disabled={!canEdit || busy}
|
|
111
|
+
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 disabled:cursor-not-allowed disabled:opacity-60"
|
|
112
|
+
>
|
|
113
|
+
{busy ? (
|
|
114
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
115
|
+
) : (
|
|
116
|
+
<Plug size={14} aria-hidden="true" />
|
|
117
|
+
)}
|
|
118
|
+
Test connection
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={onSyncModels}
|
|
123
|
+
disabled={!canEdit || busy}
|
|
124
|
+
aria-label={`Refresh models for ${provider.displayName}`}
|
|
125
|
+
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 disabled:cursor-not-allowed disabled:opacity-60"
|
|
126
|
+
>
|
|
127
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
128
|
+
Refresh models
|
|
129
|
+
</button>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => setConfirmDisconnect(true)}
|
|
133
|
+
disabled={!canEdit || busy}
|
|
134
|
+
aria-label={`Disconnect ${provider.displayName}`}
|
|
135
|
+
className="text-muted-foreground hover:text-destructive inline-flex items-center rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
136
|
+
>
|
|
137
|
+
<Trash2 size={16} aria-hidden="true" />
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="space-y-3">
|
|
143
|
+
{/* API key */}
|
|
144
|
+
<div>
|
|
145
|
+
<label htmlFor={keyId} className={LABEL_CLASS}>
|
|
146
|
+
<KeyRound size={13} aria-hidden="true" />
|
|
147
|
+
API key
|
|
148
|
+
{envManaged && (
|
|
149
|
+
<span className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs">
|
|
150
|
+
<Lock size={11} aria-hidden="true" />
|
|
151
|
+
Managed by environment
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
</label>
|
|
155
|
+
{envManaged ? (
|
|
156
|
+
<p className="text-muted-foreground text-sm">
|
|
157
|
+
This key is set via an environment variable and can't be edited here.
|
|
158
|
+
{provider.apiKeyLast4 ? ` Ends in ••••${provider.apiKeyLast4}.` : ''}
|
|
159
|
+
</p>
|
|
160
|
+
) : (
|
|
161
|
+
<div className="flex items-center gap-2">
|
|
162
|
+
<input
|
|
163
|
+
id={keyId}
|
|
164
|
+
type="password"
|
|
165
|
+
autoComplete="off"
|
|
166
|
+
spellCheck={false}
|
|
167
|
+
value={keyInput}
|
|
168
|
+
disabled={!keyEditable || busy}
|
|
169
|
+
placeholder={
|
|
170
|
+
provider.hasKey
|
|
171
|
+
? `Key set ••••${provider.apiKeyLast4 ?? ''} — enter a new key to replace`
|
|
172
|
+
: 'Paste API key'
|
|
173
|
+
}
|
|
174
|
+
onChange={(e) => setKeyInput(e.target.value)}
|
|
175
|
+
className={INPUT_CLASS}
|
|
176
|
+
/>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={() => setConfirmKey(true)}
|
|
180
|
+
disabled={!keyEditable || busy || keyInput.trim() === ''}
|
|
181
|
+
className="bg-brand text-brand-foreground inline-flex shrink-0 items-center rounded-md px-3 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
182
|
+
>
|
|
183
|
+
Save key
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{/* Custom base URL */}
|
|
190
|
+
{provider.provider === 'custom' && (
|
|
191
|
+
<div>
|
|
192
|
+
<label htmlFor={baseUrlId} className={LABEL_CLASS}>
|
|
193
|
+
Base URL
|
|
194
|
+
</label>
|
|
195
|
+
<div className="flex items-center gap-2">
|
|
196
|
+
<input
|
|
197
|
+
id={baseUrlId}
|
|
198
|
+
type="url"
|
|
199
|
+
value={baseUrlInput}
|
|
200
|
+
disabled={!canEdit || busy}
|
|
201
|
+
placeholder="https://api.example.com/v1"
|
|
202
|
+
onChange={(e) => setBaseUrlInput(e.target.value)}
|
|
203
|
+
className={INPUT_CLASS}
|
|
204
|
+
/>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={() => onUpdateBaseUrl(baseUrlInput.trim())}
|
|
208
|
+
disabled={!canEdit || busy || baseUrlInput.trim() === (provider.baseUrl ?? '')}
|
|
209
|
+
className="border-border text-foreground hover:bg-accent inline-flex shrink-0 items-center rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
210
|
+
>
|
|
211
|
+
Save
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{provider.lastTestedAt && (
|
|
218
|
+
<p className="text-muted-foreground text-sm">
|
|
219
|
+
Last tested {new Date(provider.lastTestedAt).toLocaleString()}.
|
|
220
|
+
</p>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<ConfirmDangerousSettingDialog
|
|
225
|
+
open={confirmKey}
|
|
226
|
+
title="Replace the stored API key?"
|
|
227
|
+
description="The new key is encrypted and replaces the existing one. Existing AI features will use it immediately."
|
|
228
|
+
confirmLabel="Save key"
|
|
229
|
+
onConfirm={applyKey}
|
|
230
|
+
onCancel={() => setConfirmKey(false)}
|
|
231
|
+
/>
|
|
232
|
+
<ConfirmDangerousSettingDialog
|
|
233
|
+
open={confirmDisconnect}
|
|
234
|
+
title={`Disconnect ${provider.displayName}?`}
|
|
235
|
+
description="This removes the stored key and model catalog for this provider. AI features that rely on it will stop working until you reconnect."
|
|
236
|
+
confirmLabel="Disconnect"
|
|
237
|
+
onConfirm={() => {
|
|
238
|
+
setConfirmDisconnect(false)
|
|
239
|
+
onDisconnect()
|
|
240
|
+
}}
|
|
241
|
+
onCancel={() => setConfirmDisconnect(false)}
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side, display-only feature → model resolution.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors a simplified version of cms-core's `resolveFeatureModel` so the UI can
|
|
5
|
+
* preview which model a feature will use as the admin edits — including before
|
|
6
|
+
* Save. The server resolver remains the authority for the AI runtime; this only
|
|
7
|
+
* drives labels and capability warnings.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AiModelView } from './useAiSettings.js'
|
|
11
|
+
|
|
12
|
+
const CAPABILITY_LABELS: Record<string, string> = {
|
|
13
|
+
text: 'text',
|
|
14
|
+
vision: 'vision',
|
|
15
|
+
imageGeneration: 'image generation',
|
|
16
|
+
embeddings: 'embeddings',
|
|
17
|
+
toolCalling: 'tool calling',
|
|
18
|
+
structuredOutput: 'structured output',
|
|
19
|
+
streaming: 'streaming',
|
|
20
|
+
reasoning: 'reasoning',
|
|
21
|
+
longContext: 'long context',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findModel(models: AiModelView[], id: string | undefined): AiModelView | undefined {
|
|
25
|
+
if (!id) return undefined
|
|
26
|
+
return models.find((m) => m.id === id || m.providerModelId === id)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Required capabilities a model is missing. Unknown capability strings are ignored. */
|
|
30
|
+
export function missingCapabilities(model: AiModelView | undefined, required: string[]): string[] {
|
|
31
|
+
const wanted = required.filter((c) => c in CAPABILITY_LABELS)
|
|
32
|
+
if (!model) return wanted
|
|
33
|
+
return wanted.filter((c) => !model.capabilities?.[c])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function capabilityLabel(key: string): string {
|
|
37
|
+
return CAPABILITY_LABELS[key] ?? key
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EffectiveFeatureModel {
|
|
41
|
+
/** The id that will be used (override, else the global default). */
|
|
42
|
+
modelId: string
|
|
43
|
+
model?: AiModelView
|
|
44
|
+
/** True when the feature has its own model override (not the default). */
|
|
45
|
+
isOverride: boolean
|
|
46
|
+
missingCapabilities: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function effectiveFeatureModel(
|
|
50
|
+
override: string | undefined,
|
|
51
|
+
defaultModelId: string,
|
|
52
|
+
required: string[],
|
|
53
|
+
models: AiModelView[],
|
|
54
|
+
): EffectiveFeatureModel {
|
|
55
|
+
const isOverride = Boolean(override)
|
|
56
|
+
const modelId = override || defaultModelId
|
|
57
|
+
const model = findModel(models, modelId)
|
|
58
|
+
return { modelId, model, isOverride, missingCapabilities: missingCapabilities(model, required) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether a model satisfies every required capability and is selectable. */
|
|
62
|
+
export function isModelCompatible(model: AiModelView, required: string[]): boolean {
|
|
63
|
+
return model.status !== 'unavailable' && missingCapabilities(model, required).length === 0
|
|
64
|
+
}
|