fix(server): empty mimetype attachments fallback (#11869)

This commit is contained in:
darkskygit
2025-04-22 15:36:35 +00:00
parent 597b27c22f
commit de8c65f4e6
7 changed files with 150 additions and 35 deletions
@@ -385,6 +385,45 @@ test('should create message correctly', async t => {
t.truthy(messageId, 'should be able to create message with valid session'); t.truthy(messageId, 'should be able to create message with valid session');
} }
{
// with attachment url
{
const { id } = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
id,
randomUUID(),
promptName
);
const messageId = await createCopilotMessage(app, sessionId, undefined, [
'http://example.com/cat.jpg',
]);
t.truthy(messageId, 'should be able to create message with url link');
}
// with attachment
{
const { id } = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
id,
randomUUID(),
promptName
);
const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
const messageId = await createCopilotMessage(
app,
sessionId,
undefined,
undefined,
[new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })]
);
t.truthy(messageId, 'should be able to create message with blobs');
}
}
{ {
await t.throwsAsync( await t.throwsAsync(
createCopilotMessage(app, randomUUID()), createCopilotMessage(app, randomUUID()),
@@ -492,19 +492,53 @@ export async function createCopilotMessage(
sessionId: string, sessionId: string,
content?: string, content?: string,
attachments?: string[], attachments?: string[],
blobs?: ArrayBuffer[], blobs?: File[],
params?: Record<string, string> params?: Record<string, string>
): Promise<string> { ): Promise<string> {
const res = await app.gql( let resp = app
` .POST('/graphql')
mutation createCopilotMessage($options: CreateChatMessageInput!) { .set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
createCopilotMessage(options: $options) .field(
'operations',
JSON.stringify({
query: `
mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options)
}
`,
variables: {
options: { sessionId, content, attachments, blobs: [], params },
},
})
)
.field(
'map',
JSON.stringify(
Array.from<any>({ length: blobs?.length ?? 0 }).reduce(
(acc, _, idx) => {
acc[idx.toString()] = [`variables.options.blobs.${idx}`];
return acc;
},
{}
)
)
);
if (blobs && blobs.length) {
for (const [idx, file] of blobs.entries()) {
resp = resp.attach(
idx.toString(),
Buffer.from(await file.arrayBuffer()),
{
filename: file.name || `file${idx}`,
contentType: file.type || 'application/octet-stream',
}
);
} }
`, }
{ options: { sessionId, content, attachments, blobs, params } }
);
return res.createCopilotMessage; const res = await resp.expect(200);
return res.body.data.createCopilotMessage;
} }
export async function chatWithText( export async function chatWithText(
@@ -138,7 +138,15 @@ export class FalProvider
); );
return { return {
model_name: options.modelName || undefined, model_name: options.modelName || undefined,
image_url: attachments?.[0], image_url: attachments
?.map(v =>
typeof v === 'string'
? v
: v.mimeType.startsWith('image/')
? v.attachment
: undefined
)
.filter(v => !!v)[0],
prompt: content.trim(), prompt: content.trim(),
loras: lora.length ? lora : undefined, loras: lora.length ? lora : undefined,
controlnets: controlnets.length ? controlnets : undefined, controlnets: controlnets.length ? controlnets : undefined,
@@ -52,7 +52,15 @@ export const ChatMessageRole = Object.values(AiPromptRole) as [
export const PureMessageSchema = z.object({ export const PureMessageSchema = z.object({
content: z.string(), content: z.string(),
attachments: z.array(z.string()).optional().nullable(), attachments: z
.array(
z.union([
z.string(),
z.object({ attachment: z.string(), mimeType: z.string() }),
])
)
.optional()
.nullable(),
params: z.record(z.any()).optional().nullable(), params: z.record(z.any()).optional().nullable(),
}); });
@@ -35,15 +35,26 @@ const FORMAT_INFER_MAP: Record<string, string> = {
flv: 'video/flv', flv: 'video/flv',
}; };
function inferMimeType(url: string) { async function inferMimeType(url: string) {
if (url.startsWith('data:')) { if (url.startsWith('data:')) {
return url.split(';')[0].split(':')[1]; return url.split(';')[0].split(':')[1];
} }
const extension = url.split('.').pop(); const pathname = new URL(url).pathname;
const extension = pathname.split('.').pop();
if (extension) { if (extension) {
return FORMAT_INFER_MAP[extension]; const ext = FORMAT_INFER_MAP[extension];
if (ext) {
return ext;
}
const mimeType = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
}).then(res => res.headers.get('Content-Type'));
if (mimeType) {
return mimeType;
}
} }
return undefined; return 'application/octet-stream';
} }
export async function chatToGPTMessage( export async function chatToGPTMessage(
@@ -66,19 +77,24 @@ export async function chatToGPTMessage(
contents.push({ type: 'text', text: content }); contents.push({ type: 'text', text: content });
} }
for (const url of attachments) { for (let attachment of attachments) {
if (SIMPLE_IMAGE_URL_REGEX.test(url)) { let mimeType: string;
const mimeType = if (typeof attachment === 'string') {
typeof mimetype === 'string' ? mimetype : inferMimeType(url); mimeType =
if (mimeType) { typeof mimetype === 'string'
if (mimeType.startsWith('image/')) { ? mimetype
contents.push({ type: 'image', image: url, mimeType }); : await inferMimeType(attachment);
} else { } else {
const data = url.startsWith('data:') ({ attachment, mimeType } = attachment);
? await fetch(url).then(r => r.arrayBuffer()) }
: new URL(url); if (SIMPLE_IMAGE_URL_REGEX.test(attachment)) {
contents.push({ type: 'file' as const, data, mimeType }); if (mimeType.startsWith('image/')) {
} contents.push({ type: 'image', image: attachment, mimeType });
} else {
const data = attachment.startsWith('data:')
? await fetch(attachment).then(r => r.arrayBuffer())
: new URL(attachment);
contents.push({ type: 'file' as const, data, mimeType });
} }
} }
} }
@@ -34,6 +34,7 @@ import { Admin } from '../../core/common';
import { AccessController } from '../../core/permission'; import { AccessController } from '../../core/permission';
import { UserType } from '../../core/user'; import { UserType } from '../../core/user';
import { PromptService } from './prompt'; import { PromptService } from './prompt';
import { PromptMessage } from './providers';
import { ChatSessionService } from './session'; import { ChatSessionService } from './session';
import { CopilotStorage } from './storage'; import { CopilotStorage } from './storage';
import { import {
@@ -113,7 +114,7 @@ class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
content!: string | undefined; content!: string | undefined;
@Field(() => [String], { nullable: true }) @Field(() => [String], { nullable: true, deprecationReason: 'use blobs' })
attachments!: string[] | undefined; attachments!: string[] | undefined;
@Field(() => [GraphQLUpload], { nullable: true }) @Field(() => [GraphQLUpload], { nullable: true })
@@ -527,8 +528,8 @@ export class CopilotResolver {
throw new BadRequestException('Session not found'); throw new BadRequestException('Session not found');
} }
const attachments: PromptMessage['attachments'] = options.attachments || [];
if (options.blobs) { if (options.blobs) {
options.attachments = options.attachments || [];
const { workspaceId } = session.config; const { workspaceId } = session.config;
const blobs = await Promise.all(options.blobs); const blobs = await Promise.all(options.blobs);
@@ -539,18 +540,18 @@ export class CopilotResolver {
const filename = createHash('sha256') const filename = createHash('sha256')
.update(uploaded.buffer) .update(uploaded.buffer)
.digest('base64url'); .digest('base64url');
const link = await this.storage.put( const attachment = await this.storage.put(
user.id, user.id,
workspaceId, workspaceId,
filename, filename,
uploaded.buffer uploaded.buffer
); );
options.attachments.push(link); attachments.push({ attachment, mimeType: blob.mimetype });
} }
} }
try { try {
return await this.chatSession.createMessage(options); return await this.chatSession.createMessage({ ...options, attachments });
} catch (e: any) { } catch (e: any) {
throw new CopilotFailedToCreateMessage(e.message); throw new CopilotFailedToCreateMessage(e.message);
} }
@@ -166,7 +166,11 @@ export class ChatSession implements AsyncDisposable {
firstMessage.attachments || [], firstMessage.attachments || [],
] ]
.flat() .flat()
.filter(v => !!v?.trim()); .filter(v =>
typeof v === 'string'
? !!v.trim()
: v && v.attachment.trim() && v.mimeType
);
return finished; return finished;
} }
@@ -553,7 +557,12 @@ export class ChatSessionService {
action: prompt.action || null, action: prompt.action || null,
tokens: tokenCost, tokens: tokenCost,
createdAt, createdAt,
messages: preload.concat(ret.data), messages: preload.concat(ret.data).map(m => ({
...m,
attachments: m.attachments
?.map(a => (typeof a === 'string' ? a : a.attachment))
.filter(a => !!a),
})),
}; };
} else { } else {
this.logger.error( this.logger.error(