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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|