feat: 自动更新
This commit is contained in:
@@ -2,11 +2,15 @@ import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, Use
|
||||
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
|
||||
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,
|
||||
@@ -25,79 +29,19 @@ interface AiProvidersPageProps extends Partial<PageComponentProps> {
|
||||
}
|
||||
|
||||
interface ProviderPreset {
|
||||
id: string;
|
||||
kind: AiProviderKind;
|
||||
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"
|
||||
}
|
||||
}
|
||||
{ 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 = {}) {
|
||||
@@ -171,7 +115,12 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
}
|
||||
|
||||
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
||||
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -200,36 +149,30 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
}
|
||||
|
||||
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 引用和默认模型。` });
|
||||
setForm((current) => applyAiProviderKindDefaults(current, preset.kind));
|
||||
setFormCheck({ status: "pending", message: `已选择 ${preset.label}。常规配置只需要平台 secret 引用;高级参数已按厂商默认值填入。` });
|
||||
}
|
||||
|
||||
function runFormPreflight() {
|
||||
const missing: string[] = [];
|
||||
const models = form.modelsText
|
||||
const completed = completeAiProviderForm(form);
|
||||
const models = completed.modelsText
|
||||
.split(",")
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean);
|
||||
if (!form.id.trim()) {
|
||||
missing.push("ID");
|
||||
}
|
||||
if (!form.name.trim()) {
|
||||
if (!completed.name.trim()) {
|
||||
missing.push("名称");
|
||||
}
|
||||
if (!form.baseUrl.trim()) {
|
||||
if (!completed.baseUrl.trim()) {
|
||||
missing.push("Base URL");
|
||||
}
|
||||
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) {
|
||||
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(form.timeoutMs, 10)) || Number.parseInt(form.timeoutMs, 10) <= 0) {
|
||||
if (!Number.isFinite(Number.parseInt(completed.timeoutMs, 10)) || Number.parseInt(completed.timeoutMs, 10) <= 0) {
|
||||
missing.push("有效超时");
|
||||
}
|
||||
setFormCheck(
|
||||
@@ -242,12 +185,14 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setViewState("saving");
|
||||
const existing = providers.some((provider) => provider.id === form.id.trim());
|
||||
const completed = completeAiProviderForm(form);
|
||||
const providerId = generatedAiProviderId(completed);
|
||||
const existing = providers.some((provider) => provider.id === providerId);
|
||||
|
||||
try {
|
||||
const saved = existing
|
||||
? await platformApiClient.updateAiProvider(form.id.trim(), aiProviderUpdateRequestFromForm(form))
|
||||
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(form));
|
||||
? await platformApiClient.updateAiProvider(providerId, aiProviderUpdateRequestFromForm(completed))
|
||||
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(completed));
|
||||
upsertProvider(saved);
|
||||
setSelectedId(saved.id);
|
||||
setForm(aiProviderToForm(saved));
|
||||
@@ -260,7 +205,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
} catch (error) {
|
||||
setViewState("error");
|
||||
const message = errorMessage(error, "保存失败");
|
||||
setAction({ providerId: form.id.trim() || selectedId, label: "save", success: false, message });
|
||||
setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
|
||||
setFormCheck({ status: "failed", message });
|
||||
}
|
||||
}
|
||||
@@ -359,6 +304,10 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -497,72 +446,78 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
|
||||
<ProviderSetupGuide />
|
||||
<div className="provider-preset-grid" aria-label="提供商预设">
|
||||
{providerPresets.map((preset) => (
|
||||
<button key={preset.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(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>{preset.summary}</span>
|
||||
<span>{aiProviderKindDefaults[preset.kind].requirement}</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} />
|
||||
<small className="field-help">可选;留空时使用厂商默认名称。系统 ID 会自动生成。</small>
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
<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>默认模型</span>
|
||||
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
|
||||
<span>Base URL</span>
|
||||
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
|
||||
<small className="field-help">{formDefaults.advancedNote}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>超时 ms</span>
|
||||
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
|
||||
<span>模型</span>
|
||||
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
|
||||
<small className="field-help">逗号分隔;编辑已保存配置时可以自动发现并填入。</small>
|
||||
</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-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}>
|
||||
保存前检查
|
||||
@@ -605,7 +560,7 @@ function ProviderSetupGuide() {
|
||||
return (
|
||||
<div className="form-guidance provider-setup-guide">
|
||||
<strong>配置流程</strong>
|
||||
<span>先选预设,再填写平台侧 secret 引用。保存前做本地检查;保存后测试已保存配置,测试成功后再启用。</span>
|
||||
<span>先选厂商,再填写平台侧 secret 引用。ID、Base URL、模型和超时会按厂商默认值生成,需要时再展开高级设置。</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user