feat(server): add compatibility for ios client (#13263)

fix AI-355

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for uploading a single file as an attachment when
creating chat messages, in addition to existing multiple file uploads.

* **Tests**
* Expanded test coverage to verify message creation with both single and
multiple file attachments.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-07-18 16:31:26 +08:00
committed by GitHub
parent fa42e3619f
commit 013a6ceb7e
5 changed files with 88 additions and 36 deletions
@@ -461,6 +461,29 @@ test('should create message correctly', async t => {
sessionId, sessionId,
undefined, undefined,
undefined, undefined,
new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })
);
t.truthy(messageId, 'should be able to create message with blob');
}
// with attachments
{
const { id } = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
id,
randomUUID(),
textPromptName
);
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,
undefined,
[new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })] [new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })]
); );
t.truthy(messageId, 'should be able to create message with blobs'); t.truthy(messageId, 'should be able to create message with blobs');
@@ -554,52 +554,73 @@ export async function createCopilotMessage(
sessionId: string, sessionId: string,
content?: string, content?: string,
attachments?: string[], attachments?: string[],
blob?: File,
blobs?: File[], blobs?: File[],
params?: Record<string, string> params?: Record<string, string>
): Promise<string> { ): Promise<string> {
let resp = app const gql = {
.POST('/graphql') query: `
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
query: `
mutation createCopilotMessage($options: CreateChatMessageInput!) { mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options) createCopilotMessage(options: $options)
} }
`, `,
variables: { variables: {
options: { sessionId, content, attachments, blobs: [], params }, options: {
}, sessionId,
}) content,
) attachments,
.field( blob: null,
'map', blobs: [],
JSON.stringify( params,
Array.from<any>({ length: blobs?.length ?? 0 }).reduce( },
(acc, _, idx) => { },
acc[idx.toString()] = [`variables.options.blobs.${idx}`]; };
return acc;
}, let resp = app
{} .POST('/graphql')
) .set({ 'x-request-id': 'test', 'x-operation-name': 'test' });
) if (blob || blobs) {
); resp = resp.field('operations', JSON.stringify(gql));
if (blobs && blobs.length) {
for (const [idx, file] of blobs.entries()) { if (blob) {
resp = resp.attach( resp = resp.field(
idx.toString(), 'map',
Buffer.from(await file.arrayBuffer()), JSON.stringify({ '0': ['variables.options.blob'] })
{
filename: file.name || `file${idx}`,
contentType: file.type || 'application/octet-stream',
}
); );
resp = resp.attach('0', Buffer.from(await blob.arrayBuffer()), {
filename: blob.name || 'file',
contentType: blob.type || 'application/octet-stream',
});
} else if (blobs && blobs.length) {
resp = resp.field(
'map',
JSON.stringify(
Array.from<any>({ length: blobs?.length ?? 0 }).reduce(
(acc, _, idx) => {
acc[idx.toString()] = [`variables.options.blobs.${idx}`];
return acc;
},
{}
)
)
);
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',
}
);
}
} }
} else {
resp = resp.send(gql);
} }
const res = await resp.expect(200); const res = await resp.expect(200);
console.log('createCopilotMessage', res.body);
return res.body.data.createCopilotMessage; return res.body.data.createCopilotMessage;
} }
@@ -143,6 +143,9 @@ class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> {
@Field(() => [String], { nullable: true, deprecationReason: 'use blobs' }) @Field(() => [String], { nullable: true, deprecationReason: 'use blobs' })
attachments!: string[] | undefined; attachments!: string[] | undefined;
@Field(() => GraphQLUpload, { nullable: true })
blob!: Promise<FileUpload> | undefined;
@Field(() => [GraphQLUpload], { nullable: true }) @Field(() => [GraphQLUpload], { nullable: true })
blobs!: Promise<FileUpload>[] | undefined; blobs!: Promise<FileUpload>[] | undefined;
@@ -703,10 +706,13 @@ export class CopilotResolver {
} }
const attachments: PromptMessage['attachments'] = options.attachments || []; const attachments: PromptMessage['attachments'] = options.attachments || [];
if (options.blobs) { if (options.blob || options.blobs) {
const { workspaceId } = session.config; const { workspaceId } = session.config;
const blobs = await Promise.all(options.blobs); const blobs = await Promise.all(
options.blob ? [options.blob] : options.blobs || []
);
delete options.blob;
delete options.blobs; delete options.blobs;
for (const blob of blobs) { for (const blob of blobs) {
+1
View File
@@ -457,6 +457,7 @@ type CopilotWorkspaceIgnoredDocTypeEdge {
input CreateChatMessageInput { input CreateChatMessageInput {
attachments: [String!] attachments: [String!]
blob: Upload
blobs: [Upload!] blobs: [Upload!]
content: String content: String
params: JSON params: JSON
+1
View File
@@ -569,6 +569,7 @@ export interface CopilotWorkspaceIgnoredDocTypeEdge {
export interface CreateChatMessageInput { export interface CreateChatMessageInput {
attachments?: InputMaybe<Array<Scalars['String']['input']>>; attachments?: InputMaybe<Array<Scalars['String']['input']>>;
blob?: InputMaybe<Scalars['Upload']['input']>;
blobs?: InputMaybe<Array<Scalars['Upload']['input']>>; blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
content?: InputMaybe<Scalars['String']['input']>; content?: InputMaybe<Scalars['String']['input']>;
params?: InputMaybe<Scalars['JSON']['input']>; params?: InputMaybe<Scalars['JSON']['input']>;