refactor(core): 任务与模型协议治理 - 拆分服务职责并禁止已发起任务取消
This commit is contained in:
161
web/src/services/api/image-protocols.ts
Normal file
161
web/src/services/api/image-protocols.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import type { BackendToolRequests, GeminiContent, GeminiPart, ResponseApiToolDefinition, ResponseFunctionTool, ResponseInputContent, ResponseInputItem, ResponseInputMessage, ResponseMessageContent, ToolChoice } from "@/services/api/image-contracts";
|
||||
|
||||
export function withSystemMessage<T extends ResponseInputMessage>(config: AiConfig, messages: T[]): ResponseInputMessage[] {
|
||||
const systemPrompt = config.systemPrompt.trim();
|
||||
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
|
||||
}
|
||||
|
||||
export function toResponseInput(messages: ResponseInputMessage[]): ResponseInputItem[] {
|
||||
return messages.flatMap((message): ResponseInputItem[] => {
|
||||
if ("type" in message) return [message];
|
||||
if (message.role === "tool") return [{ type: "function_call_output", call_id: message.tool_call_id, output: message.content }];
|
||||
return [{ role: message.role, content: toResponseContent(message.content || "") }];
|
||||
});
|
||||
}
|
||||
|
||||
function toResponseContent(content: ResponseMessageContent): string | ResponseInputContent[] {
|
||||
if (!Array.isArray(content)) return String(content || "");
|
||||
return content.map((item) => {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text };
|
||||
if (item.type === "image_url") return { type: "input_image" as const, image_url: item.image_url.url };
|
||||
return item.file_url.url.startsWith("data:") ? { type: "input_file" as const, filename: item.file_url.name, file_data: item.file_url.url } : { type: "input_file" as const, filename: item.file_url.name, file_url: item.file_url.url };
|
||||
});
|
||||
}
|
||||
|
||||
export function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefinition {
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: tool.function.strict,
|
||||
};
|
||||
}
|
||||
|
||||
export function toChatCompletionMessages(messages: ResponseInputMessage[]) {
|
||||
const result: Array<Record<string, unknown>> = [];
|
||||
for (let index = 0; index < messages.length;) {
|
||||
const message = messages[index];
|
||||
if ("type" in message) {
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
while (index < messages.length && "type" in messages[index]) {
|
||||
const call = messages[index] as Extract<ResponseInputMessage, { type: "function_call" }>;
|
||||
toolCalls.push({ id: call.call_id, type: "function", function: { name: call.name, arguments: call.arguments } });
|
||||
index += 1;
|
||||
}
|
||||
result.push({ role: "assistant", content: null, tool_calls: toolCalls });
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool") result.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
|
||||
else result.push({ role: message.role, content: toChatCompletionContent(message.content) });
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toChatCompletionContent(content: ResponseMessageContent) {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content.map((item) => {
|
||||
if (item.type !== "file_url") return item;
|
||||
const file = item.file_url.url.startsWith("data:") ? { filename: item.file_url.name, file_data: item.file_url.url } : { filename: item.file_url.name, file_url: item.file_url.url };
|
||||
return { type: "file", file };
|
||||
});
|
||||
}
|
||||
|
||||
export function toChatCompletionToolChoice(toolChoice: ToolChoice) {
|
||||
return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
|
||||
}
|
||||
|
||||
export function buildBackendToolRequests(messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice): BackendToolRequests {
|
||||
return {
|
||||
responses: {
|
||||
input: toResponseInput(messages),
|
||||
tools: tools.map(toResponseTool),
|
||||
tool_choice: toolChoice,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
chatCompletion: {
|
||||
messages: toChatCompletionMessages(messages),
|
||||
tools,
|
||||
tool_choice: toChatCompletionToolChoice(toolChoice),
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record<string, unknown>) {
|
||||
const systemText = [config.systemPrompt.trim(), ...messages.flatMap((message) => (!("type" in message) && message.role === "system" ? [geminiTextContent(message.content)] : []))].filter(Boolean).join("\n\n");
|
||||
const contents = toGeminiContents(messages.filter((message) => ("type" in message ? true : message.role !== "system")));
|
||||
return {
|
||||
contents,
|
||||
...(systemText ? { systemInstruction: { parts: [{ text: systemText }] } } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function toGeminiContents(messages: ResponseInputMessage[]): GeminiContent[] {
|
||||
const callNameById = new Map<string, string>();
|
||||
return messages.flatMap((message): GeminiContent[] => {
|
||||
if ("type" in message) {
|
||||
callNameById.set(message.call_id, message.name);
|
||||
return [{ role: "model", parts: [{ functionCall: { id: message.call_id, name: message.name, args: jsonObject(message.arguments) }, ...(message.thoughtSignature ? { thoughtSignature: message.thoughtSignature } : {}) }] }];
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
const name = callNameById.get(message.tool_call_id) || "tool_result";
|
||||
return [{ role: "user", parts: [{ functionResponse: { id: message.tool_call_id, name, response: { result: jsonValue(message.content) } } }] }];
|
||||
}
|
||||
return [{ role: message.role === "assistant" ? "model" : "user", parts: toGeminiParts(message.content) }];
|
||||
});
|
||||
}
|
||||
|
||||
function toGeminiParts(content: ResponseMessageContent): GeminiPart[] {
|
||||
if (!Array.isArray(content)) return [{ text: String(content || "") }];
|
||||
return content.map((item) => {
|
||||
if (item.type === "text") return { text: item.text };
|
||||
if (item.type === "image_url") return toGeminiFilePart(item.image_url.url, "image/png");
|
||||
return toGeminiFilePart(item.file_url.url, item.file_url.mimeType);
|
||||
});
|
||||
}
|
||||
|
||||
function toGeminiFilePart(url: string, mimeType: string): GeminiPart {
|
||||
const match = url.match(/^data:([^;,]+);base64,(.+)$/);
|
||||
if (match) return { inlineData: { mimeType: match[1], data: match[2] } };
|
||||
return { fileData: { fileUri: url, mimeType } };
|
||||
}
|
||||
|
||||
function geminiTextContent(content: ResponseMessageContent) {
|
||||
if (!Array.isArray(content)) return String(content || "");
|
||||
return content.map((item) => (item.type === "text" ? item.text : item.type === "image_url" ? item.image_url.url : `${item.file_url.name}: ${item.file_url.url}`)).join("\n");
|
||||
}
|
||||
|
||||
function jsonObject(value: string): Record<string, unknown> {
|
||||
const parsed = jsonValue(value);
|
||||
return isRecord(parsed) ? parsed : {};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function jsonValue(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function toGeminiToolOptions(tools: ResponseFunctionTool[], toolChoice: ToolChoice) {
|
||||
if (!tools.length) return {};
|
||||
const functionDeclarations = tools.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
}));
|
||||
const functionCallingConfig = typeof toolChoice === "object" ? { mode: "ANY", allowedFunctionNames: [toolChoice.name] } : { mode: toolChoice === "required" ? "ANY" : "AUTO" };
|
||||
return {
|
||||
tools: [{ functionDeclarations }],
|
||||
toolConfig: { functionCallingConfig },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user