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

440 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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