440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
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>
|
||
)
|
||
} |