feat(server): improve context metadata & matching (#12064)

fix AI-20

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

- **New Features**
  - Enhanced file metadata with MIME type, blob ID, and file name across context and workspace, now visible in UI and API.
  - Added workspace-level matching for files and documents with configurable thresholds and workspace scoping in search queries.
  - Introduced a new error type and user-friendly messaging for global workspace context matching failures.

- **Bug Fixes**
  - Improved consistent handling of file MIME types and nullable context IDs for accurate metadata.

- **Documentation**
  - Updated GraphQL schema, queries, and mutations to include new metadata fields, optional parameters, and error types.

- **Style**
  - Added new localization strings for global context matching error messages.

- **Tests**
  - Extended test coverage with new and updated snapshot tests for metadata and matching logic.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
darkskygit
2025-05-14 06:32:29 +00:00
parent 04c5fd6dfc
commit cecf545590
36 changed files with 465 additions and 108 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ai_workspace_files" ADD COLUMN "blob_id" VARCHAR NOT NULL DEFAULT '';

View File

@@ -518,6 +518,7 @@ model AiWorkspaceIgnoredDocs {
model AiWorkspaceFiles { model AiWorkspaceFiles {
workspaceId String @map("workspace_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar
fileId String @map("file_id") @db.VarChar fileId String @map("file_id") @db.VarChar
blobId String @map("blob_id") @db.VarChar @default("")
fileName String @map("file_name") @db.VarChar fileName String @map("file_name") @db.VarChar
mimeType String @map("mime_type") @db.VarChar mimeType String @map("mime_type") @db.VarChar
size Int @db.Integer size Int @db.Integer

View File

@@ -1316,7 +1316,11 @@ test('should be able to manage context', async t => {
// file record // file record
{ {
await storage.put(userId, session.workspaceId, 'blob', buffer); await storage.put(userId, session.workspaceId, 'blob', buffer);
const file = await session.addFile('blob', 'sample.pdf'); const file = await session.addFile(
'blob',
'sample.pdf',
'application/pdf'
);
const handler = Sinon.spy(event, 'emit'); const handler = Sinon.spy(event, 'emit');
@@ -1345,7 +1349,7 @@ test('should be able to manage context', async t => {
'should list file id' 'should list file id'
); );
const result = await session.matchFileChunks('test', 1, undefined, 1); const result = await session.matchFiles('test', 1, undefined, 1);
t.is(result.length, 1, 'should match context'); t.is(result.length, 1, 'should match context');
t.is(result[0].fileId, file.id, 'should match file id'); t.is(result[0].fileId, file.id, 'should match file id');
} }
@@ -1499,13 +1503,13 @@ test('should be able to manage workspace embedding', async t => {
}); });
const contextSession = await context.create(sessionId); const contextSession = await context.create(sessionId);
const ret = await contextSession.matchFileChunks('test', 1, undefined, 1); const ret = await contextSession.matchFiles('test', 1, undefined, 1);
t.is(ret.length, 1, 'should match workspace context'); t.is(ret.length, 1, 'should match workspace context');
t.is(ret[0].content, 'content', 'should match content'); t.is(ret[0].content, 'content', 'should match content');
await workspace.update(ws.id, { enableDocEmbedding: false }); await workspace.update(ws.id, { enableDocEmbedding: false });
const ret2 = await contextSession.matchFileChunks('test', 1, undefined, 1); const ret2 = await contextSession.matchFiles('test', 1, undefined, 1);
t.is(ret2.length, 0, 'should not match workspace context'); t.is(ret2.length, 0, 'should not match workspace context');
} }
}); });

View File

@@ -6,6 +6,17 @@ Generated by [AVA](https://avajs.dev).
## should insert embedding by doc id ## should insert embedding by doc id
> should match file embedding
[
{
chunk: 0,
content: 'content',
distance: 0,
fileId: 'file-id',
},
]
> should return empty array when embedding deleted > should return empty array when embedding deleted
[] []

View File

@@ -54,3 +54,18 @@ Generated by [AVA](https://avajs.dev).
docId: 'new_doc', docId: 'new_doc',
}, },
] ]
## should insert and search embedding
> should match workspace file embedding
[
{
blobId: 'blob1',
chunk: 0,
content: 'content',
distance: 0,
mimeType: 'text/plain',
name: 'file1',
},
]

View File

@@ -111,8 +111,7 @@ test('should insert embedding by doc id', async t => {
1, 1,
1 1
); );
t.is(ret.length, 1); t.snapshot(ret, 'should match file embedding');
t.is(ret[0].content, 'content');
} }
{ {

View File

@@ -107,6 +107,7 @@ test('should insert and search embedding', async t => {
{ {
const { fileId } = await t.context.copilotWorkspace.addFile(workspace.id, { const { fileId } = await t.context.copilotWorkspace.addFile(workspace.id, {
fileName: 'file1', fileName: 'file1',
blobId: 'blob1',
mimeType: 'text/plain', mimeType: 'text/plain',
size: 1, size: 1,
}); });
@@ -129,8 +130,10 @@ test('should insert and search embedding', async t => {
1, 1,
1 1
); );
t.is(ret.length, 1); t.snapshot(
t.is(ret[0].content, 'content'); cleanObject(ret, ['fileId']),
'should match workspace file embedding'
);
} }
} }
}); });

View File

@@ -714,6 +714,12 @@ export const USER_FRIENDLY_ERRORS = {
message: ({ contextId, content, message }) => message: ({ contextId, content, message }) =>
`Failed to match context ${contextId} with "${escape(content)}": ${message}`, `Failed to match context ${contextId} with "${escape(content)}": ${message}`,
}, },
copilot_failed_to_match_global_context: {
type: 'internal_server_error',
args: { workspaceId: 'string', content: 'string', message: 'string' },
message: ({ workspaceId, content, message }) =>
`Failed to match context in workspace ${workspaceId} with "${escape(content)}": ${message}`,
},
copilot_embedding_disabled: { copilot_embedding_disabled: {
type: 'action_forbidden', type: 'action_forbidden',
message: `Embedding feature is disabled, please contact the administrator to enable it in the workspace settings.`, message: `Embedding feature is disabled, please contact the administrator to enable it in the workspace settings.`,

View File

@@ -780,6 +780,18 @@ export class CopilotFailedToMatchContext extends UserFriendlyError {
super('internal_server_error', 'copilot_failed_to_match_context', message, args); super('internal_server_error', 'copilot_failed_to_match_context', message, args);
} }
} }
@ObjectType()
class CopilotFailedToMatchGlobalContextDataType {
@Field() workspaceId!: string
@Field() content!: string
@Field() message!: string
}
export class CopilotFailedToMatchGlobalContext extends UserFriendlyError {
constructor(args: CopilotFailedToMatchGlobalContextDataType, message?: string | ((args: CopilotFailedToMatchGlobalContextDataType) => string)) {
super('internal_server_error', 'copilot_failed_to_match_global_context', message, args);
}
}
export class CopilotEmbeddingDisabled extends UserFriendlyError { export class CopilotEmbeddingDisabled extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
@@ -1078,6 +1090,7 @@ export enum ErrorNames {
COPILOT_CONTEXT_FILE_NOT_SUPPORTED, COPILOT_CONTEXT_FILE_NOT_SUPPORTED,
COPILOT_FAILED_TO_MODIFY_CONTEXT, COPILOT_FAILED_TO_MODIFY_CONTEXT,
COPILOT_FAILED_TO_MATCH_CONTEXT, COPILOT_FAILED_TO_MATCH_CONTEXT,
COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT,
COPILOT_EMBEDDING_DISABLED, COPILOT_EMBEDDING_DISABLED,
COPILOT_EMBEDDING_UNAVAILABLE, COPILOT_EMBEDDING_UNAVAILABLE,
COPILOT_TRANSCRIPTION_JOB_EXISTS, COPILOT_TRANSCRIPTION_JOB_EXISTS,
@@ -1114,5 +1127,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({ export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion', name: 'ErrorDataUnion',
types: () => types: () =>
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType] as const, [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType] as const,
}); });

