feat(server): attachment embedding (#13348)

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

* **New Features**
* Added support for managing "blobs" in Copilot context, including
adding and removing blobs via new GraphQL mutations and UI fields.
* Introduced tracking and querying of blob embeddings within workspaces,
enabling search and similarity matching for blob content.
* Extended Copilot context and workspace APIs, schema, and UI to display
and manage blobs alongside existing documents and files.

* **Bug Fixes**
* Updated context and embedding status logic to handle blobs, ensuring
accurate status reporting and embedding management.

* **Tests**
* Added and updated test cases and snapshots to cover blob embedding
insertion, matching, and removal scenarios.

* **Documentation**
* Updated GraphQL schema and TypeScript types to reflect new
blob-related fields and mutations.

* **Chores**
* Refactored and cleaned up code to support new blob entity and
embedding logic, including renaming and updating internal methods and
types.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-07-31 06:07:28 +08:00
committed by GitHub
parent b6a5bc052e
commit feb42e34be
24 changed files with 689 additions and 84 deletions

View File

@@ -20,6 +20,7 @@ import { SafeIntResolver } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import {
BlobNotFound,
BlobQuotaExceeded,
CallMetric,
CopilotEmbeddingUnavailable,
@@ -37,6 +38,7 @@ import {
import { CurrentUser } from '../../../core/auth';
import { AccessController } from '../../../core/permission';
import {
ContextBlob,
ContextCategories,
ContextCategory,
ContextDoc,
@@ -118,6 +120,24 @@ class RemoveContextFileInput {
fileId!: string;
}
@InputType()
class AddContextBlobInput {
@Field(() => String)
contextId!: string;
@Field(() => String)
blobId!: string;
}
@InputType()
class RemoveContextBlobInput {
@Field(() => String)
contextId!: string;
@Field(() => String)
blobId!: string;
}
@ObjectType('CopilotContext')
export class CopilotContextType {
@Field(() => ID, { nullable: true })
@@ -130,7 +150,24 @@ export class CopilotContextType {
registerEnumType(ContextCategories, { name: 'ContextCategories' });
@ObjectType()
class CopilotDocType implements Omit<ContextDoc, 'status'> {
class CopilotContextCategory implements Omit<ContextCategory, 'docs'> {
@Field(() => ID)
id!: string;
@Field(() => ContextCategories)
type!: ContextCategories;
@Field(() => [CopilotContextDoc])
docs!: CopilotContextDoc[];
@Field(() => SafeIntResolver)
createdAt!: number;
}
registerEnumType(ContextEmbedStatus, { name: 'ContextEmbedStatus' });
@ObjectType()
class CopilotContextBlob implements Omit<ContextBlob, 'status'> {
@Field(() => ID)
id!: string;
@@ -142,28 +179,17 @@ class CopilotDocType implements Omit<ContextDoc, 'status'> {
}
@ObjectType()
class CopilotContextCategory implements Omit<ContextCategory, 'docs'> {
class CopilotContextDoc implements Omit<ContextDoc, 'status'> {
@Field(() => ID)
id!: string;
@Field(() => ContextCategories)
type!: ContextCategories;
@Field(() => [CopilotDocType])
docs!: CopilotDocType[];
@Field(() => ContextEmbedStatus, { nullable: true })
status!: ContextEmbedStatus | null;
@Field(() => SafeIntResolver)
createdAt!: number;
}
registerEnumType(ContextEmbedStatus, { name: 'ContextEmbedStatus' });
@ObjectType()
class CopilotContextDoc extends CopilotDocType {
@Field(() => String, { nullable: true })
error!: string | null;
}
@ObjectType()
class CopilotContextFile implements ContextFile {
@Field(() => ID)
@@ -433,11 +459,33 @@ export class CopilotContextResolver {
return tags;
}
@ResolveField(() => [CopilotContextBlob], {
description: 'list blobs in context',
})
@CallMetric('ai', 'context_blob_list')
async blobs(
@Parent() context: CopilotContextType
): Promise<CopilotContextBlob[]> {
if (!context.id) {
return [];
}
const session = await this.context.get(context.id);
const blobs = session.blobs;
await this.models.copilotContext.mergeBlobStatus(
session.workspaceId,
blobs
);
return blobs.map(blob => ({ ...blob, status: blob.status || null }));
}
@ResolveField(() => [CopilotContextDoc], {
description: 'list files in context',
})
@CallMetric('ai', 'context_file_list')
async docs(@Parent() context: CopilotContextType): Promise<CopilotDocType[]> {
async docs(
@Parent() context: CopilotContextType
): Promise<CopilotContextDoc[]> {
if (!context.id) {
return [];
}
@@ -538,7 +586,7 @@ export class CopilotContextResolver {
async addContextDoc(
@Args({ name: 'options', type: () => AddContextDocInput })
options: AddContextDocInput
): Promise<CopilotDocType> {
): Promise<CopilotContextDoc> {
const lockFlag = `${COPILOT_LOCKER}:context:${options.contextId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
@@ -674,6 +722,85 @@ export class CopilotContextResolver {
}
}
@Mutation(() => CopilotContextBlob, {
description: 'add a blob to context',
})
@CallMetric('ai', 'context_blob_add')
async addContextBlob(
@CurrentUser() user: CurrentUser,
@Args({ name: 'options', type: () => AddContextBlobInput })
options: AddContextBlobInput
): Promise<CopilotContextBlob> {
if (!this.context.canEmbedding) {
throw new CopilotEmbeddingUnavailable();
}
const lockFlag = `${COPILOT_LOCKER}:context:${options.contextId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest('Server is busy');
}
const contextSession = await this.context.get(options.contextId);
try {
const blob = await contextSession.addBlobRecord(options.blobId);
if (!blob) {
throw new BlobNotFound({
spaceId: contextSession.workspaceId,
blobId: options.blobId,
});
}
await this.jobs.addBlobEmbeddingQueue({
userId: user.id,
workspaceId: contextSession.workspaceId,
contextId: contextSession.id,
blobId: options.blobId,
});
return { ...blob, status: blob.status || null };
} catch (e: any) {
if (e instanceof UserFriendlyError) {
throw e;
}
throw new CopilotFailedToModifyContext({
contextId: options.contextId,
message: e.message,
});
}
}
@Mutation(() => Boolean, {
description: 'remove a blob from context',
})
@CallMetric('ai', 'context_blob_remove')
async removeContextBlob(
@Args({ name: 'options', type: () => RemoveContextBlobInput })
options: RemoveContextBlobInput
): Promise<boolean> {
if (!this.context.canEmbedding) {
throw new CopilotEmbeddingUnavailable();
}
const lockFlag = `${COPILOT_LOCKER}:context:${options.contextId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest('Server is busy');
}
const contextSession = await this.context.get(options.contextId);
try {
return await contextSession.removeBlobRecord(options.blobId);
} catch (e: any) {
throw new CopilotFailedToModifyContext({
contextId: options.contextId,
message: e.message,
});
}
}
@ResolveField(() => [ContextMatchedFileChunk], {
description: 'match file in context',
})