feat(*): 插件中心与模型协议 - 完善声明式协议运行时并增加功能开放控制

This commit is contained in:
ddCat
2026-08-23 23:19:27 +08:00
parent 7cc6021f6e
commit 919037e5d9
106 changed files with 8817 additions and 500 deletions

View File

@@ -382,7 +382,7 @@ export function getAdminFeatureAvailability() {
return request<{ features: FeatureAvailability }>(api.get("/admin/settings/features"));
}
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled">) {
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers">) {
return request<{ features: FeatureAvailability }>(api.patch("/admin/settings/features", features));
}

View File

@@ -23,7 +23,7 @@ export function channelRequest(config: RelayConfig, upstreamUrl: string, headers
normalizedHeaders.delete("x-goog-api-key");
normalizedHeaders.set("Authorization", `Bearer ${runtimeConfig.apiKey}`);
normalizedHeaders.set("X-Canvas-Upstream-URL", normalizedUpstreamUrl);
normalizedHeaders.set("X-Canvas-Upstream-Format", runtimeConfig.apiFormat === "gemini" ? "gemini" : "openai");
normalizedHeaders.set("X-Canvas-Upstream-Format", runtimeConfig.apiFormat === "gemini" ? "gemini" : runtimeConfig.apiFormat === "claude" ? "claude" : "openai");
if (runtimeConfig.allowLocalChannel === true) {
normalizedHeaders.set("X-Canvas-Allow-Local-Channel", "1");
normalizedHeaders.set("X-Canvas-Upstream-Base-URL", normalizedBaseUrl);

View File

@@ -15,6 +15,16 @@ import { buildBackendToolRequests, type ResponseFunctionTool, type ResponseInput
export { logicalModelIDForConfig };
// 只要模型明确选择了请求协议,就统一交给后端协议运行时执行:逻辑模型
// 由逻辑模型路由解析协议,系统渠道由 channelId 解析真实渠道模型,用户
// 自建渠道则由所选协议插件负责第三方字段映射和结果解析。没有显式协议
// 的旧配置继续保留前端直连兼容路径。
export function backendModelRuntimeRequired(config: AiConfig) {
if (logicalModelIDForConfig(config)) return true;
const requestConfig = resolveModelRequestConfig(config, config.model);
return Boolean(requestConfig.channelId || requestConfig.interfaceType);
}
export type BackendGenerationMode = "text" | "image" | "video" | "audio";
export type BackendGenerationResult = {
@@ -121,13 +131,14 @@ export async function runBackendToolGenerationTask(options: {
}): Promise<ToolResponseResult> {
throwIfAborted(options.signal);
const logicalModelId = logicalModelIDForConfig(options.config);
if (!logicalModelId) throw new Error("当前模型不是平台系统模型");
const requestConfig = resolveModelRequestConfig(options.config, options.config.model);
if (!logicalModelId && !requestConfig.channelId && !requestConfig.interfaceType) throw new Error("当前模型未选择可用请求协议");
const task = await createGenerationTask({
type: "canvas_text",
operation: "text",
prompt: options.prompt,
model: options.config.model,
logicalModelId,
...(logicalModelId ? { logicalModelId } : {}),
input: {
mode: "text",
prompt: options.prompt,

View File

@@ -33,6 +33,40 @@ export function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefin
};
}
export function toClaudeBody(config: AiConfig, messages: ResponseInputMessage[], tools: ResponseFunctionTool[] = []) {
const system = [config.systemPrompt.trim(), ...messages.flatMap((message) => !(("type" in message)) && message.role === "system" ? [String(message.content || "")] : [])].filter(Boolean).join("\n\n");
const bodyMessages: Array<Record<string, unknown>> = [];
for (const message of messages) {
if ("type" in message) {
bodyMessages.push({ role: "assistant", content: [{ type: "tool_use", id: message.call_id, name: message.name, input: jsonObject(message.arguments) }] });
} else if (message.role === "system") {
continue;
} else if (message.role === "tool") {
bodyMessages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }] });
} else {
bodyMessages.push({ role: message.role === "assistant" ? "assistant" : "user", content: toClaudeContent(message.content) });
}
}
return {
model: config.model,
max_tokens: 4096,
messages: bodyMessages,
...(system ? { system } : {}),
...(tools.length ? { tools: tools.map((tool) => ({ name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters })) } : {}),
};
}
function toClaudeContent(content: ResponseMessageContent): unknown {
if (!Array.isArray(content)) return String(content || "");
return content.map((item) => {
if (item.type === "text") return { type: "text", text: item.text };
const value = item.type === "image_url" ? item.image_url.url : item.file_url.url;
const match = value.match(/^data:([^;,]+);base64,(.+)$/);
if (match) return { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } };
return item.type === "image_url" ? { type: "image", source: { type: "url", url: value } } : { type: "text", text: `${item.file_url.name}: ${value}` };
});
}
export function toChatCompletionMessages(messages: ResponseInputMessage[]) {
const result: Array<Record<string, unknown>> = [];
for (let index = 0; index < messages.length;) {

View File

@@ -82,6 +82,72 @@ export async function requestStreamingChatCompletion(config: AiConfig, body: Rec
return { content: state.text, toolCalls, ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
}
export async function requestStreamingClaude(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const request = channelRequest(config, aiApiUrl(config, "/messages"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
const response = await fetch(request.url, {
method: "POST",
headers: request.headers,
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "Claude 请求失败"));
if (!response.body || !(response.headers.get("content-type") || "").includes("text/event-stream")) {
const payload = (await readJsonPayload<Record<string, unknown>>(response, "Claude 请求失败")) as Record<string, unknown>;
return parseClaudeResult(payload);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let text = "";
const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
let blockIndex = -1;
const consume = (chunk: string, final = false) => {
buffer += chunk;
const frames = buffer.split(/\r?\n\r?\n/);
buffer = final ? "" : frames.pop() || "";
for (const frame of frames) {
const event = frame.match(/^event:\s*(.+)$/m)?.[1]?.trim() || "";
const raw = frame.match(/^data:\s*(.+)$/m)?.[1]?.trim() || "";
if (!raw || raw === "[DONE]") continue;
let payload: Record<string, unknown>;
try { payload = JSON.parse(raw) as Record<string, unknown>; } catch { throw new Error("Claude 流式响应格式无效"); }
if (event === "error" || payload.type === "error") throw new Error(String((payload.error as Record<string, unknown> | undefined)?.message || "Claude 上游返回失败"));
if (payload.type === "content_block_start") {
const contentBlock = payload.content_block as Record<string, unknown> | undefined;
if (contentBlock?.type === "tool_use") {
blockIndex = Number(payload.index ?? toolCalls.size);
toolCalls.set(blockIndex, { id: String(contentBlock.id || ""), name: String(contentBlock.name || ""), arguments: "" });
}
} else if (payload.type === "content_block_delta") {
const delta = payload.delta as Record<string, unknown> | undefined;
if (delta?.type === "text_delta" && typeof delta.text === "string") { text += delta.text; onDelta?.(delta.text); }
if (delta?.type === "input_json_delta" && blockIndex >= 0) toolCalls.get(blockIndex)!.arguments += String(delta.partial_json || "");
}
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
consume(decoder.decode(value, { stream: true }));
}
consume(decoder.decode(), true);
return { content: text, toolCalls: Array.from(toolCalls.values()).filter((call) => call.id && call.name).map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: call.arguments || "{}" } })) };
}
function parseClaudeResult(payload: Record<string, unknown>): ToolResponseResult {
const content = Array.isArray(payload.content) ? payload.content : [];
let text = "";
const toolCalls: ToolResponseResult["toolCalls"] = [];
for (const item of content) {
const block = item as Record<string, unknown>;
if (block.type === "text") text += String(block.text || "");
if (block.type === "tool_use") toolCalls.push({ id: String(block.id || ""), type: "function", function: { name: String(block.name || ""), arguments: JSON.stringify(block.input || {}) } });
}
if (!text && !toolCalls.length) throw new Error("Claude 接口没有返回内容");
return { content: text, toolCalls };
}
export async function requestGeminiStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const request = channelRequest(config, `${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, geminiHeaders(config));
const response = await fetch(request.url, {

View File

@@ -15,8 +15,8 @@ const IMAGE_OUTPUT_FORMAT = "png";
import type { AiTextMessage, GeminiPart, ImageApiResponse, RequestOptions, ResponseApiPayload, ResponseFunctionTool, ResponseInputMessage, ToolChoice, ToolResponseResult } from "@/services/api/image-contracts";
import { normalizeGrokImageResolution, normalizeQuality, normalizeVolcengineArkImageSize, resolveImageRequestSize, validateImageCapability } from "@/services/api/image-validation";
import { parseGeminiImagePayload, parseImagePayload, readAxiosError } from "@/services/api/image-response";
import { toChatCompletionMessages, toChatCompletionToolChoice, toGeminiBody, toGeminiToolOptions, toResponseInput, toResponseTool, withSystemMessage } from "@/services/api/image-protocols";
import { requestGeminiStreamingResponse, requestStreamingChatCompletion, requestStreamingResponse } from "@/services/api/image-streaming";
import { toChatCompletionMessages, toChatCompletionToolChoice, toClaudeBody, toGeminiBody, toGeminiToolOptions, toResponseInput, toResponseTool, withSystemMessage } from "@/services/api/image-protocols";
import { requestGeminiStreamingResponse, requestStreamingChatCompletion, requestStreamingClaude, requestStreamingResponse } from "@/services/api/image-streaming";
export { buildBackendToolRequests } from "@/services/api/image-protocols";
export type { AiTextContentPart, AiTextMessage, ResponseFunctionTool, ResponseInputMessage, ResponseToolCall, ToolChoice, ToolResponseResult } from "@/services/api/image-contracts";
@@ -241,6 +241,11 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
if (requestConfig.interfaceType === "claude-api") {
const answer = (await requestStreamingClaude(requestConfig, toClaudeBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const answer =
(
@@ -282,6 +287,7 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn
if (requestConfig.apiFormat === "gemini") {
return await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages, toGeminiToolOptions(tools, toolChoice)), onDelta, options);
}
if (requestConfig.interfaceType === "claude-api") return await requestStreamingClaude(requestConfig, toClaudeBody(requestConfig, messages, tools), onDelta, options);
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const chatPayload: Record<string, unknown> = {
model: requestConfig.model,

View File

@@ -135,6 +135,7 @@ export type PublicChannelModel = {
modelKey: string;
displayName: string;
capability: string;
protocol?: string;
capabilityConfig?: Record<string, any>;
priceTiers: PublicChannelModelPriceTier[];
pricingMode: string;

View File

@@ -0,0 +1,56 @@
import { apiClient, request } from "@/services/api/request";
import type { PluginManifest, ProtocolCapability, ProtocolScope } from "@/lib/plugins/plugin-types";
export type BackendPlugin = {
manifest: {
id: string;
name: string;
version: string;
apiVersion: string;
category: "protocol";
description?: string;
author?: string;
surfaces: string[];
permissions: string[];
trusted: boolean;
kind: "protocol";
protocol: {
categories: ProtocolCapability[];
scopes: ProtocolScope[];
create?: string;
poll?: string;
cancel?: string;
contentType?: string;
documentation?: string;
parameters?: NonNullable<PluginManifest["protocol"]>["parameters"];
};
};
source: "bundled" | "uploaded" | string;
fileName: string;
sha256: string;
installedAt: string;
updatedAt: string;
status: "enabled" | "disabled" | "invalid" | string;
error?: string;
};
export async function fetchPlugins() {
const result = await request<{ plugins: BackendPlugin[] }>(apiClient.get("/plugins"));
return result.plugins;
}
export async function uploadPlugin(file: File) {
const body = new FormData();
body.append("file", file);
const result = await request<{ plugin: BackendPlugin }>(apiClient.post("/plugins", body));
return result.plugin;
}
export async function setPluginEnabled(id: string, enabled: boolean) {
const result = await request<{ plugin: BackendPlugin }>(apiClient.post(`/plugins/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`));
return result.plugin;
}
export async function uninstallPlugin(id: string) {
await request<{ deleted: boolean }>(apiClient.delete(`/plugins/${encodeURIComponent(id)}`));
}

View File

@@ -0,0 +1,35 @@
import { apiClient, request } from "@/services/api/request";
import type { ModelProtocolDefinition, ProtocolCapability } from "@/lib/model-protocols";
type ProtocolCatalogItem = {
id: string;
version: string;
name: string;
vendor: string;
categories: string[];
scopes: string[];
create?: string;
poll?: string;
contentType?: string;
enabled: boolean;
unavailableReason?: string;
};
export async function fetchProtocolCatalog(scope: string, capability?: ProtocolCapability) {
const result = await request<{ protocols: ProtocolCatalogItem[] }>(apiClient.get("/protocols", { params: { scope, capability } }));
return result.protocols.filter((item) => item.enabled && !item.unavailableReason).map(toProtocolDefinition);
}
function toProtocolDefinition(item: ProtocolCatalogItem): ModelProtocolDefinition {
return {
value: item.id,
label: item.name,
vendor: item.vendor,
capability: (item.categories[0] || "text") as ProtocolCapability,
create: item.create || "",
poll: item.poll,
contentType: item.contentType || "application/json",
media: `${item.vendor} · ${item.version}`,
enabled: item.enabled && !item.unavailableReason,
};
}