686 lines
31 KiB
TypeScript
686 lines
31 KiB
TypeScript
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
|
||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import { platformApiClient } from "../api/client";
|
||
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
|
||
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
|
||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import { isPlatformAdmin } from "../contracts/workspace";
|
||
import {
|
||
aiProviderKindDefaults,
|
||
applyAiProviderKindDefaults,
|
||
completeAiProviderForm,
|
||
generatedAiProviderId,
|
||
aiProviderToForm,
|
||
emptyAiProviderForm,
|
||
type AiProviderActionState,
|
||
type AiProviderFilter,
|
||
type AiProviderFormState,
|
||
type AiProviderListState,
|
||
type AiProviderPageInitialState,
|
||
type AiProviderViewState,
|
||
summarizeAiProviders
|
||
} from "../contracts/aiProviders";
|
||
import { aiProviderCreateRequestFromForm, aiProviderRetireRequest, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
|
||
import { cx } from "../utils/classes";
|
||
|
||
interface AiProvidersPageProps extends Partial<PageComponentProps> {
|
||
initialState?: AiProviderPageInitialState;
|
||
}
|
||
|
||
interface ProviderPreset {
|
||
kind: AiProviderKind;
|
||
label: string;
|
||
}
|
||
|
||
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
|
||
|
||
const providerPresets: ProviderPreset[] = [
|
||
{ kind: "openai", label: "OpenAI" },
|
||
{ kind: "claude", label: "Claude" },
|
||
{ kind: "gemini", label: "Gemini" },
|
||
{ kind: "ollama", label: "Ollama" },
|
||
{ kind: "openai-compatible", label: "兼容网关" },
|
||
{ kind: "custom", label: "自定义" }
|
||
];
|
||
|
||
export function AiProvidersPage({ initialState, session, operations }: AiProvidersPageProps = {}) {
|
||
const initialSelectedProvider = initialState?.selectedId ? initialState.providers?.find((provider) => provider.id === initialState.selectedId) : undefined;
|
||
const [providers, setProviders] = useState<AiProviderResponse[]>(initialState?.providers ?? []);
|
||
const [listState, setListState] = useState<AiProviderListState>(initialState?.listState ?? "loading");
|
||
const [listError, setListError] = useState(initialState?.listError ?? "");
|
||
const [selectedId, setSelectedId] = useState(initialState?.selectedId ?? "");
|
||
const [filter, setFilter] = useState<AiProviderFilter>("all");
|
||
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(initialSelectedProvider));
|
||
const [formMode, setFormMode] = useState<"create" | "edit" | null>(initialSelectedProvider ? "edit" : null);
|
||
const [viewState, setViewState] = useState<AiProviderViewState>(initialState?.source ?? "local-development");
|
||
const [action, setAction] = useState<AiProviderActionState | null>(initialState?.action ?? null);
|
||
const [confirmRetire, setConfirmRetire] = useState<AiProviderResponse | null>(null);
|
||
const [confirmStatus, setConfirmStatus] = useState<AiProviderResponse | null>(null);
|
||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||
const [busyProviderIds, setBusyProviderIds] = useState<Set<string>>(() => new Set());
|
||
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
|
||
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
|
||
|
||
const refreshProviders = useCallback(async () => {
|
||
if (initialState?.providers) {
|
||
setListState(initialState.listState ?? "ready");
|
||
setViewState(initialState.source ?? "local-development");
|
||
return;
|
||
}
|
||
setListState("loading");
|
||
setListError("");
|
||
try {
|
||
const response = await platformApiClient.listAiProviders();
|
||
const nextSelectedId = initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId) ? initialState.selectedId : "";
|
||
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
|
||
setProviders(response.items);
|
||
setSelectedId(nextSelectedId);
|
||
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
|
||
setFormMode(nextSelectedProvider ? "edit" : null);
|
||
setListState("ready");
|
||
setListError("");
|
||
setViewState("api");
|
||
setFormCheck(null);
|
||
} catch (error: unknown) {
|
||
setProviders([]);
|
||
setSelectedId("");
|
||
setForm(emptyAiProviderForm());
|
||
setListState("error");
|
||
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
|
||
setViewState("error");
|
||
setFormCheck(null);
|
||
}
|
||
}, [initialState]);
|
||
|
||
useEffect(() => {
|
||
void refreshProviders();
|
||
}, [refreshProviders]);
|
||
|
||
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
|
||
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
|
||
const canManage = Boolean(session && isPlatformAdmin(session));
|
||
|
||
function setProviderBusy(providerId: string, busy: boolean) {
|
||
setBusyProviderIds((current) => {
|
||
const next = new Set(current);
|
||
if (busy) next.add(providerId);
|
||
else next.delete(providerId);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
|
||
setForm((current) => ({ ...current, [key]: value }));
|
||
}
|
||
|
||
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
||
const key = event.target.name as keyof AiProviderFormState;
|
||
if (key === "kind") {
|
||
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
|
||
} else if (key === "apiKeyRef") {
|
||
setForm((current) => ({ ...current, apiKeyRef: event.target.value, apiKeyConfigured: current.apiKeyConfigured || Boolean(event.target.value.trim()) }));
|
||
} else if (key === "baseUrl") {
|
||
setForm((current) => ({ ...current, baseUrl: event.target.value, baseUrlConfigured: current.baseUrlConfigured || Boolean(event.target.value.trim()) }));
|
||
} else {
|
||
updateForm(key, event.target.value);
|
||
}
|
||
setFormCheck(null);
|
||
}
|
||
|
||
function selectProvider(provider: AiProviderResponse) {
|
||
if (!canManage) {
|
||
return;
|
||
}
|
||
setSelectedId(provider.id);
|
||
setForm(aiProviderToForm(provider));
|
||
setFormMode("edit");
|
||
setAction(null);
|
||
setFormCheck(null);
|
||
setExpandedProviderId(null);
|
||
}
|
||
|
||
function startCreate() {
|
||
if (!canManage) {
|
||
return;
|
||
}
|
||
setSelectedId("");
|
||
setForm(emptyAiProviderForm());
|
||
setFormMode("create");
|
||
setAction(null);
|
||
setConfirmRetire(null);
|
||
setFormCheck(null);
|
||
setExpandedProviderId(null);
|
||
}
|
||
|
||
function closeForm() {
|
||
setFormMode(null);
|
||
setFormCheck(null);
|
||
}
|
||
|
||
function applyProviderPreset(preset: ProviderPreset) {
|
||
setForm((current) => applyAiProviderKindDefaults(current, preset.kind));
|
||
setFormCheck({ status: "pending", message: `已选择 ${preset.label}。常规配置只需要平台 secret 引用;高级参数已按厂商默认值填入。` });
|
||
}
|
||
|
||
function runFormPreflight() {
|
||
const missing: string[] = [];
|
||
const completed = completeAiProviderForm(form);
|
||
const models = completed.modelsText
|
||
.split(",")
|
||
.map((model) => model.trim())
|
||
.filter(Boolean);
|
||
if (!completed.name.trim()) {
|
||
missing.push("名称");
|
||
}
|
||
if (!completed.baseUrl.trim() && !completed.baseUrlConfigured) {
|
||
missing.push("Base URL");
|
||
}
|
||
if (completed.relayMode !== "local" && !completed.apiKeyConfigured && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
|
||
missing.push("secret://providers/... 密钥引用");
|
||
}
|
||
if (models.length === 0) {
|
||
missing.push("至少一个模型");
|
||
}
|
||
if (!Number.isFinite(Number.parseInt(completed.timeoutMs, 10)) || Number.parseInt(completed.timeoutMs, 10) <= 0) {
|
||
missing.push("有效超时");
|
||
}
|
||
setFormCheck(
|
||
missing.length > 0
|
||
? { status: "failed", message: `保存前检查未通过:请补充 ${missing.join("、")}。` }
|
||
: { status: "succeeded", message: "保存前检查通过。保存后先测试配置,测试成功再启用。" }
|
||
);
|
||
}
|
||
|
||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!canManage) {
|
||
return;
|
||
}
|
||
setViewState("saving");
|
||
const completed = completeAiProviderForm(form);
|
||
const providerId = generatedAiProviderId(completed);
|
||
const existing = providers.some((provider) => provider.id === providerId);
|
||
const intent = existing ? "更新 AI 提供商" : "创建 AI 提供商";
|
||
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: providerId, requester: session?.displayName });
|
||
|
||
try {
|
||
const saved = existing
|
||
? await platformApiClient.updateAiProvider(providerId, aiProviderUpdateRequestFromForm(completed))
|
||
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(completed));
|
||
upsertProvider(saved);
|
||
setSelectedId(saved.id);
|
||
setForm(aiProviderToForm(saved));
|
||
setViewState("api");
|
||
setListState("ready");
|
||
setListError("");
|
||
setAction({ providerId: saved.id, label: "save", success: true, message: "已保存" });
|
||
if (operationId) operations?.succeed(operationId, `${saved.name} 已保存`);
|
||
setFormCheck(null);
|
||
setFormMode(null);
|
||
} catch (error) {
|
||
setViewState("error");
|
||
const message = errorMessage(error, "保存失败");
|
||
setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
|
||
setFormCheck({ status: "failed", message });
|
||
if (operationId) operations?.fail(operationId, message, operationId);
|
||
}
|
||
}
|
||
|
||
async function handleStatus(provider: AiProviderResponse): Promise<boolean> {
|
||
if (!canManage) {
|
||
return false;
|
||
}
|
||
setExpandedProviderId(null);
|
||
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
|
||
const intent = nextStatus === "active" ? "启用 AI 提供商" : "停用 AI 提供商";
|
||
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: provider.id, requester: session?.displayName });
|
||
setProviderBusy(provider.id, true);
|
||
try {
|
||
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
|
||
upsertProvider(updated);
|
||
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
|
||
if (operationId) operations?.succeed(operationId, `${updated.name} 已${updated.status === "disabled" ? "停用" : "启用"}`);
|
||
return true;
|
||
} catch (error) {
|
||
const message = errorMessage(error, "状态更新失败");
|
||
setAction({ providerId: provider.id, label: "status", success: false, message });
|
||
if (operationId) operations?.fail(operationId, message, operationId);
|
||
return false;
|
||
} finally {
|
||
setProviderBusy(provider.id, false);
|
||
}
|
||
}
|
||
|
||
async function handleRetire(provider: AiProviderResponse) {
|
||
if (!canManage) {
|
||
return;
|
||
}
|
||
setConfirmBusy(true);
|
||
const operationId = operations?.begin({ intent: "退役 AI 提供商", targetKind: "llm", targetId: provider.id, requester: session?.displayName });
|
||
setProviderBusy(provider.id, true);
|
||
try {
|
||
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
|
||
upsertProvider(retired);
|
||
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
|
||
if (operationId) operations?.succeed(operationId, `${retired.name} 已退役`);
|
||
setConfirmRetire(null);
|
||
} catch (error) {
|
||
const message = errorMessage(error, "退役失败,可能仍被引用");
|
||
setAction({ providerId: provider.id, label: "retire", success: false, message });
|
||
if (operationId) operations?.fail(operationId, message, operationId);
|
||
} finally {
|
||
setConfirmBusy(false);
|
||
setProviderBusy(provider.id, false);
|
||
}
|
||
}
|
||
|
||
async function handleTest(provider: AiProviderResponse) {
|
||
if (!canManage) {
|
||
return;
|
||
}
|
||
setExpandedProviderId(null);
|
||
setProviderBusy(provider.id, true);
|
||
try {
|
||
const result = await platformApiClient.testAiProvider(provider.id);
|
||
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
|
||
} catch (error) {
|
||
setAction({ providerId: provider.id, label: "test", success: false, message: errorMessage(error, "测试失败") });
|
||
} finally {
|
||
setProviderBusy(provider.id, false);
|
||
}
|
||
}
|
||
|
||
async function handleModels(provider: AiProviderResponse) {
|
||
if (!canManage) {
|
||
return;
|
||
}
|
||
setExpandedProviderId(null);
|
||
setProviderBusy(provider.id, true);
|
||
try {
|
||
const result = await platformApiClient.listAiProviderModels(provider.id);
|
||
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length} 个模型` });
|
||
} catch (error) {
|
||
setAction({ providerId: provider.id, label: "models", success: false, message: errorMessage(error, "模型刷新失败") });
|
||
} finally {
|
||
setProviderBusy(provider.id, false);
|
||
}
|
||
}
|
||
|
||
function requestStatusChange(provider: AiProviderResponse) {
|
||
if (!canManage || busyProviderIds.has(provider.id)) {
|
||
return;
|
||
}
|
||
setExpandedProviderId(null);
|
||
setConfirmStatus(provider);
|
||
}
|
||
|
||
async function handleSavedFormTest() {
|
||
const providerId = form.id.trim() || selectedId;
|
||
if (formMode !== "edit" || !providerId) {
|
||
setFormCheck({ status: "failed", message: "请先保存提供商,再测试已保存配置。" });
|
||
return;
|
||
}
|
||
setFormCheck({ status: "pending", message: "正在测试已保存配置…" });
|
||
try {
|
||
const result = await platformApiClient.testAiProvider(providerId);
|
||
setAction({ providerId, label: "test", success: result.success, message: result.message });
|
||
setFormCheck({ status: result.success ? "succeeded" : "failed", message: result.success ? `${result.message}。测试成功后可在更多菜单启用。` : result.message });
|
||
} catch (error) {
|
||
const message = errorMessage(error, "测试失败");
|
||
setAction({ providerId, label: "test", success: false, message });
|
||
setFormCheck({ status: "failed", message });
|
||
}
|
||
}
|
||
|
||
async function handleDiscoverModelsForForm() {
|
||
const providerId = form.id.trim() || selectedId;
|
||
if (formMode !== "edit" || !providerId) {
|
||
setFormCheck({ status: "failed", message: "请先保存提供商,再从平台发现模型。" });
|
||
return;
|
||
}
|
||
setFormCheck({ status: "pending", message: "正在发现模型…" });
|
||
try {
|
||
const result = await platformApiClient.listAiProviderModels(providerId);
|
||
setForm((current) => ({
|
||
...current,
|
||
modelsText: result.models.join(", "),
|
||
defaultModel: result.defaultModel ?? result.models[0] ?? current.defaultModel
|
||
}));
|
||
setAction({ providerId, label: "models", success: true, message: `${result.models.length} 个模型` });
|
||
setFormCheck({ status: "succeeded", message: `已填入 ${result.models.length} 个模型;保存后生效。` });
|
||
} catch (error) {
|
||
const message = errorMessage(error, "模型发现失败");
|
||
setAction({ providerId, label: "models", success: false, message });
|
||
setFormCheck({ status: "failed", message });
|
||
}
|
||
}
|
||
|
||
function upsertProvider(provider: AiProviderResponse) {
|
||
setProviders((current) => {
|
||
const exists = current.some((item) => item.id === provider.id);
|
||
return exists ? current.map((item) => (item.id === provider.id ? provider : item)) : [...current, provider];
|
||
});
|
||
}
|
||
|
||
const completedForm = completeAiProviderForm(form);
|
||
const formDefaults = aiProviderKindDefaults[form.kind];
|
||
const secretRequired = completedForm.relayMode !== "local";
|
||
|
||
return (
|
||
<section className="ai-providers-page" aria-labelledby="ai-provider-title">
|
||
<header className="page-header ai-provider-header">
|
||
<div>
|
||
<p className="page-kicker">模型</p>
|
||
<h1 id="ai-provider-title" className="page-title">
|
||
AI 提供商管理
|
||
</h1>
|
||
</div>
|
||
<span className={cx("page-status", viewState === "api" && canManage && "page-status-ready")}>{!canManage ? (viewState === "local-development" ? "本地开发 / 只读" : "只读模式") : viewStateLabel(viewState)}</span>
|
||
</header>
|
||
|
||
<dl className="page-summary-strip ai-provider-summary" aria-label="AI 提供商快速状态">
|
||
<div className="page-summary-chip summary-tone-neutral">
|
||
<dt>提供商</dt>
|
||
<dd>{listState === "ready" ? metrics.total : "--"}</dd>
|
||
</div>
|
||
<div className="page-summary-chip summary-tone-success">
|
||
<dt>启用</dt>
|
||
<dd>{listState === "ready" ? metrics.active : "--"}</dd>
|
||
</div>
|
||
<div className="page-summary-chip summary-tone-warning">
|
||
<dt>模型</dt>
|
||
<dd>{listState === "ready" ? metrics.models : "--"}</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<div className="ai-provider-toolbar" aria-label="provider filters">
|
||
{(["all", "active", "disabled", "error"] as AiProviderFilter[]).map((item) => (
|
||
<button key={item} type="button" className={cx("segmented-button", filter === item && "segmented-button-active")} onClick={() => setFilter(item)}>
|
||
{filterLabel(item)}
|
||
</button>
|
||
))}
|
||
<button type="button" className="icon-command" disabled={!canManage} title={canManage ? "新增提供商" : "当前账号没有管理权限"} onClick={startCreate}>
|
||
<Candy size={16} />
|
||
<span>新增</span>
|
||
</button>
|
||
</div>
|
||
{!canManage && <ResultBadge status="pending" label="当前账号为只读模式,保存、状态和测试动作需要平台管理员权限" />}
|
||
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
|
||
|
||
{listState === "loading" && <LoadingState label="正在加载 AI 提供商…" />}
|
||
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => void refreshProviders()} />}
|
||
|
||
{listState === "ready" && providers.length === 0 && (
|
||
<EmptyState icon={<FlaskConical size={26} />} title="暂无 AI 提供商" description="平台还没有返回任何提供商。创建第一个平台托管的 AI 提供商后,这里会显示 API 数据。" actionLabel="新增提供商" onAction={startCreate} />
|
||
)}
|
||
|
||
{listState === "ready" && providers.length > 0 && (
|
||
<div className="provider-table-wrap">
|
||
<table className="provider-table">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>状态</th>
|
||
<th>类型</th>
|
||
<th>模式</th>
|
||
<th>模型</th>
|
||
<th>密钥状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredProviders.map((provider) => (
|
||
<tr key={provider.id} className={cx(provider.id === selectedId && "provider-row-active")}>
|
||
<td>
|
||
<button type="button" className="table-link-button" disabled={!canManage} onClick={() => selectProvider(provider)}>
|
||
{provider.name}
|
||
</button>
|
||
<span className="provider-id">{provider.id}</span>
|
||
</td>
|
||
<td>
|
||
<span className={cx("status-pill", `status-${provider.status}`)}>{statusLabel(provider.status)}</span>
|
||
</td>
|
||
<td>{provider.kind}</td>
|
||
<td>{provider.relayMode}</td>
|
||
<td>{provider.models.length}</td>
|
||
<td>
|
||
<span className={cx("status-pill", provider.apiKeyConfigured ? "status-active" : "status-disabled")}>{provider.apiKeyConfigured ? "已配置" : "未配置"}</span>
|
||
</td>
|
||
<td className="provider-actions-cell">
|
||
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
|
||
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
|
||
<FlaskConical size={15} />
|
||
<span>{busyProviderIds.has(provider.id) ? "处理中" : "测试"}</span>
|
||
</button>
|
||
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
|
||
<Sparkles size={15} />
|
||
<span>{busyProviderIds.has(provider.id) ? "处理中" : "模型"}</span>
|
||
</button>
|
||
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
|
||
<WandSparkles size={15} />
|
||
<span>编辑</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cx(expandedProviderId === provider.id && "row-action-button-active")}
|
||
aria-expanded={expandedProviderId === provider.id}
|
||
aria-label={`${provider.name} 更多操作`}
|
||
disabled={!canManage || busyProviderIds.has(provider.id)}
|
||
onClick={() => setExpandedProviderId((current) => (current === provider.id ? null : provider.id))}
|
||
>
|
||
<MoreHorizontal size={15} />
|
||
<span>更多</span>
|
||
</button>
|
||
</div>
|
||
{expandedProviderId === provider.id && (
|
||
<div className="inline-action-menu" role="menu" aria-label={`${provider.name} 更多操作`}>
|
||
<button type="button" role="menuitem" onClick={() => requestStatusChange(provider)}>
|
||
<Power size={14} />
|
||
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
className="danger-command"
|
||
onClick={() => {
|
||
setExpandedProviderId(null);
|
||
if (canManage) setConfirmRetire(provider);
|
||
}}
|
||
>
|
||
<UserRoundMinus size={14} />
|
||
<span>退役提供商</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{filteredProviders.length === 0 && <EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />}
|
||
</div>
|
||
)}
|
||
|
||
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={() => { if (viewState !== "saving") closeForm(); }}>
|
||
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
|
||
<ProviderSetupGuide />
|
||
<div className="provider-preset-grid" aria-label="提供商预设">
|
||
{providerPresets.map((preset) => (
|
||
<button key={preset.kind} type="button" className={cx("provider-preset-option", form.kind === preset.kind && "provider-preset-option-active")} onClick={() => applyProviderPreset(preset)}>
|
||
<strong>{preset.label}</strong>
|
||
<span>{aiProviderKindDefaults[preset.kind].requirement}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label>
|
||
<span>名称</span>
|
||
<input name="name" value={form.name} onChange={handleInput} />
|
||
<small className="field-help">可选;留空时使用厂商默认名称。系统 ID 会自动生成。</small>
|
||
</label>
|
||
<label>
|
||
<span>{secretRequired ? "平台密钥引用" : "密钥引用(本地模式可留空)"}</span>
|
||
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={form.apiKeyConfigured ? "已配置;留空保持不变" : secretRequired ? "secret://providers/..." : "本地服务通常不需要"} />
|
||
<small className="field-help">
|
||
{secretRequired ? "填写 secret://providers/...;真实密钥进入平台 secret store,不粘贴到页面。" : "Ollama 本地模式默认只需要 Base URL 和模型名。"}
|
||
</small>
|
||
</label>
|
||
<div className="provider-generated-id" aria-label="自动生成的提供商 ID">
|
||
<span>系统 ID</span>
|
||
<code>{generatedAiProviderId(form)}</code>
|
||
<small>自动生成,不需要手填。</small>
|
||
</div>
|
||
<details className="provider-advanced-settings">
|
||
<summary>高级设置:Base URL、模型、模式、超时</summary>
|
||
<div className="form-grid">
|
||
<label>
|
||
<span>类型</span>
|
||
<select name="kind" value={form.kind} onChange={handleInput}>
|
||
<option value="openai-compatible">OpenAI Compatible</option>
|
||
<option value="openai">OpenAI</option>
|
||
<option value="claude">Claude</option>
|
||
<option value="gemini">Gemini</option>
|
||
<option value="ollama">Ollama</option>
|
||
<option value="custom">Custom</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>模式</span>
|
||
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
|
||
<option value="direct">Direct</option>
|
||
<option value="relay">Relay</option>
|
||
<option value="local">Local</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
<span>Base URL</span>
|
||
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} placeholder={form.baseUrlConfigured ? "已配置;留空保持不变" : formDefaults.baseUrl} />
|
||
<small className="field-help">{formDefaults.advancedNote}</small>
|
||
</label>
|
||
<label>
|
||
<span>模型</span>
|
||
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
|
||
<small className="field-help">逗号分隔;编辑已保存配置时可以自动发现并填入。</small>
|
||
</label>
|
||
<div className="form-grid">
|
||
<label>
|
||
<span>默认模型</span>
|
||
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
|
||
</label>
|
||
<label>
|
||
<span>超时 ms</span>
|
||
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
|
||
</label>
|
||
</div>
|
||
<label>
|
||
<span>脱敏策略</span>
|
||
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
|
||
<small className="field-help">默认 default 会隐藏密钥、Bearer token 和敏感配置片段。</small>
|
||
</label>
|
||
</details>
|
||
<div className="form-helper-actions">
|
||
<button type="button" className="theme-upload" onClick={runFormPreflight}>
|
||
保存前检查
|
||
</button>
|
||
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleSavedFormTest()}>
|
||
{formMode === "edit" ? "测试已保存配置" : "保存后可测试"}
|
||
</button>
|
||
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleDiscoverModelsForForm()}>
|
||
{formMode === "edit" ? "发现模型并填入" : "保存后可发现模型"}
|
||
</button>
|
||
</div>
|
||
{formCheck && <ResultBadge status={formCheck.status} label={formCheck.message} />}
|
||
<div className="confirm-actions">
|
||
<button type="button" disabled={viewState === "saving"} onClick={closeForm}>
|
||
取消
|
||
</button>
|
||
<button type="submit" className="confirm-primary" disabled={!canManage || viewState === "saving"}>
|
||
<WandSparkles size={16} />
|
||
<span>{viewState === "saving" ? "保存中…" : "保存配置"}</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</ManagementDialog>
|
||
|
||
<ConfirmDialog
|
||
open={confirmRetire !== null}
|
||
title="退役 AI 提供商"
|
||
description={`确认将 ${confirmRetire?.name ?? ""}(${confirmRetire?.id ?? ""})退役为停用状态?如果仍被引用,平台会拒绝这次变更。`}
|
||
confirmLabel="确认退役"
|
||
danger
|
||
busy={confirmBusy}
|
||
onCancel={() => { if (!confirmBusy) setConfirmRetire(null); }}
|
||
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
|
||
/>
|
||
<ConfirmDialog
|
||
open={confirmStatus !== null}
|
||
title="确认提供商状态变更"
|
||
description={`确认将 ${confirmStatus?.name ?? "该提供商"} ${confirmStatus?.status === "active" ? "停用" : "启用"}?平台会返回持久状态,失败时保持当前状态。`}
|
||
confirmLabel={confirmStatus?.status === "active" ? "确认停用" : "确认启用"}
|
||
danger={confirmStatus?.status === "active"}
|
||
busy={confirmStatus ? busyProviderIds.has(confirmStatus.id) : false}
|
||
onCancel={() => { if (!confirmStatus || !busyProviderIds.has(confirmStatus.id)) setConfirmStatus(null); }}
|
||
onConfirm={() => { if (confirmStatus) { void handleStatus(confirmStatus).then((succeeded) => { if (succeeded) setConfirmStatus(null); }); } }}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ProviderSetupGuide() {
|
||
return (
|
||
<div className="form-guidance provider-setup-guide">
|
||
<strong>配置流程</strong>
|
||
<span>先选厂商,再填写平台侧 secret 引用。ID、Base URL、模型和超时会按厂商默认值生成,需要时再展开高级设置。</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function errorMessage(error: unknown, fallback: string): string {
|
||
if (!(error instanceof Error)) {
|
||
return fallback;
|
||
}
|
||
if (error.message.toLowerCase().includes("reference")) {
|
||
return `${fallback}:${error.message}`;
|
||
}
|
||
return error.message || fallback;
|
||
}
|
||
|
||
function viewStateLabel(viewState: AiProviderViewState): string {
|
||
switch (viewState) {
|
||
case "api":
|
||
return "平台 API";
|
||
case "local-development":
|
||
return "本地开发";
|
||
case "saving":
|
||
return "保存中";
|
||
default:
|
||
return "连接失败";
|
||
}
|
||
}
|
||
|
||
function filterLabel(filter: AiProviderFilter): string {
|
||
switch (filter) {
|
||
case "active":
|
||
return "启用";
|
||
case "disabled":
|
||
return "停用";
|
||
case "error":
|
||
return "错误";
|
||
default:
|
||
return "全部";
|
||
}
|
||
}
|
||
|
||
function statusLabel(status: AiProviderStatus): string {
|
||
switch (status) {
|
||
case "active":
|
||
return "启用";
|
||
case "disabled":
|
||
return "停用";
|
||
default:
|
||
return "错误";
|
||
}
|
||
}
|