feat(*): 插件中心与模型协议 - 完善声明式协议运行时并增加功能开放控制
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,6 +135,7 @@ export type PublicChannelModel = {
|
||||
modelKey: string;
|
||||
displayName: string;
|
||||
capability: string;
|
||||
protocol?: string;
|
||||
capabilityConfig?: Record<string, any>;
|
||||
priceTiers: PublicChannelModelPriceTier[];
|
||||
pricingMode: string;
|
||||
|
||||
56
web/src/services/api/plugins.ts
Normal file
56
web/src/services/api/plugins.ts
Normal 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)}`));
|
||||
}
|
||||
35
web/src/services/api/protocols.ts
Normal file
35
web/src/services/api/protocols.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user