Files
browser/platform_web/pages/AiProvidersPage.tsx
T

659 lines
27 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 { 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 {
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 {
id: string;
label: string;
summary: string;
draft: Partial<AiProviderFormState>;
}
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
const providerPresets: ProviderPreset[] = [
{
id: "openai-relay",
label: "OpenAI Relay",
summary: "平台中转,适合公网 OpenAI 兼容网关。",
draft: {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-4.1, gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "claude-relay",
label: "Claude Relay",
summary: "平台托管 Anthropic 兼容配置,密钥只留引用。",
draft: {
id: "ai.claude",
name: "Claude Relay",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet, claude-haiku",
defaultModel: "claude-sonnet",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "gemini-relay",
label: "Gemini Relay",
summary: "平台托管 Google Gemini 配置,保存后发现模型。",
draft: {
id: "ai.gemini",
name: "Gemini Relay",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-pro",
defaultModel: "gemini-pro",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ollama-local",
label: "Ollama Local",
summary: "本机或内网模型服务,默认走本地模式。",
draft: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "secret://providers/ollama-local",
modelsText: "llama3.1, qwen2.5",
defaultModel: "llama3.1",
relayMode: "local",
redactionPolicy: "default"
}
}
];
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>) {
updateForm(event.target.name as keyof AiProviderFormState, 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) => ({
...current,
...preset.draft,
id: formMode === "edit" ? current.id : preset.draft.id ?? current.id
}));
setFormCheck({ status: "pending", message: `已套用 ${preset.label} 预设。保存前请确认 Base URL、secret 引用和默认模型。` });
}
function runFormPreflight() {
const missing: string[] = [];
const models = form.modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
if (!form.id.trim()) {
missing.push("ID");
}
if (!form.name.trim()) {
missing.push("名称");
}
if (!form.baseUrl.trim()) {
missing.push("Base URL");
}
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) {
missing.push("secret://providers/... 密钥引用");
}
if (models.length === 0) {
missing.push("至少一个模型");
}
if (!Number.isFinite(Number.parseInt(form.timeoutMs, 10)) || Number.parseInt(form.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 existing = providers.some((provider) => provider.id === form.id.trim());
try {
const saved = existing
? await platformApiClient.updateAiProvider(form.id.trim(), aiProviderUpdateRequestFromForm(form))
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(form));
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: form.id.trim() || 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];
});
}
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.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(preset)}>
<strong>{preset.label}</strong>
<span>{preset.summary}</span>
</button>
))}
</div>
<label>
<span>ID</span>
<input name="id" value={form.id} onChange={handleInput} disabled={formMode === "edit"} />
<small className="field-help">稳定逻辑 ID,例如 ai.openai;编辑已有提供商时不可修改。</small>
</label>
<label>
<span>名称</span>
<input name="name" value={form.name} onChange={handleInput} />
</label>
<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">优先用预设填入;为空或测试失败时不要启用。</small>
</label>
<label>
<span>密钥引用</span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
<small className="field-help">只填写 secret://providers/... 引用,不要粘贴 raw API key。</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>
<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 引用。保存前做本地检查;保存后测试已保存配置,测试成功后再启用。</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 "错误";
}
}