Complete platform management workflows
This commit is contained in:
@@ -1,88 +1,170 @@
|
||||
import { Candy, CheckCircle2, FlaskConical, Power, Sparkles, WandSparkles } from "lucide-react";
|
||||
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 { EmptyState } from "../components/StateViews";
|
||||
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,
|
||||
summarizeAiProviders,
|
||||
type AiProviderViewState
|
||||
type AiProviderListState,
|
||||
type AiProviderPageInitialState,
|
||||
type AiProviderViewState,
|
||||
summarizeAiProviders
|
||||
} from "../contracts/aiProviders";
|
||||
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
|
||||
import { aiProviderCreateRequestFromForm, aiProviderRetireRequest, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
const seedProviders: AiProviderResponse[] = [
|
||||
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: "ai.openai",
|
||||
name: "OpenAI Relay",
|
||||
kind: "openai-compatible",
|
||||
baseUrl: "https://relay.example.test/v1",
|
||||
apiKeyRef: "secret://providers/openai",
|
||||
models: ["gpt-4.1", "gpt-4.1-mini"],
|
||||
defaultModel: "gpt-4.1-mini",
|
||||
relayMode: "relay",
|
||||
timeoutMs: 30000,
|
||||
status: "active",
|
||||
redactionPolicy: "default"
|
||||
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: "ai.local",
|
||||
name: "Local Ollama",
|
||||
kind: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
apiKeyRef: "env://OLLAMA_API_KEY",
|
||||
models: ["llama3.1", "qwen2.5-coder"],
|
||||
defaultModel: "qwen2.5-coder",
|
||||
relayMode: "local",
|
||||
timeoutMs: 20000,
|
||||
status: "disabled",
|
||||
redactionPolicy: "local"
|
||||
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() {
|
||||
const [providers, setProviders] = useState<AiProviderResponse[]>(seedProviders);
|
||||
const [selectedId, setSelectedId] = useState(seedProviders[0]?.id ?? "");
|
||||
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(seedProviders[0]));
|
||||
const [viewState, setViewState] = useState<AiProviderViewState>("local");
|
||||
const [action, setAction] = useState<AiProviderActionState | null>(null);
|
||||
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 || response.items.length === 0) {
|
||||
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(response.items[0].id);
|
||||
setForm(aiProviderToForm(response.items[0]));
|
||||
setSelectedId(nextSelectedId);
|
||||
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
|
||||
setFormMode(nextSelectedProvider ? "edit" : null);
|
||||
setListState("ready");
|
||||
setListError("");
|
||||
setViewState("api");
|
||||
setFormCheck(null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setViewState("local");
|
||||
.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]
|
||||
);
|
||||
const selectedProvider = providers.find((provider) => provider.id === selectedId);
|
||||
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 }));
|
||||
@@ -90,18 +172,71 @@ export function AiProvidersPage() {
|
||||
|
||||
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>) {
|
||||
@@ -117,46 +252,103 @@ export function AiProvidersPage() {
|
||||
setSelectedId(saved.id);
|
||||
setForm(aiProviderToForm(saved));
|
||||
setViewState("api");
|
||||
setAction({ providerId: saved.id, label: "save", success: true, message: "saved" });
|
||||
} catch {
|
||||
const local = localProviderFromForm(form, existing ? selectedProvider?.status ?? "active" : "active");
|
||||
upsertProvider(local);
|
||||
setSelectedId(local.id);
|
||||
setForm(aiProviderToForm(local));
|
||||
setViewState("local");
|
||||
setAction({ providerId: local.id, label: "save", success: true, message: "saved locally" });
|
||||
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 });
|
||||
} catch {
|
||||
const updated = { ...provider, status: nextStatus };
|
||||
upsertProvider(updated);
|
||||
setAction({ providerId: provider.id, label: "status", success: true, message: nextStatus });
|
||||
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 {
|
||||
const success = provider.status === "active";
|
||||
setAction({ providerId: provider.id, label: "test", success, message: success ? "metadata validation passed" : "provider must be active" });
|
||||
} 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 {
|
||||
setAction({ providerId: provider.id, label: "models", success: true, message: `${provider.models.length}` });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,23 +368,23 @@ export function AiProvidersPage() {
|
||||
AI 提供商管理
|
||||
</h1>
|
||||
</div>
|
||||
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewState === "api" ? "已连接" : viewState === "saving" ? "保存中" : "本地视图"}</span>
|
||||
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewStateLabel(viewState)}</span>
|
||||
</header>
|
||||
|
||||
<div className="metric-grid ai-provider-metrics">
|
||||
<article className="metric-card metric-tone-neutral">
|
||||
<span className="metric-label">提供商</span>
|
||||
<strong className="metric-value">{metrics.total}</strong>
|
||||
</article>
|
||||
<article className="metric-card metric-tone-success">
|
||||
<span className="metric-label">启用</span>
|
||||
<strong className="metric-value">{metrics.active}</strong>
|
||||
</article>
|
||||
<article className="metric-card metric-tone-warning">
|
||||
<span className="metric-label">模型</span>
|
||||
<strong className="metric-value">{metrics.models}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<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) => (
|
||||
@@ -205,8 +397,16 @@ export function AiProvidersPage() {
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</div>
|
||||
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
|
||||
|
||||
<div className="ai-provider-workspace">
|
||||
{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>
|
||||
@@ -238,50 +438,83 @@ export function AiProvidersPage() {
|
||||
<td>
|
||||
<code className="secret-ref">{provider.apiKeyRef}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions" aria-label={`${provider.name} 操作`}>
|
||||
<button type="button" title="启用或禁用" aria-label={`${provider.name} 启用或禁用`} onClick={() => void handleStatus(provider)}>
|
||||
<Power size={15} />
|
||||
</button>
|
||||
<button type="button" title="测试配置" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
|
||||
<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" title="刷新模型" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
|
||||
<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 提供商。" />
|
||||
)}
|
||||
{filteredProviders.length === 0 && <EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="provider-form" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<div className="form-header">
|
||||
<h2>{selectedProvider ? "编辑提供商" : "新增提供商"}</h2>
|
||||
{action && (
|
||||
<span className={cx("action-result", action.success ? "action-result-success" : "action-result-failed")}>
|
||||
<CheckCircle2 size={14} />
|
||||
{action.message}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
ID
|
||||
<input name="id" value={form.id} onChange={handleInput} disabled={Boolean(selectedProvider)} />
|
||||
<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>
|
||||
@@ -292,7 +525,7 @@ export function AiProvidersPage() {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
模式
|
||||
<span>模式</span>
|
||||
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
|
||||
<option value="direct">Direct</option>
|
||||
<option value="relay">Relay</option>
|
||||
@@ -301,48 +534,103 @@ export function AiProvidersPage() {
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Base URL
|
||||
<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>
|
||||
超时 ms
|
||||
<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>
|
||||
<button type="submit" className="primary-command" title="保存提供商">
|
||||
<WandSparkles size={16} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</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 localProviderFromForm(form: AiProviderFormState, status: AiProviderStatus): AiProviderResponse {
|
||||
const request = aiProviderCreateRequestFromForm(form);
|
||||
return {
|
||||
...request,
|
||||
defaultModel: request.defaultModel,
|
||||
status
|
||||
};
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user