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

440 lines
16 KiB
TypeScript
Raw Normal View History

2026-06-27 14:49:17 +00:00
import { useCallback, useEffect, useState } from 'react'
import { fetchAccountAiProviderModels } from '../api/ai'
2026-06-26 08:37:04 +00:00
import { api, ApiError } from '../api/client'
import {
DEFAULT_AI_CREDENTIALS,
getApiKeyStatus,
isMaskedKey,
normalizeProviderSettings,
normalizeSupportedProvider,
PROVIDER_KEY_LABELS,
PROVIDER_OPTIONS,
PROVIDER_ORDER,
2026-06-27 14:49:17 +00:00
resolveProviderApiToken,
2026-06-26 08:37:04 +00:00
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
}
2026-06-27 14:49:17 +00:00
type ModelsMeta = {
loading: boolean
error: string
}
2026-06-26 08:37:04 +00:00
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
}
2026-06-27 14:49:17 +00:00
function pickModel(models: string[], current: string | undefined, fallback: string): string {
if (current && models.includes(current)) return current
return models[0] ?? fallback
}
2026-06-26 08:37:04 +00:00
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({}))
2026-06-27 14:49:17 +00:00
const [providerModels, setProviderModels] = useState<Partial<Record<ProviderId, string[]>>>({})
const [modelsLoaded, setModelsLoaded] = useState<Partial<Record<ProviderId, boolean>>>({})
const [modelsMeta, setModelsMeta] = useState<Partial<Record<ProviderId, ModelsMeta>>>({})
2026-06-26 08:37:04 +00:00
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
2026-06-27 14:49:17 +00:00
const loadProviderModels = useCallback(
async (providerId: ProviderId, token?: string) => {
if (!accountId) return null
const overrideToken = token ?? resolveProviderApiToken(providerId, keyInputs)
if (!overrideToken && !configured[providerId]) return null
setModelsMeta((prev) => ({
...prev,
[providerId]: { loading: true, error: '' },
}))
try {
const data = await fetchAccountAiProviderModels(
accountId,
providerId,
overrideToken ?? undefined,
)
const models = data.models ?? []
setProviderModels((prev) => ({ ...prev, [providerId]: models }))
setModelsLoaded((prev) => ({ ...prev, [providerId]: true }))
setModelsMeta((prev) => ({
...prev,
[providerId]: { loading: false, error: data.error ?? '' },
}))
if (models.length > 0) {
setSettings((prev) => {
if (!prev) return prev
const mainProvider = normalizeSupportedProvider(prev.provider)
const researchProv = normalizeSupportedProvider(prev.research_provider ?? mainProvider)
let next = prev
if (providerId === mainProvider && !models.includes(prev.model)) {
next = { ...next, model: models[0] }
}
if (providerId === researchProv) {
const currentResearch = prev.research_model ?? ''
if (!currentResearch || !models.includes(currentResearch)) {
next = { ...next, research_model: models[0] }
}
}
return next
})
}
return data
} catch (e) {
setProviderModels((prev) => ({ ...prev, [providerId]: [] }))
setModelsLoaded((prev) => ({ ...prev, [providerId]: true }))
setModelsMeta((prev) => ({
...prev,
[providerId]: {
loading: false,
error: e instanceof ApiError ? e.message : '載入模型清單失敗',
},
}))
return null
}
},
[accountId, configured, keyInputs],
)
2026-06-26 08:37:04 +00:00
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])
2026-06-27 14:49:17 +00:00
const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider
const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider)
useEffect(() => {
if (loading || !settings) return
void loadProviderModels(provider)
if (researchProvider !== provider) {
void loadProviderModels(researchProvider)
}
}, [loading, settings, provider, researchProvider, loadProviderModels])
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
for (const providerId of PROVIDER_ORDER) {
if (!resolveProviderApiToken(providerId, keyInputs)) continue
timers.push(
setTimeout(() => {
void loadProviderModels(providerId)
}, 500),
)
}
return () => timers.forEach(clearTimeout)
}, [keyInputs, loadProviderModels])
const getModelsForProvider = (providerId: ProviderId) => {
if (!modelsLoaded[providerId]) return []
return providerModels[providerId] ?? []
}
const primaryModels = getModelsForProvider(provider)
const researchModels = getModelsForProvider(researchProvider)
const primaryModelsMeta = modelsMeta[provider]
const researchModelsMeta = modelsMeta[researchProvider]
const currentProviderConfigured = configured[provider]
2026-06-26 08:37:04 +00:00
const save = async () => {
if (!accountId || !settings) return
setSaving(true)
setError('')
setMessage('')
try {
const apiKeys: ProviderApiKeys = {}
2026-06-27 14:49:17 +00:00
for (const providerId of PROVIDER_ORDER) {
const trimmed = keyInputs[providerId]?.trim()
2026-06-26 08:37:04 +00:00
if (!trimmed || isMaskedKey(trimmed)) continue
2026-06-27 14:49:17 +00:00
apiKeys[providerId] = trimmed
2026-06-26 08:37:04 +00:00
}
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 設定已儲存')
2026-06-27 14:49:17 +00:00
for (const [providerId, token] of Object.entries(apiKeys) as [ProviderId, string][]) {
void loadProviderModels(providerId, token)
}
2026-06-26 08:37:04 +00:00
} catch (e) {
setError(e instanceof ApiError ? e.message : '儲存失敗')
} finally {
setSaving(false)
}
}
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
2026-06-27 14:49:17 +00:00
const models = getModelsForProvider(next)
2026-06-26 08:37:04 +00:00
setSettings((prev) =>
prev
? {
...prev,
provider: next,
2026-06-27 14:49:17 +00:00
model: pickModel(models, prev.model, DEFAULT_AI_CREDENTIALS.model),
2026-06-26 08:37:04 +00:00
}
: prev,
)
2026-06-27 14:49:17 +00:00
void loadProviderModels(next)
2026-06-26 08:37:04 +00:00
}}
>
{PROVIDER_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</Field>
2026-06-27 14:49:17 +00:00
<Field
label="主要 Model"
hint={
primaryModelsMeta?.loading
? '正在向 provider 載入模型清單…'
: primaryModelsMeta?.error
? primaryModelsMeta.error
: !configured[provider]
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
: undefined
}
>
2026-06-26 08:37:04 +00:00
<Select
2026-06-27 14:49:17 +00:00
value={primaryModels.includes(settings.model) ? settings.model : ''}
disabled={primaryModelsMeta?.loading || primaryModels.length === 0}
2026-06-26 08:37:04 +00:00
onChange={(e) =>
setSettings((prev) => (prev ? { ...prev, model: e.target.value } : prev))
}
>
2026-06-27 14:49:17 +00:00
{primaryModels.length === 0 ? (
<option value="">
{primaryModelsMeta?.loading ? '載入中…' : '尚無可用模型'}
2026-06-26 08:37:04 +00:00
</option>
2026-06-27 14:49:17 +00:00
) : (
primaryModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))
)}
2026-06-26 08:37:04 +00:00
</Select>
</Field>
<Field label="研究用 Provider選填">
<Select
value={researchProvider}
onChange={(e) => {
const next = e.target.value as ProviderId
2026-06-27 14:49:17 +00:00
const models = getModelsForProvider(next)
2026-06-26 08:37:04 +00:00
setSettings((prev) =>
prev
? {
...prev,
research_provider: next,
2026-06-27 14:49:17 +00:00
research_model: pickModel(
models,
prev.research_model,
DEFAULT_AI_CREDENTIALS.research_model ?? DEFAULT_AI_CREDENTIALS.model,
),
2026-06-26 08:37:04 +00:00
}
: prev,
)
2026-06-27 14:49:17 +00:00
void loadProviderModels(next)
2026-06-26 08:37:04 +00:00
}}
>
{PROVIDER_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</Field>
2026-06-27 14:49:17 +00:00
<Field
label="研究用 Model"
hint={
researchModelsMeta?.loading
? '正在向 provider 載入模型清單…'
: researchModelsMeta?.error
? researchModelsMeta.error
: !configured[researchProvider]
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
: undefined
}
>
2026-06-26 08:37:04 +00:00
<Select
2026-06-27 14:49:17 +00:00
value={
settings.research_model && researchModels.includes(settings.research_model)
? settings.research_model
: ''
}
disabled={researchModelsMeta?.loading || researchModels.length === 0}
2026-06-26 08:37:04 +00:00
onChange={(e) =>
setSettings((prev) =>
prev ? { ...prev, research_model: e.target.value } : prev,
)
}
>
2026-06-27 14:49:17 +00:00
{researchModels.length === 0 ? (
<option value="">
{researchModelsMeta?.loading ? '載入中…' : '尚無可用模型'}
2026-06-26 08:37:04 +00:00
</option>
2026-06-27 14:49:17 +00:00
) : (
researchModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))
)}
2026-06-26 08:37:04 +00:00
</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>
)
}