289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|