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 | undefined): Record { 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(null) const [keyInputs, setKeyInputs] = useState({}) const [configured, setConfigured] = useState>(() => getApiKeyStatus({})) const [providerModels, setProviderModels] = useState>>({}) const [modelsLoaded, setModelsLoaded] = useState>>({}) const [modelsMeta, setModelsMeta] = useState>>({}) 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(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[] = [] 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( 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

請先從頂部選擇 Threads 經營帳號。

} return (
{!compact ? (
AI API Key

同一登入帳號下的所有經營帳號共用。8D 分析、產文等任務會讀取這組 provider 與 key。

) : null} {loading ? (

載入 AI 設定…

) : settings ? ( <>
{currentProviderConfigured ? '主要 Provider 已設定 key' : '主要 Provider 尚未設定 key'}
{PROVIDER_ORDER.map((providerId) => { const meta = PROVIDER_KEY_LABELS[providerId] const isConfigured = configured[providerId] return (
{meta.label} {isConfigured ? '已設定' : '未設定'}

{meta.hint}

setKeyInputs((prev) => ({ ...prev, [providerId]: e.target.value })) } placeholder={isConfigured ? '留空則保留現有 key' : '貼上 API key'} autoComplete="off" /> {meta.docsUrl ? ( 取得 {meta.label} API key → ) : null}
) })}

Key 只存在後端資料庫,綁定此登入帳號。留空或維持遮罩值不會覆寫已儲存的 key。

) : (

無法載入設定。

)}
) }