Files
browser/platform_web/pages/AiProvidersPage.tsx
T
2026-07-15 19:43:06 +08:00

614 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
import { type ChangeEvent, type FormEvent, 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 {
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 }: 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 [confirmBusy, setConfirmBusy] = useState(false);
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
useEffect(() => {
let cancelled = false;
if (initialState?.providers) {
setListState(initialState.listState ?? "ready");
setViewState(initialState.source ?? "local-development");
return () => {
cancelled = true;
};
}
platformApiClient
.listAiProviders()
.then((response) => {
if (cancelled) {
return;
}
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) => {
if (cancelled) {
return;
}
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
});
return () => {
cancelled = true;
};
}, [initialState?.listState, initialState?.providers, initialState?.selectedId, initialState?.source]);
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
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 {
updateForm(key, event.target.value);
}
setFormCheck(null);
}
function selectProvider(provider: AiProviderResponse) {
setSelectedId(provider.id);
setForm(aiProviderToForm(provider));
setFormMode("edit");
setAction(null);
setFormCheck(null);
setExpandedProviderId(null);
}
function startCreate() {
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()) {
missing.push("Base URL");
}
if (completed.relayMode !== "local" && !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();
setViewState("saving");
const completed = completeAiProviderForm(form);
const providerId = generatedAiProviderId(completed);
const existing = providers.some((provider) => provider.id === providerId);
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: "已保存" });
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 });
}
}
async function handleStatus(provider: AiProviderResponse) {
setExpandedProviderId(null);
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
try {
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
} catch (error) {
setAction({ providerId: provider.id, label: "status", success: false, message: errorMessage(error, "状态更新失败") });
}
}
async function handleRetire(provider: AiProviderResponse) {
setConfirmBusy(true);
try {
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
upsertProvider(retired);
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
setConfirmRetire(null);
} catch (error) {
setAction({ providerId: provider.id, label: "retire", success: false, message: errorMessage(error, "退役失败,可能仍被引用") });
} finally {
setConfirmBusy(false);
}
}
async function handleTest(provider: AiProviderResponse) {
setExpandedProviderId(null);
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, "测试失败") });
}
}
async function handleModels(provider: AiProviderResponse) {
setExpandedProviderId(null);
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, "模型刷新失败") });
}
}
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" && "page-status-ready")}>{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>{metrics.total}</dd>
</div>
<div className="page-summary-chip summary-tone-success">
<dt>启用</dt>
<dd>{metrics.active}</dd>
</div>
<div className="page-summary-chip summary-tone-warning">
<dt>模型</dt>
<dd>{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" title="新增提供商" onClick={startCreate}>
<Candy size={16} />
<span>新增</span>
</button>
</div>
{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={() => window.location.reload()} />}
{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" 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>
<code className="secret-ref">{provider.apiKeyRef}</code>
</td>
<td className="provider-actions-cell">
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<FlaskConical size={15} />
<span>测试</span>
</button>
<button type="button" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<Sparkles size={15} />
<span>模型</span>
</button>
<button type="button" 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} 更多操作`}
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={() => void handleStatus(provider)}>
<Power size={14} />
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
</button>
<button
type="button"
role="menuitem"
className="danger-command"
onClick={() => {
setExpandedProviderId(null);
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={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={secretRequired ? formDefaults.apiKeyRef : "本地服务通常不需要"} />
<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} />
<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" onClick={closeForm}>
取消
</button>
<button type="submit" className="confirm-primary">
<WandSparkles size={16} />
<span>保存配置</span>
</button>
</div>
</form>
</ManagementDialog>
<ConfirmDialog
open={confirmRetire !== null}
title="退役 AI 提供商"
description={`确认将 ${confirmRetire?.name ?? ""}${confirmRetire?.id ?? ""})退役为停用状态?如果仍被引用,平台会拒绝这次变更。`}
confirmLabel="确认退役"
danger
busy={confirmBusy}
onCancel={() => setConfirmRetire(null)}
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
/>
</section>
);
}
function ProviderSetupGuide() {
return (
<div className="form-guidance provider-setup-guide">
<strong>配置流程</strong>
<span>先选厂商,再填写平台侧 secret 引用。IDBase 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 "错误";
}
}