fix(canvas): 创作工作台 - 恢复连续对话并完善附件、媒体占位与生成分辨率

Fixes #231

Fixes #233
This commit is contained in:
ddCat
2026-08-15 22:01:42 +08:00
parent 11d9d469f5
commit 7804574f9f
15 changed files with 753 additions and 115 deletions

View File

@@ -14,9 +14,14 @@ import { imageSizeRequest, modelCapabilityConfigFor, normalizeImageValue, type I
export type AiTextMessage = {
role: "system" | "user" | "assistant";
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
content: string | AiTextContentPart[];
};
export type AiTextContentPart =
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
| { type: "file_url"; file_url: { url: string; name: string; mimeType: string } };
export type ResponseToolCall = {
id: string;
type: "function";
@@ -43,7 +48,7 @@ export type ToolResponseResult = {
type ToolChoice = "auto" | "required" | { type: "function"; name: string };
type ResponseMessageContent = AiTextMessage["content"] | string;
type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string };
type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string };
type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string };
type ResponseApiToolDefinition = {
type: "function";
@@ -347,7 +352,13 @@ function toResponseInput(messages: ResponseInputMessage[]): ResponseInputItem[]
function toResponseContent(content: ResponseMessageContent): string | ResponseInputContent[] {
if (!Array.isArray(content)) return String(content || "");
return content.map((item) => (item.type === "text" ? { type: "input_text" as const, text: item.text } : { type: "input_image" as const, image_url: item.image_url.url }));
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 };
});
}
function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefinition {
@@ -377,13 +388,24 @@ function toChatCompletionMessages(messages: ResponseInputMessage[]) {
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: message.content });
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 };
});
}
function toChatCompletionToolChoice(toolChoice: ToolChoice) {
return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
}
@@ -653,18 +675,22 @@ function toGeminiContents(messages: ResponseInputMessage[]): GeminiContent[] {
function toGeminiParts(content: ResponseMessageContent): GeminiPart[] {
if (!Array.isArray(content)) return [{ text: String(content || "") }];
return content.map((item) => (item.type === "text" ? { text: item.text } : toGeminiImagePart(item.image_url.url)));
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 toGeminiImagePart(url: string): GeminiPart {
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: "image/png" } };
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.image_url.url)).join("\n");
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> {
@@ -782,7 +808,7 @@ async function requestGeminiImages(config: AiConfig, prompt: string, references:
async function requestGeminiImagesOnce(config: AiConfig, prompt: string, references: ReferenceImage[], options?: RequestOptions) {
const parts: GeminiPart[] = [{ text: prompt }];
for (const image of references) {
parts.push(toGeminiImagePart(await imageToDataUrl(image)));
parts.push(toGeminiFilePart(await imageToDataUrl(image), image.type || "image/png"));
}
const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config));
const response = await axios.post<GeminiPayload>(

View File

@@ -623,6 +623,7 @@ function normalizeVideoSize(value: string) {
function normalizeVideoResolution(value: string) {
if (value === "low") return "480p";
if (value === "auto" || value === "high" || value === "medium") return "720p";
if (value.toLowerCase() === "2k") return "1440p";
if (value.toLowerCase() === "4k") return "2160p";
const resolution = value.replace(/p$/i, "") || "720";
return `${resolution}p`;