first commit
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import { Candy, CheckCircle2, FlaskConical, Power, Sparkles, WandSparkles } 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 {
|
||||
aiProviderToForm,
|
||||
emptyAiProviderForm,
|
||||
type AiProviderActionState,
|
||||
type AiProviderFilter,
|
||||
type AiProviderFormState,
|
||||
summarizeAiProviders,
|
||||
type AiProviderViewState
|
||||
} from "../contracts/aiProviders";
|
||||
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
const seedProviders: AiProviderResponse[] = [
|
||||
{
|
||||
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: "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"
|
||||
}
|
||||
];
|
||||
|
||||
export function AiProvidersPage() {
|
||||
const [providers, setProviders] = useState<AiProviderResponse[]>(seedProviders);
|
||||
const [selectedId, setSelectedId] = useState(seedProviders[0]?.id ?? "");
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
platformApiClient
|
||||
.listAiProviders()
|
||||
.then((response) => {
|
||||
if (cancelled || response.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
setProviders(response.items);
|
||||
setSelectedId(response.items[0].id);
|
||||
setForm(aiProviderToForm(response.items[0]));
|
||||
setViewState("api");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setViewState("local");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function selectProvider(provider: AiProviderResponse) {
|
||||
setSelectedId(provider.id);
|
||||
setForm(aiProviderToForm(provider));
|
||||
setAction(null);
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
setSelectedId("");
|
||||
setForm(emptyAiProviderForm());
|
||||
setAction(null);
|
||||
}
|
||||
|
||||
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");
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatus(provider: AiProviderResponse) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(provider: AiProviderResponse) {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModels(provider: AiProviderResponse) {
|
||||
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}` });
|
||||
}
|
||||
}
|
||||
|
||||
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")}>{viewState === "api" ? "已连接" : viewState === "saving" ? "保存中" : "本地视图"}</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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="ai-provider-workspace">
|
||||
<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>
|
||||
<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)}>
|
||||
<FlaskConical size={15} />
|
||||
</button>
|
||||
<button type="button" title="刷新模型" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
|
||||
<Sparkles size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label>
|
||||
ID
|
||||
<input name="id" value={form.id} onChange={handleInput} disabled={Boolean(selectedProvider)} />
|
||||
</label>
|
||||
<label>
|
||||
名称
|
||||
<input name="name" value={form.name} onChange={handleInput} />
|
||||
</label>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
类型
|
||||
<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>
|
||||
模式
|
||||
<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>
|
||||
Base URL
|
||||
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
|
||||
</label>
|
||||
<label>
|
||||
密钥引用
|
||||
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
|
||||
</label>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
默认模型
|
||||
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
|
||||
</label>
|
||||
<label>
|
||||
超时 ms
|
||||
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
脱敏策略
|
||||
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
|
||||
</label>
|
||||
<button type="submit" className="primary-command" title="保存提供商">
|
||||
<WandSparkles size={16} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function localProviderFromForm(form: AiProviderFormState, status: AiProviderStatus): AiProviderResponse {
|
||||
const request = aiProviderCreateRequestFromForm(form);
|
||||
return {
|
||||
...request,
|
||||
defaultModel: request.defaultModel,
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
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 "错误";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user