thread-master/frontend/src/components/AccountAiSettings.tsx

289 lines
10 KiB
TypeScript
Raw Normal View History

2026-06-26 08:37:04 +00:00
import { useEffect, useState } from 'react'
import { api, ApiError } from '../api/client'
import {
DEFAULT_AI_CREDENTIALS,
getApiKeyStatus,
isMaskedKey,
normalizeProviderSettings,
normalizeSupportedProvider,
PROVIDER_KEY_LABELS,
PROVIDER_OPTIONS,
PROVIDER_ORDER,
type ProviderApiKeys,
type ProviderId,
} from '../lib/aiCredentials'
import type { ThreadsAccountAiSettingsData } from '../types/api'
import { Badge, Button, ErrorText, Field, Input, SectionTitle, Select, SuccessText } from './ui'
type AccountAiSettingsProps = {
accountId: string
compact?: boolean
}
function aiSettingsPath(accountId: string) {
return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`
}
function parseConfigured(raw: Record<string, unknown> | undefined): Record<ProviderId, boolean> {
const base = getApiKeyStatus({})
if (!raw) return base
for (const provider of PROVIDER_ORDER) {
const value = raw[provider]
base[provider] = value === true || value === 'true'
}
return base
}
export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps) {
const [settings, setSettings] = useState<ThreadsAccountAiSettingsData | null>(null)
const [keyInputs, setKeyInputs] = useState<ProviderApiKeys>({})
const [configured, setConfigured] = useState<Record<ProviderId, boolean>>(() => getApiKeyStatus({}))
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const load = async () => {
if (!accountId) return
setLoading(true)
setError('')
try {
const data = await api.get<ThreadsAccountAiSettingsData>(aiSettingsPath(accountId), { auth: true })
setSettings({
...data,
...normalizeProviderSettings({
provider: data.provider,
model: data.model,
research_provider: data.research_provider,
research_model: data.research_model,
}),
})
setConfigured(parseConfigured(data.api_keys_configured))
setKeyInputs(data.api_keys ?? {})
} catch (e) {
setError(e instanceof ApiError ? e.message : '載入 AI 設定失敗')
} finally {
setLoading(false)
}
}
useEffect(() => {
load().catch(() => undefined)
}, [accountId])
const save = async () => {
if (!accountId || !settings) return
setSaving(true)
setError('')
setMessage('')
try {
const apiKeys: ProviderApiKeys = {}
for (const provider of PROVIDER_ORDER) {
const trimmed = keyInputs[provider]?.trim()
if (!trimmed || isMaskedKey(trimmed)) continue
apiKeys[provider] = trimmed
}
const normalized = normalizeProviderSettings({
provider: settings.provider,
model: settings.model,
research_provider: settings.research_provider,
research_model: settings.research_model,
})
const data = await api.put<ThreadsAccountAiSettingsData>(
aiSettingsPath(accountId),
{
provider: normalized.provider,
model: normalized.model,
research_provider: normalized.research_provider,
research_model: normalized.research_model,
api_keys: apiKeys,
},
{ auth: true },
)
setSettings({
...data,
...normalizeProviderSettings({
provider: data.provider,
model: data.model,
research_provider: data.research_provider,
research_model: data.research_model,
}),
})
setConfigured(parseConfigured(data.api_keys_configured))
setKeyInputs(data.api_keys ?? {})
setMessage('AI 設定已儲存')
} catch (e) {
setError(e instanceof ApiError ? e.message : '儲存失敗')
} finally {
setSaving(false)
}
}
const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider
const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider)
const providerOption = PROVIDER_OPTIONS.find((item) => item.value === provider)
const researchOption = PROVIDER_OPTIONS.find((item) => item.value === researchProvider)
const currentProviderConfigured = configured[provider]
if (!accountId) {
return <p className="text-sm text-ink-secondary"> Threads </p>
}
return (
<div className="grid gap-4">
{!compact ? (
<div>
<SectionTitle>AI API Key</SectionTitle>
<p className="mt-1 text-sm leading-relaxed text-ink-secondary">
8D provider key
</p>
</div>
) : null}
{loading ? (
<p className="text-sm text-muted"> AI </p>
) : settings ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Field label="主要 Provider">
<Select
value={provider}
onChange={(e) => {
const next = e.target.value as ProviderId
const option = PROVIDER_OPTIONS.find((item) => item.value === next)
setSettings((prev) =>
prev
? {
...prev,
provider: next,
model: option?.models[0] ?? prev.model,
}
: prev,
)
}}
>
{PROVIDER_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</Field>
<Field label="主要 Model">
<Select
value={settings.model}
onChange={(e) =>
setSettings((prev) => (prev ? { ...prev, model: e.target.value } : prev))
}
>
{(providerOption?.models ?? []).map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</Select>
</Field>
<Field label="研究用 Provider選填">
<Select
value={researchProvider}
onChange={(e) => {
const next = e.target.value as ProviderId
const option = PROVIDER_OPTIONS.find((item) => item.value === next)
setSettings((prev) =>
prev
? {
...prev,
research_provider: next,
research_model: option?.models[0] ?? prev.research_model,
}
: prev,
)
}}
>
{PROVIDER_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</Field>
<Field label="研究用 Model">
<Select
value={settings.research_model ?? ''}
onChange={(e) =>
setSettings((prev) =>
prev ? { ...prev, research_model: e.target.value } : prev,
)
}
>
{(researchOption?.models ?? []).map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</Select>
</Field>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge tone={currentProviderConfigured ? 'success' : 'warning'}>
{currentProviderConfigured ? '主要 Provider 已設定 key' : '主要 Provider 尚未設定 key'}
</Badge>
</div>
<div className="grid gap-3">
{PROVIDER_ORDER.map((providerId) => {
const meta = PROVIDER_KEY_LABELS[providerId]
const isConfigured = configured[providerId]
return (
<div key={providerId} className="ac-slot px-4 py-4">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-bold text-ink">{meta.label}</span>
<Badge tone={isConfigured ? 'success' : 'neutral'}>
{isConfigured ? '已設定' : '未設定'}
</Badge>
</div>
<p className="mb-3 text-xs leading-relaxed text-muted">{meta.hint}</p>
<Input
type="password"
value={keyInputs[providerId] ?? ''}
onChange={(e) =>
setKeyInputs((prev) => ({ ...prev, [providerId]: e.target.value }))
}
placeholder={isConfigured ? '留空則保留現有 key' : '貼上 API key'}
autoComplete="off"
/>
{meta.docsUrl ? (
<a
href={meta.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="ac-link mt-2 inline-block text-xs"
>
{meta.label} API key
</a>
) : null}
</div>
)
})}
</div>
<p className="text-xs leading-relaxed text-muted">
Key key
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={save} disabled={saving || loading}>
{saving ? '儲存中…' : '儲存 AI 設定'}
</Button>
</div>
</>
) : (
<p className="text-sm text-ink-secondary"></p>
)}
<ErrorText message={error} />
<SuccessText message={message} />
</div>
)
}