View File

@@ -46,6 +46,7 @@ export const ContextFileSchema = z.object({
id: z.string(), id: z.string(),
chunkSize: z.number(), chunkSize: z.number(),
name: z.string(), name: z.string(),
mimeType: z.string().optional(),
status: ContextEmbedStatusSchema, status: ContextEmbedStatusSchema,
error: z.string().nullable(), error: z.string().nullable(),
blobId: z.string(), blobId: z.string(),
@@ -100,6 +101,9 @@ export type ChunkSimilarity = {
export type FileChunkSimilarity = ChunkSimilarity & { export type FileChunkSimilarity = ChunkSimilarity & {
fileId: string; fileId: string;
blobId: string;
name: string;
mimeType: string;
}; };
export type DocChunkSimilarity = ChunkSimilarity & { export type DocChunkSimilarity = ChunkSimilarity & {
@@ -108,6 +112,7 @@ export type DocChunkSimilarity = ChunkSimilarity & {
export const CopilotWorkspaceFileSchema = z.object({ export const CopilotWorkspaceFileSchema = z.object({
fileName: z.string(), fileName: z.string(),
blobId: z.string(),
mimeType: z.string(), mimeType: z.string(),
size: z.number(), size: z.number(),
}); });

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { CopilotSessionNotFound } from '../base'; import { CopilotSessionNotFound } from '../base';
@@ -179,9 +180,9 @@ export class CopilotContextModel extends BaseModel {
contextId: string, contextId: string,
topK: number, topK: number,
threshold: number threshold: number
): Promise<FileChunkSimilarity[]> { ): Promise<Omit<FileChunkSimilarity, 'blobId' | 'name' | 'mimeType'>[]> {
const similarityChunks = await this.db.$queryRaw< const similarityChunks = await this.db.$queryRaw<
Array<FileChunkSimilarity> Array<Omit<FileChunkSimilarity, 'blobId' | 'name' | 'mimeType'>>
>` >`
SELECT "file_id" as "fileId", "chunk", "content", "embedding" <=> ${embedding}::vector as "distance" SELECT "file_id" as "fileId", "chunk", "content", "embedding" <=> ${embedding}::vector as "distance"
FROM "ai_context_embeddings" FROM "ai_context_embeddings"
@@ -217,6 +218,7 @@ export class CopilotContextModel extends BaseModel {
}); });
} }
@Transactional()
async matchWorkspaceEmbedding( async matchWorkspaceEmbedding(
embedding: number[], embedding: number[],
workspaceId: string, workspaceId: string,
@@ -232,6 +234,18 @@ export class CopilotContextModel extends BaseModel {
ORDER BY "distance" ASC ORDER BY "distance" ASC
LIMIT ${topK}; LIMIT ${topK};
`; `;
return similarityChunks.filter(c => Number(c.distance) <= threshold);
const matchedChunks = similarityChunks.filter(
c => Number(c.distance) <= threshold
);
const matchedDocIds = Array.from(new Set(matchedChunks.map(c => c.docId)));
if (!matchDocIds?.length && matchedDocIds.length) {
const ignoredDocs = await this.models.copilotWorkspace.checkIgnoredDocs(
workspaceId,
matchedDocIds
);
return matchedChunks.filter(c => !ignoredDocs.includes(c.docId));
}
return matchedChunks;
} }
} }

View File

@@ -16,6 +16,25 @@ import type {
@Injectable() @Injectable()
export class CopilotWorkspaceConfigModel extends BaseModel { export class CopilotWorkspaceConfigModel extends BaseModel {
@Transactional()
private async listIgnoredDocIds(
workspaceId: string,
options?: PaginationInput
) {
return await this.db.aiWorkspaceIgnoredDocs.findMany({
where: {
workspaceId,
},
select: {
docId: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
skip: options?.offset,
take: options?.first,
});
}
@Transactional() @Transactional()
async updateIgnoredDocs( async updateIgnoredDocs(
workspaceId: string, workspaceId: string,
@@ -23,7 +42,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
remove: string[] = [] remove: string[] = []
) { ) {
const removed = new Set(remove); const removed = new Set(remove);
const ignored = await this.listIgnoredDocs(workspaceId).then( const ignored = await this.listIgnoredDocIds(workspaceId).then(
r => new Set(r.map(r => r.docId).filter(id => !removed.has(id))) r => new Set(r.map(r => r.docId).filter(id => !removed.has(id)))
); );
const added = add.filter(id => !ignored.has(id)); const added = add.filter(id => !ignored.has(id));
@@ -51,25 +70,11 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
return added.length + ignored.size; return added.length + ignored.size;
} }
@Transactional()
async listIgnoredDocs( async listIgnoredDocs(
workspaceId: string, workspaceId: string,
options?: { options?: PaginationInput
includeRead?: boolean;
} & PaginationInput
): Promise<IgnoredDoc[]> { ): Promise<IgnoredDoc[]> {
const row = await this.db.aiWorkspaceIgnoredDocs.findMany({ const row = await this.listIgnoredDocIds(workspaceId, options);
where: {
workspaceId,
},
select: {
docId: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
skip: options?.offset,
take: options?.first,
});
const ids = row.map(r => ({ workspaceId, docId: r.docId })); const ids = row.map(r => ({ workspaceId, docId: r.docId }));
const docs = await this.models.doc.findMetas(ids); const docs = await this.models.doc.findMetas(ids);
const docsMap = new Map( const docsMap = new Map(
@@ -106,7 +111,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
@Transactional() @Transactional()
async checkIgnoredDocs(workspaceId: string, docIds: string[]) { async checkIgnoredDocs(workspaceId: string, docIds: string[]) {
const ignored = await this.listIgnoredDocs(workspaceId).then( const ignored = await this.listIgnoredDocIds(workspaceId).then(
r => new Set(r.map(r => r.docId)) r => new Set(r.map(r => r.docId))
); );
@@ -214,9 +219,19 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
const similarityChunks = await this.db.$queryRaw< const similarityChunks = await this.db.$queryRaw<
Array<FileChunkSimilarity> Array<FileChunkSimilarity>
>` >`
SELECT "file_id" as "fileId", "chunk", "content", "embedding" <=> ${embedding}::vector as "distance" SELECT
FROM "ai_workspace_file_embeddings" e."file_id" as "fileId",
WHERE workspace_id = ${workspaceId} f."file_name" as "name",
f."blob_id" as "blobId",
f."mime_type" as "mimeType",
e."chunk",
e."content",
e."embedding" <=> ${embedding}::vector as "distance"
FROM "ai_workspace_file_embeddings" e
JOIN "ai_workspace_files" f
ON e."workspace_id" = f."workspace_id"
AND e."file_id" = f."file_id"
WHERE e.workspace_id = ${workspaceId}
ORDER BY "distance" ASC ORDER BY "distance" ASC
LIMIT ${topK}; LIMIT ${topK};
`; `;

View File

@@ -23,6 +23,7 @@ import {
CallMetric, CallMetric,
CopilotEmbeddingUnavailable, CopilotEmbeddingUnavailable,
CopilotFailedToMatchContext, CopilotFailedToMatchContext,
CopilotFailedToMatchGlobalContext,
CopilotFailedToModifyContext, CopilotFailedToModifyContext,
CopilotSessionNotFound, CopilotSessionNotFound,
EventBus, EventBus,
@@ -117,8 +118,8 @@ class RemoveContextFileInput {
@ObjectType('CopilotContext') @ObjectType('CopilotContext')
export class CopilotContextType { export class CopilotContextType {
@Field(() => ID) @Field(() => ID, { nullable: true })
id!: string; id!: string | undefined;
@Field(() => String) @Field(() => String)
workspaceId!: string; workspaceId!: string;
@@ -169,6 +170,9 @@ class CopilotContextFile implements ContextFile {
@Field(() => String) @Field(() => String)
name!: string; name!: string;
@Field(() => String)
mimeType!: string;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
chunkSize!: number; chunkSize!: number;
@@ -190,6 +194,15 @@ class ContextMatchedFileChunk implements FileChunkSimilarity {
@Field(() => String) @Field(() => String)
fileId!: string; fileId!: string;
@Field(() => String)
blobId!: string;
@Field(() => String)
name!: string;
@Field(() => String)
mimeType!: string;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
chunk!: number; chunk!: number;
@@ -283,6 +296,15 @@ export class CopilotContextRootResolver {
} }
} }
if (copilot.workspaceId) {
return [
{
id: undefined,
workspaceId: copilot.workspaceId,
},
];
}
return []; return [];
} }
@@ -387,6 +409,9 @@ export class CopilotContextResolver {
async collections( async collections(
@Parent() context: CopilotContextType @Parent() context: CopilotContextType
): Promise<CopilotContextCategory[]> { ): Promise<CopilotContextCategory[]> {
if (!context.id) {
return [];
}
const session = await this.context.get(context.id); const session = await this.context.get(context.id);
const collections = session.collections; const collections = session.collections;
await this.models.copilotContext.mergeDocStatus( await this.models.copilotContext.mergeDocStatus(
@@ -404,6 +429,9 @@ export class CopilotContextResolver {
async tags( async tags(
@Parent() context: CopilotContextType @Parent() context: CopilotContextType
): Promise<CopilotContextCategory[]> { ): Promise<CopilotContextCategory[]> {
if (!context.id) {
return [];
}
const session = await this.context.get(context.id); const session = await this.context.get(context.id);
const tags = session.tags; const tags = session.tags;
await this.models.copilotContext.mergeDocStatus( await this.models.copilotContext.mergeDocStatus(
@@ -419,6 +447,9 @@ export class CopilotContextResolver {
}) })
@CallMetric('ai', 'context_file_list') @CallMetric('ai', 'context_file_list')
async docs(@Parent() context: CopilotContextType): Promise<CopilotDocType[]> { async docs(@Parent() context: CopilotContextType): Promise<CopilotDocType[]> {
if (!context.id) {
return [];
}
const session = await this.context.get(context.id); const session = await this.context.get(context.id);
const docs = session.docs; const docs = session.docs;
await this.models.copilotContext.mergeDocStatus(session.workspaceId, docs); await this.models.copilotContext.mergeDocStatus(session.workspaceId, docs);
@@ -433,6 +464,9 @@ export class CopilotContextResolver {
async files( async files(
@Parent() context: CopilotContextType @Parent() context: CopilotContextType
): Promise<CopilotContextFile[]> { ): Promise<CopilotContextFile[]> {
if (!context.id) {
return [];
}
const session = await this.context.get(context.id); const session = await this.context.get(context.id);
return session.files; return session.files;
} }
@@ -593,7 +627,11 @@ export class CopilotContextResolver {
const session = await this.context.get(options.contextId); const session = await this.context.get(options.contextId);
try { try {
const file = await session.addFile(options.blobId, content.filename); const file = await session.addFile(
options.blobId,
content.filename,
content.mimetype
);
const buffer = await readStream(content.createReadStream()); const buffer = await readStream(content.createReadStream());
await this.storage.put( await this.storage.put(
@@ -664,6 +702,8 @@ export class CopilotContextResolver {
@Args('content') content: string, @Args('content') content: string,
@Args('limit', { type: () => SafeIntResolver, nullable: true }) @Args('limit', { type: () => SafeIntResolver, nullable: true })
limit?: number, limit?: number,
@Args('scopedThreshold', { type: () => Float, nullable: true })
scopedThreshold?: number,
@Args('threshold', { type: () => Float, nullable: true }) @Args('threshold', { type: () => Float, nullable: true })
threshold?: number threshold?: number
): Promise<ContextMatchedFileChunk[]> { ): Promise<ContextMatchedFileChunk[]> {
@@ -671,22 +711,46 @@ export class CopilotContextResolver {
return []; return [];
} }
const session = await this.context.get(context.id);
try { try {
return await session.matchFileChunks( if (!context.id) {
return await this.context.matchWorkspaceFiles(
context.workspaceId,
content, content,
limit, limit,
this.getSignal(ctx.req), this.getSignal(ctx.req),
threshold threshold
); );
}
const session = await this.context.get(context.id);
return await session.matchFiles(
content,
limit,
this.getSignal(ctx.req),
scopedThreshold,
threshold
);
} catch (e: any) { } catch (e: any) {
// passthrough user friendly error
if (e instanceof UserFriendlyError) {
throw e;
}
if (context.id) {
throw new CopilotFailedToMatchContext({ throw new CopilotFailedToMatchContext({
contextId: context.id, contextId: context.id,
// don't record the large content // don't record the large content
content: content.slice(0, 512), content: content.slice(0, 512),
message: e.message, message: e.message,
}); });
} else {
throw new CopilotFailedToMatchGlobalContext({
workspaceId: context.workspaceId,
// don't record the large content
content: content.slice(0, 512),
message: e.message,
});
}
} }
} }
@@ -711,20 +775,38 @@ export class CopilotContextResolver {
} }
try { try {
const session = await this.context.get(context.id);
await this.ac await this.ac
.user(user.id) .user(user.id)
.workspace(session.workspaceId) .workspace(context.workspaceId)
.allowLocal() .allowLocal()
.assert('Workspace.Copilot'); .assert('Workspace.Copilot');
const allowEmbedding = await this.models.workspace.allowEmbedding( const allowEmbedding = await this.models.workspace.allowEmbedding(
session.workspaceId context.workspaceId
); );
if (!allowEmbedding) { if (!allowEmbedding) {
return []; return [];
} }
const chunks = await session.matchWorkspaceChunks( if (!context.id) {
return await this.context.matchWorkspaceDocs(
context.workspaceId,
content,
limit,
this.getSignal(ctx.req),
threshold
);
}
const session = await this.context.get(context.id);
if (session.workspaceId !== context.workspaceId) {
throw new CopilotFailedToMatchContext({
contextId: context.id,
// don't record the large content
content: content.slice(0, 512),
message: 'context not in the same workspace',
});
}
const chunks = await session.matchWorkspaceDocs(
content, content,
limit, limit,
this.getSignal(ctx.req), this.getSignal(ctx.req),
@@ -748,12 +830,22 @@ export class CopilotContextResolver {
if (e instanceof UserFriendlyError) { if (e instanceof UserFriendlyError) {
throw e; throw e;
} }
if (context.id) {
throw new CopilotFailedToMatchContext({ throw new CopilotFailedToMatchContext({
contextId: context.id, contextId: context.id,
// don't record the large content // don't record the large content
content: content.slice(0, 512), content: content.slice(0, 512),
message: e.message, message: e.message,
}); });
} else {
throw new CopilotFailedToMatchGlobalContext({
workspaceId: context.workspaceId,
// don't record the large content
content: content.slice(0, 512),
message: e.message,
});
}
} }
} }
} }

View File

@@ -148,6 +148,48 @@ export class CopilotContextService implements OnApplicationBootstrap {
return null; return null;
} }
async matchWorkspaceFiles(
workspaceId: string,
content: string,
topK: number = 5,
signal?: AbortSignal,
threshold: number = 0.5
) {
if (!this.embeddingClient) return [];
const embedding = await this.embeddingClient.getEmbedding(content, signal);
if (!embedding) return [];
const chunks = await this.models.copilotWorkspace.matchFileEmbedding(
workspaceId,
embedding,
topK * 2,
threshold
);
return this.embeddingClient.reRank(content, chunks, topK, signal);
}
async matchWorkspaceDocs(
workspaceId: string,
content: string,
topK: number = 5,
signal?: AbortSignal,
threshold: number = 0.5
) {
if (!this.embeddingClient) return [];
const embedding = await this.embeddingClient.getEmbedding(content, signal);
if (!embedding) return [];
const workspace = await this.models.copilotContext.matchWorkspaceEmbedding(
embedding,
workspaceId,
topK * 2,
threshold
);
return this.embeddingClient.reRank(content, workspace, topK);
}
@OnEvent('workspace.doc.embed.failed') @OnEvent('workspace.doc.embed.failed')
async onDocEmbedFailed({ async onDocEmbedFailed({
contextId, contextId,

View File

@@ -52,12 +52,16 @@ export class ContextSession implements AsyncDisposable {
} }
get files() { get files() {
return this.config.files.map(f => ({ ...f })); return this.config.files.map(f => this.fulfillFile(f));
} }
get docIds() { get docIds() {
return Array.from( return Array.from(
new Set([this.config.docs, this.config.categories].flat().map(d => d.id)) new Set(
[this.config.docs, this.config.categories.flatMap(c => c.docs)]
.flat()
.map(d => d.id)
)
); );
} }
@@ -136,14 +140,25 @@ export class ContextSession implements AsyncDisposable {
return true; return true;
} }
async addFile(blobId: string, name: string): Promise<ContextFile> { private fulfillFile(file: ContextFile): Required<ContextFile> {
return {
...file,
mimeType: file.mimeType || 'application/octet-stream',
};
}
async addFile(
blobId: string,
name: string,
mimeType: string
): Promise<Required<ContextFile>> {
let fileId = nanoid(); let fileId = nanoid();
const existsBlob = this.config.files.find(f => f.blobId === blobId); const existsBlob = this.config.files.find(f => f.blobId === blobId);
if (existsBlob) { if (existsBlob) {
// use exists file id if the blob exists // use exists file id if the blob exists
// we assume that the file content pointed to by the same blobId is consistent. // we assume that the file content pointed to by the same blobId is consistent.
if (existsBlob.status === ContextEmbedStatus.finished) { if (existsBlob.status === ContextEmbedStatus.finished) {
return existsBlob; return this.fulfillFile(existsBlob);
} }
fileId = existsBlob.id; fileId = existsBlob.id;
} else { } else {
@@ -152,11 +167,12 @@ export class ContextSession implements AsyncDisposable {
blobId, blobId,
chunkSize: 0, chunkSize: 0,
name, name,
mimeType,
error: null, error: null,
createdAt: Date.now(), createdAt: Date.now(),
})); }));
} }
return this.getFile(fileId) as ContextFile; return this.fulfillFile(this.getFile(fileId) as ContextFile);
} }
getFile(fileId: string): ContextFile | undefined { getFile(fileId: string): ContextFile | undefined {
@@ -181,15 +197,14 @@ export class ContextSession implements AsyncDisposable {
* @param threshold relevance threshold for the similarity score, higher threshold means more similar chunks, default 0.7, good enough based on prior experiments * @param threshold relevance threshold for the similarity score, higher threshold means more similar chunks, default 0.7, good enough based on prior experiments
* @returns list of similar chunks * @returns list of similar chunks
*/ */
async matchFileChunks( async matchFiles(
content: string, content: string,
topK: number = 5, topK: number = 5,
signal?: AbortSignal, signal?: AbortSignal,
threshold: number = 0.85 scopedThreshold: number = 0.85,
threshold: number = 0.5
): Promise<FileChunkSimilarity[]> { ): Promise<FileChunkSimilarity[]> {
const embedding = await this.client const embedding = await this.client.getEmbedding(content, signal);
.getEmbeddings([content], signal)
.then(r => r?.[0]?.embedding);
if (!embedding) return []; if (!embedding) return [];
const [context, workspace] = await Promise.all([ const [context, workspace] = await Promise.all([
@@ -197,7 +212,7 @@ export class ContextSession implements AsyncDisposable {
embedding, embedding,
this.id, this.id,
topK * 2, topK * 2,
threshold scopedThreshold
), ),
this.models.copilotWorkspace.matchFileEmbedding( this.models.copilotWorkspace.matchFileEmbedding(
this.workspaceId, this.workspaceId,
@@ -206,10 +221,21 @@ export class ContextSession implements AsyncDisposable {
threshold threshold
), ),
]); ]);
const files = new Map(this.files.map(f => [f.id, f]));
return this.client.reRank( return this.client.reRank(
content, content,
[...context, ...workspace], [
...context
.filter(f => files.has(f.fileId))
.map(c => {
const { blobId, name, mimeType } = files.get(
c.fileId
) as Required<ContextFile>;
return { ...c, blobId, name, mimeType };
}),
...workspace,
],
topK, topK,
signal signal
); );
@@ -223,16 +249,14 @@ export class ContextSession implements AsyncDisposable {
* @param threshold relevance threshold for the similarity score, higher threshold means more similar chunks, default 0.7, good enough based on prior experiments * @param threshold relevance threshold for the similarity score, higher threshold means more similar chunks, default 0.7, good enough based on prior experiments
* @returns list of similar chunks * @returns list of similar chunks
*/ */
async matchWorkspaceChunks( async matchWorkspaceDocs(
content: string, content: string,
topK: number = 5, topK: number = 5,
signal?: AbortSignal, signal?: AbortSignal,
scopedThreshold: number = 0.5, scopedThreshold: number = 0.85,
threshold: number = 0.85 threshold: number = 0.5
) { ) {
const embedding = await this.client const embedding = await this.client.getEmbedding(content, signal);
.getEmbeddings([content], signal)
.then(r => r?.[0]?.embedding);
if (!embedding) return []; if (!embedding) return [];
const docIds = this.docIds; const docIds = this.docIds;

View File

@@ -128,6 +128,11 @@ export abstract class EmbeddingClient {
.slice(0, topK); .slice(0, topK);
} }
async getEmbedding(query: string, signal?: AbortSignal) {
const embedding = await this.getEmbeddings([query], signal);
return embedding?.[0]?.embedding;
}
abstract getEmbeddings( abstract getEmbeddings(
input: string[], input: string[],
signal?: AbortSignal signal?: AbortSignal

View File

@@ -60,6 +60,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
await this.storage.put(userId, workspaceId, blobId, buffer); await this.storage.put(userId, workspaceId, blobId, buffer);
const file = await this.models.copilotWorkspace.addFile(workspaceId, { const file = await this.models.copilotWorkspace.addFile(workspaceId, {
fileName, fileName,
blobId,
mimeType: content.mimetype, mimeType: content.mimetype,
size: buffer.length, size: buffer.length,
}); });

View File

@@ -55,6 +55,9 @@ export class CopilotWorkspaceFileType implements CopilotWorkspaceFile {
@Field(() => String) @Field(() => String)
fileId!: string; fileId!: string;
@Field(() => String)
blobId!: string;
@Field(() => String) @Field(() => String)
fileName!: string; fileName!: string;

View File

@@ -69,10 +69,13 @@ type ContextMatchedDocChunk {
} }
type ContextMatchedFileChunk { type ContextMatchedFileChunk {
blobId: String!
chunk: SafeInt! chunk: SafeInt!
content: String! content: String!
distance: Float distance: Float
fileId: String! fileId: String!
mimeType: String!
name: String!
} }
type ContextWorkspaceEmbeddingStatus { type ContextWorkspaceEmbeddingStatus {
@@ -107,10 +110,10 @@ type CopilotContext {
"""list files in context""" """list files in context"""
files: [CopilotContextFile!]! files: [CopilotContextFile!]!
id: ID! id: ID
"""match file in context""" """match file in context"""
matchFiles(content: String!, limit: SafeInt, threshold: Float): [ContextMatchedFileChunk!]! matchFiles(content: String!, limit: SafeInt, scopedThreshold: Float, threshold: Float): [ContextMatchedFileChunk!]!
"""match workspace docs""" """match workspace docs"""
matchWorkspaceDocs(content: String!, limit: SafeInt, scopedThreshold: Float, threshold: Float): [ContextMatchedDocChunk!]! matchWorkspaceDocs(content: String!, limit: SafeInt, scopedThreshold: Float, threshold: Float): [ContextMatchedDocChunk!]!
@@ -140,6 +143,7 @@ type CopilotContextFile {
createdAt: SafeInt! createdAt: SafeInt!
error: String error: String
id: ID! id: ID!
mimeType: String!
name: String! name: String!
status: ContextEmbedStatus! status: ContextEmbedStatus!
} }
@@ -169,6 +173,12 @@ type CopilotFailedToMatchContextDataType {
message: String! message: String!
} }
type CopilotFailedToMatchGlobalContextDataType {
content: String!
message: String!
workspaceId: String!
}
type CopilotFailedToModifyContextDataType { type CopilotFailedToModifyContextDataType {
contextId: String! contextId: String!
message: String! message: String!
@@ -280,6 +290,7 @@ type CopilotWorkspaceConfig {
} }
type CopilotWorkspaceFile { type CopilotWorkspaceFile {
blobId: String!
createdAt: DateTime! createdAt: DateTime!
fileId: String! fileId: String!
fileName: String! fileName: String!
@@ -464,7 +475,7 @@ type EditorType {
name: String! name: String!
} }
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
enum ErrorNames { enum ErrorNames {
ACCESS_DENIED ACCESS_DENIED
@@ -491,6 +502,7 @@ enum ErrorNames {
COPILOT_FAILED_TO_CREATE_MESSAGE COPILOT_FAILED_TO_CREATE_MESSAGE
COPILOT_FAILED_TO_GENERATE_TEXT COPILOT_FAILED_TO_GENERATE_TEXT
COPILOT_FAILED_TO_MATCH_CONTEXT COPILOT_FAILED_TO_MATCH_CONTEXT
COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT
COPILOT_FAILED_TO_MODIFY_CONTEXT COPILOT_FAILED_TO_MODIFY_CONTEXT
COPILOT_INVALID_CONTEXT COPILOT_INVALID_CONTEXT
COPILOT_MESSAGE_NOT_FOUND COPILOT_MESSAGE_NOT_FOUND

View File

@@ -3,6 +3,7 @@ mutation addContextFile($content: Upload!, $options: AddContextFileInput!) {
id id
createdAt createdAt
name name
mimeType
chunkSize chunkSize
error error
status status

View File

@@ -15,6 +15,7 @@ query listContextObject(
files { files {
id id
name name
mimeType
blobId blobId
chunkSize chunkSize
error error

View File

@@ -1,14 +1,17 @@
query matchContext($contextId: String!, $content: String!, $limit: SafeInt, $threshold: Float) { query matchContext($contextId: String, $workspaceId: String, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) {
currentUser { currentUser {
copilot { copilot(workspaceId: $workspaceId) {
contexts(contextId: $contextId) { contexts(contextId: $contextId) {
matchFiles(content: $content, limit: $limit, threshold: $threshold) { matchFiles(content: $content, limit: $limit, scopedThreshold: $scopedThreshold, threshold: $threshold) {
fileId fileId
blobId
name
mimeType
chunk chunk
content content
distance distance
} }
matchWorkspaceDocs(content: $content, limit: $limit, threshold: $threshold) { matchWorkspaceDocs(content: $content, limit: $limit, scopedThreshold: $scopedThreshold, threshold: $threshold) {
docId docId
chunk chunk
content content

View File

@@ -1,6 +1,6 @@
query matchWorkspaceDocs($contextId: String!, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) { query matchWorkspaceDocs($contextId: String, $workspaceId: String, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) {
currentUser { currentUser {
copilot { copilot(workspaceId: $workspaceId) {
contexts(contextId: $contextId) { contexts(contextId: $contextId) {
matchWorkspaceDocs(content: $content, limit: $limit, scopedThreshold: $scopedThreshold, threshold: $threshold) { matchWorkspaceDocs(content: $content, limit: $limit, scopedThreshold: $scopedThreshold, threshold: $threshold) {
docId docId

View File

@@ -1,9 +1,10 @@
query matchFiles($contextId: String!, $content: String!, $limit: SafeInt, $threshold: Float) { query matchFiles($contextId: String, $workspaceId: String, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) {
currentUser { currentUser {
copilot { copilot(workspaceId: $workspaceId) {
contexts(contextId: $contextId) { contexts(contextId: $contextId) {
matchFiles(content: $content, limit: $limit, threshold: $threshold) { matchFiles(content: $content, limit: $limit, scopedThreshold: $scopedThreshold, threshold: $threshold) {
fileId fileId
blobId
chunk chunk
content content
distance distance

View File

@@ -2,6 +2,7 @@ mutation addWorkspaceEmbeddingFiles($workspaceId: String!, $blob: Upload!) {
addWorkspaceEmbeddingFiles(workspaceId: $workspaceId, blob: $blob) { addWorkspaceEmbeddingFiles(workspaceId: $workspaceId, blob: $blob) {
fileId fileId
fileName fileName
blobId
mimeType mimeType
size size
createdAt createdAt

View File

@@ -11,6 +11,7 @@ query getWorkspaceEmbeddingFiles($workspaceId: String!, $pagination: PaginationI
node { node {
fileId fileId
fileName fileName
blobId
mimeType mimeType
size size
createdAt createdAt

View File

@@ -390,6 +390,7 @@ export const addContextFileMutation = {
id id
createdAt createdAt
name name
mimeType
chunkSize chunkSize
error error
status status
@@ -423,6 +424,7 @@ export const listContextObjectQuery = {
files { files {
id id
name name
mimeType
blobId blobId
chunkSize chunkSize
error error
@@ -473,17 +475,30 @@ export const listContextQuery = {
export const matchContextQuery = { export const matchContextQuery = {
id: 'matchContextQuery' as const, id: 'matchContextQuery' as const,
op: 'matchContext', op: 'matchContext',
query: `query matchContext($contextId: String!, $content: String!, $limit: SafeInt, $threshold: Float) { query: `query matchContext($contextId: String, $workspaceId: String, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) {
currentUser { currentUser {
copilot { copilot(workspaceId: $workspaceId) {
contexts(contextId: $contextId) { contexts(contextId: $contextId) {
matchFiles(content: $content, limit: $limit, threshold: $threshold) { matchFiles(
content: $content
limit: $limit
scopedThreshold: $scopedThreshold
threshold: $threshold
) {
fileId fileId
blobId
name
mimeType
chunk chunk
content content
distance distance
} }
matchWorkspaceDocs(content: $content, limit: $limit, threshold: $threshold) { matchWorkspaceDocs(
content: $content
limit: $limit
scopedThreshold: $scopedThreshold
threshold: $threshold
) {
docId docId
chunk chunk
content content
@@ -498,9 +513,9 @@ export const matchContextQuery = {
export const matchWorkspaceDocsQuery = { export const matchWorkspaceDocsQuery = {
id: 'matchWorkspaceDocsQuery' as const, id: 'matchWorkspaceDocsQuery' as const,
op: 'matchWorkspaceDocs', op: 'matchWorkspaceDocs',
query: `query matchWorkspaceDocs($contextId: String!, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) { query: `query matchWorkspaceDocs($contextId: String, $workspaceId: String, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) {
currentUser { currentUser {
copilot { copilot(workspaceId: $workspaceId) {
contexts(contextId: $contextId) { contexts(contextId: $contextId) {
matchWorkspaceDocs( matchWorkspaceDocs(
content: $content content: $content
@@ -522,12 +537,18 @@ export const matchWorkspaceDocsQuery = {
export const matchFilesQuery = { export const matchFilesQuery = {
id: 'matchFilesQuery' as const, id: 'matchFilesQuery' as const,
op: 'matchFiles', op: 'matchFiles',
query: `query matchFiles($contextId: String!, $content: String!, $limit: SafeInt, $threshold: Float) { query: `query matchFiles($contextId: String, $workspaceId: String, $content: String!, $limit: SafeInt, $scopedThreshold: Float, $threshold: Float) {
currentUser { currentUser {
copilot { copilot(workspaceId: $workspaceId) {
contexts(contextId: $contextId) { contexts(contextId: $contextId) {
matchFiles(content: $content, limit: $limit, threshold: $threshold) { matchFiles(
content: $content
limit: $limit
scopedThreshold: $scopedThreshold
threshold: $threshold
) {
fileId fileId
blobId
chunk chunk
content content
distance distance
@@ -750,6 +771,7 @@ export const addWorkspaceEmbeddingFilesMutation = {
addWorkspaceEmbeddingFiles(workspaceId: $workspaceId, blob: $blob) { addWorkspaceEmbeddingFiles(workspaceId: $workspaceId, blob: $blob) {
fileId fileId
fileName fileName
blobId
mimeType mimeType
size size
createdAt createdAt
@@ -774,6 +796,7 @@ export const getWorkspaceEmbeddingFilesQuery = {
node { node {
fileId fileId
fileName fileName
blobId
mimeType mimeType
size size
createdAt createdAt

View File

@@ -109,10 +109,13 @@ export interface ContextMatchedDocChunk {
export interface ContextMatchedFileChunk { export interface ContextMatchedFileChunk {
__typename?: 'ContextMatchedFileChunk'; __typename?: 'ContextMatchedFileChunk';
blobId: Scalars['String']['output'];
chunk: Scalars['SafeInt']['output']; chunk: Scalars['SafeInt']['output'];
content: Scalars['String']['output']; content: Scalars['String']['output'];
distance: Maybe<Scalars['Float']['output']>; distance: Maybe<Scalars['Float']['output']>;
fileId: Scalars['String']['output']; fileId: Scalars['String']['output'];
mimeType: Scalars['String']['output'];
name: Scalars['String']['output'];
} }
export interface ContextWorkspaceEmbeddingStatus { export interface ContextWorkspaceEmbeddingStatus {
@@ -172,7 +175,7 @@ export interface CopilotContext {
docs: Array<CopilotContextDoc>; docs: Array<CopilotContextDoc>;
/** list files in context */ /** list files in context */
files: Array<CopilotContextFile>; files: Array<CopilotContextFile>;
id: Scalars['ID']['output']; id: Maybe<Scalars['ID']['output']>;
/** match file in context */ /** match file in context */
matchFiles: Array<ContextMatchedFileChunk>; matchFiles: Array<ContextMatchedFileChunk>;
/** match workspace docs */ /** match workspace docs */
@@ -185,6 +188,7 @@ export interface CopilotContext {
export interface CopilotContextMatchFilesArgs { export interface CopilotContextMatchFilesArgs {
content: Scalars['String']['input']; content: Scalars['String']['input'];
limit?: InputMaybe<Scalars['SafeInt']['input']>; limit?: InputMaybe<Scalars['SafeInt']['input']>;
scopedThreshold?: InputMaybe<Scalars['Float']['input']>;
threshold?: InputMaybe<Scalars['Float']['input']>; threshold?: InputMaybe<Scalars['Float']['input']>;
} }
@@ -218,6 +222,7 @@ export interface CopilotContextFile {
createdAt: Scalars['SafeInt']['output']; createdAt: Scalars['SafeInt']['output'];
error: Maybe<Scalars['String']['output']>; error: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output']; id: Scalars['ID']['output'];
mimeType: Scalars['String']['output'];
name: Scalars['String']['output']; name: Scalars['String']['output'];
status: ContextEmbedStatus; status: ContextEmbedStatus;
} }
@@ -252,6 +257,13 @@ export interface CopilotFailedToMatchContextDataType {
message: Scalars['String']['output']; message: Scalars['String']['output'];
} }
export interface CopilotFailedToMatchGlobalContextDataType {
__typename?: 'CopilotFailedToMatchGlobalContextDataType';
content: Scalars['String']['output'];
message: Scalars['String']['output'];
workspaceId: Scalars['String']['output'];
}
export interface CopilotFailedToModifyContextDataType { export interface CopilotFailedToModifyContextDataType {
__typename?: 'CopilotFailedToModifyContextDataType'; __typename?: 'CopilotFailedToModifyContextDataType';
contextId: Scalars['String']['output']; contextId: Scalars['String']['output'];
@@ -383,6 +395,7 @@ export interface CopilotWorkspaceConfigIgnoredDocsArgs {
export interface CopilotWorkspaceFile { export interface CopilotWorkspaceFile {
__typename?: 'CopilotWorkspaceFile'; __typename?: 'CopilotWorkspaceFile';
blobId: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
fileId: Scalars['String']['output']; fileId: Scalars['String']['output'];
fileName: Scalars['String']['output']; fileName: Scalars['String']['output'];
@@ -582,6 +595,7 @@ export type ErrorDataUnion =
| CopilotDocNotFoundDataType | CopilotDocNotFoundDataType
| CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType
| CopilotFailedToMatchContextDataType | CopilotFailedToMatchContextDataType
| CopilotFailedToMatchGlobalContextDataType
| CopilotFailedToModifyContextDataType | CopilotFailedToModifyContextDataType
| CopilotInvalidContextDataType | CopilotInvalidContextDataType
| CopilotMessageNotFoundDataType | CopilotMessageNotFoundDataType
@@ -651,6 +665,7 @@ export enum ErrorNames {
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE', COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT', COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT',
COPILOT_FAILED_TO_MATCH_CONTEXT = 'COPILOT_FAILED_TO_MATCH_CONTEXT', COPILOT_FAILED_TO_MATCH_CONTEXT = 'COPILOT_FAILED_TO_MATCH_CONTEXT',
COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT = 'COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT',
COPILOT_FAILED_TO_MODIFY_CONTEXT = 'COPILOT_FAILED_TO_MODIFY_CONTEXT', COPILOT_FAILED_TO_MODIFY_CONTEXT = 'COPILOT_FAILED_TO_MODIFY_CONTEXT',
COPILOT_INVALID_CONTEXT = 'COPILOT_INVALID_CONTEXT', COPILOT_INVALID_CONTEXT = 'COPILOT_INVALID_CONTEXT',
COPILOT_MESSAGE_NOT_FOUND = 'COPILOT_MESSAGE_NOT_FOUND', COPILOT_MESSAGE_NOT_FOUND = 'COPILOT_MESSAGE_NOT_FOUND',
@@ -2876,6 +2891,7 @@ export type AddContextFileMutation = {
id: string; id: string;
createdAt: number; createdAt: number;
name: string; name: string;
mimeType: string;
chunkSize: number; chunkSize: number;
error: string | null; error: string | null;
status: ContextEmbedStatus; status: ContextEmbedStatus;
@@ -2917,6 +2933,7 @@ export type ListContextObjectQuery = {
__typename?: 'CopilotContextFile'; __typename?: 'CopilotContextFile';
id: string; id: string;
name: string; name: string;
mimeType: string;
blobId: string; blobId: string;
chunkSize: number; chunkSize: number;
error: string | null; error: string | null;
@@ -2965,7 +2982,7 @@ export type ListContextQuery = {
__typename?: 'Copilot'; __typename?: 'Copilot';
contexts: Array<{ contexts: Array<{
__typename?: 'CopilotContext'; __typename?: 'CopilotContext';
id: string; id: string | null;
workspaceId: string; workspaceId: string;
}>; }>;
}; };
@@ -2973,9 +2990,11 @@ export type ListContextQuery = {
}; };
export type MatchContextQueryVariables = Exact<{ export type MatchContextQueryVariables = Exact<{
contextId: Scalars['String']['input']; contextId?: InputMaybe<Scalars['String']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
content: Scalars['String']['input']; content: Scalars['String']['input'];
limit?: InputMaybe<Scalars['SafeInt']['input']>; limit?: InputMaybe<Scalars['SafeInt']['input']>;
scopedThreshold?: InputMaybe<Scalars['Float']['input']>;
threshold?: InputMaybe<Scalars['Float']['input']>; threshold?: InputMaybe<Scalars['Float']['input']>;
}>; }>;
@@ -2990,6 +3009,9 @@ export type MatchContextQuery = {
matchFiles: Array<{ matchFiles: Array<{
__typename?: 'ContextMatchedFileChunk'; __typename?: 'ContextMatchedFileChunk';
fileId: string; fileId: string;
blobId: string;
name: string;
mimeType: string;
chunk: number; chunk: number;
content: string; content: string;
distance: number | null; distance: number | null;
@@ -3007,7 +3029,8 @@ export type MatchContextQuery = {
}; };
export type MatchWorkspaceDocsQueryVariables = Exact<{ export type MatchWorkspaceDocsQueryVariables = Exact<{
contextId: Scalars['String']['input']; contextId?: InputMaybe<Scalars['String']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
content: Scalars['String']['input']; content: Scalars['String']['input'];
limit?: InputMaybe<Scalars['SafeInt']['input']>; limit?: InputMaybe<Scalars['SafeInt']['input']>;
scopedThreshold?: InputMaybe<Scalars['Float']['input']>; scopedThreshold?: InputMaybe<Scalars['Float']['input']>;
@@ -3035,9 +3058,11 @@ export type MatchWorkspaceDocsQuery = {
}; };
export type MatchFilesQueryVariables = Exact<{ export type MatchFilesQueryVariables = Exact<{
contextId: Scalars['String']['input']; contextId?: InputMaybe<Scalars['String']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
content: Scalars['String']['input']; content: Scalars['String']['input'];
limit?: InputMaybe<Scalars['SafeInt']['input']>; limit?: InputMaybe<Scalars['SafeInt']['input']>;
scopedThreshold?: InputMaybe<Scalars['Float']['input']>;
threshold?: InputMaybe<Scalars['Float']['input']>; threshold?: InputMaybe<Scalars['Float']['input']>;
}>; }>;
@@ -3052,6 +3077,7 @@ export type MatchFilesQuery = {
matchFiles: Array<{ matchFiles: Array<{
__typename?: 'ContextMatchedFileChunk'; __typename?: 'ContextMatchedFileChunk';
fileId: string; fileId: string;
blobId: string;
chunk: number; chunk: number;
content: string; content: string;
distance: number | null; distance: number | null;
@@ -3321,6 +3347,7 @@ export type AddWorkspaceEmbeddingFilesMutation = {
__typename?: 'CopilotWorkspaceFile'; __typename?: 'CopilotWorkspaceFile';
fileId: string; fileId: string;
fileName: string; fileName: string;
blobId: string;
mimeType: string; mimeType: string;
size: number; size: number;
createdAt: string; createdAt: string;
@@ -3352,6 +3379,7 @@ export type GetWorkspaceEmbeddingFilesQuery = {
__typename?: 'CopilotWorkspaceFile'; __typename?: 'CopilotWorkspaceFile';
fileId: string; fileId: string;
fileName: string; fileName: string;
blobId: string;
mimeType: string; mimeType: string;
size: number; size: number;
createdAt: string; createdAt: string;

View File

@@ -329,9 +329,12 @@ declare global {
abortSignal: AbortSignal abortSignal: AbortSignal
) => Promise<void>; ) => Promise<void>;
matchContext: ( matchContext: (
contextId: string,
content: string, content: string,
limit?: number contextId?: string,
workspaceId?: string,
limit?: number,
scopedThreshold?: number,
threshold?: number
) => Promise<{ ) => Promise<{
files?: ContextMatchedFileChunk[]; files?: ContextMatchedFileChunk[];
docs?: ContextMatchedDocChunk[]; docs?: ContextMatchedDocChunk[];

View File

@@ -661,7 +661,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
>(); >();
const { files: matchedFiles = [], docs: matchedDocs = [] } = const { files: matchedFiles = [], docs: matchedDocs = [] } =
(await AIProvider.context?.matchContext(contextId, userInput)) ?? {}; (await AIProvider.context?.matchContext(userInput, contextId)) ?? {};
matchedDocs.forEach(doc => { matchedDocs.forEach(doc => {
docContexts.set(doc.docId, { docContexts.set(doc.docId, {

View File

@@ -241,7 +241,7 @@ export class CopilotClient {
sessionId, sessionId,
}, },
}); });
return res.currentUser?.copilot?.contexts?.[0]?.id; return res.currentUser?.copilot?.contexts?.[0]?.id || undefined;
} }
async addContextDoc(options: OptionsField<typeof addContextDocMutation>) { async addContextDoc(options: OptionsField<typeof addContextDocMutation>) {
@@ -333,13 +333,23 @@ export class CopilotClient {
return res.currentUser?.copilot?.contexts?.[0]; return res.currentUser?.copilot?.contexts?.[0];
} }
async matchContext(contextId: string, content: string, limit?: number) { async matchContext(
content: string,
contextId?: string,
workspaceId?: string,
limit?: number,
scopedThreshold?: number,
threshold?: number
) {
const res = await this.gql({ const res = await this.gql({
query: matchContextQuery, query: matchContextQuery,
variables: { variables: {
contextId,
content, content,
contextId,
workspaceId,
limit, limit,
scopedThreshold,
threshold,
}, },
}); });
const { matchFiles: files, matchWorkspaceDocs: docs } = const { matchFiles: files, matchWorkspaceDocs: docs } =

View File

@@ -700,11 +700,21 @@ Could you make a new website based on these notes and send back just the html fi
} }
}, },
matchContext: async ( matchContext: async (
contextId: string,
content: string, content: string,
limit?: number contextId?: string,
workspaceId?: string,
limit?: number,
scopedThreshold?: number,
threshold?: number
) => { ) => {
return client.matchContext(contextId, content, limit); return client.matchContext(
content,
contextId,
workspaceId,
limit,
scopedThreshold,
threshold
);
}, },
}); });

View File

@@ -8412,6 +8412,13 @@ export function useAFFiNEI18N(): {
contextId: string; contextId: string;
message: string; message: string;
}>): string; }>): string;
/**
* `Failed to match context in workspace {{workspaceId}} with "%7B%7Bcontent%7D%7D": {{message}}`
*/
["error.COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT"](options: Readonly<{
workspaceId: string;
message: string;
}>): string;
/** /**
* `Embedding feature is disabled, please contact the administrator to enable it in the workspace settings.` * `Embedding feature is disabled, please contact the administrator to enable it in the workspace settings.`
*/ */

View File

@@ -2081,6 +2081,7 @@
"error.COPILOT_CONTEXT_FILE_NOT_SUPPORTED": "File {{fileName}} is not supported to use as context: {{message}}", "error.COPILOT_CONTEXT_FILE_NOT_SUPPORTED": "File {{fileName}} is not supported to use as context: {{message}}",
"error.COPILOT_FAILED_TO_MODIFY_CONTEXT": "Failed to modify context {{contextId}}: {{message}}", "error.COPILOT_FAILED_TO_MODIFY_CONTEXT": "Failed to modify context {{contextId}}: {{message}}",
"error.COPILOT_FAILED_TO_MATCH_CONTEXT": "Failed to match context {{contextId}} with \"%7B%7Bcontent%7D%7D\": {{message}}", "error.COPILOT_FAILED_TO_MATCH_CONTEXT": "Failed to match context {{contextId}} with \"%7B%7Bcontent%7D%7D\": {{message}}",
"error.COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT": "Failed to match context in workspace {{workspaceId}} with \"%7B%7Bcontent%7D%7D\": {{message}}",
"error.COPILOT_EMBEDDING_DISABLED": "Embedding feature is disabled, please contact the administrator to enable it in the workspace settings.", "error.COPILOT_EMBEDDING_DISABLED": "Embedding feature is disabled, please contact the administrator to enable it in the workspace settings.",
"error.COPILOT_EMBEDDING_UNAVAILABLE": "Embedding feature not available, you may need to install pgvector extension to your database", "error.COPILOT_EMBEDDING_UNAVAILABLE": "Embedding feature not available, you may need to install pgvector extension to your database",
"error.COPILOT_TRANSCRIPTION_JOB_EXISTS": "Transcription job already exists", "error.COPILOT_TRANSCRIPTION_JOB_EXISTS": "Transcription job already exists",