feat(server): improve context management (#15448)
#### PR Dependency Tree * **PR #15448** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added workspace artifact upload, browsing, removal, deduplication, and library ownership support. * Copilot now supports scoped document and artifact search, canvas reading, live editor context, and frontend tools. * Added scope and focus selectors with source-resolution receipts in chat. * Added embedding health, progress, synchronization, and retrieval capabilities. * Added BYOK policy visibility, provider restrictions, endpoint dialect selection, and validation. * Added delegated editor interactions and userdata document authorization. * **Bug Fixes** * Improved attachment handling, cancellation, access control, retrieval fallbacks, workspace synchronization, and configuration validation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { AiJobStatus, AiJobType } from '@prisma/client';
|
||||
import type { JsonValue } from '@prisma/client/runtime/library';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface CopilotJob {
|
||||
id?: string;
|
||||
@@ -12,83 +11,6 @@ export interface CopilotJob {
|
||||
payload?: JsonValue;
|
||||
}
|
||||
|
||||
export interface CopilotContext {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
config: JsonValue;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export enum ContextEmbedStatus {
|
||||
processing = 'processing',
|
||||
finished = 'finished',
|
||||
failed = 'failed',
|
||||
}
|
||||
|
||||
export enum ContextCategories {
|
||||
Tag = 'tag',
|
||||
Collection = 'collection',
|
||||
}
|
||||
|
||||
const ContextEmbedStatusSchema = z.enum([
|
||||
ContextEmbedStatus.processing,
|
||||
ContextEmbedStatus.finished,
|
||||
ContextEmbedStatus.failed,
|
||||
]);
|
||||
|
||||
const ContextBlobSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
const ContextDocSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export const ContextFileSchema = z.object({
|
||||
id: z.string(),
|
||||
chunkSize: z.number(),
|
||||
name: z.string(),
|
||||
mimeType: z.string().optional(),
|
||||
status: ContextEmbedStatusSchema,
|
||||
error: z.string().nullable(),
|
||||
blobId: z.string(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export const ContextCategorySchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum([ContextCategories.Tag, ContextCategories.Collection]),
|
||||
docs: ContextDocSchema.merge(
|
||||
z.object({ status: ContextEmbedStatusSchema })
|
||||
).array(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export const ContextConfigSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
blobs: ContextBlobSchema.merge(
|
||||
z.object({ status: ContextEmbedStatusSchema.optional() })
|
||||
).array(),
|
||||
files: ContextFileSchema.array(),
|
||||
docs: ContextDocSchema.merge(
|
||||
z.object({ status: ContextEmbedStatusSchema.optional() })
|
||||
).array(),
|
||||
categories: ContextCategorySchema.array(),
|
||||
});
|
||||
|
||||
export const MinimalContextConfigSchema = ContextConfigSchema.pick({
|
||||
workspaceId: true,
|
||||
});
|
||||
|
||||
export type ContextCategory = z.infer<typeof ContextCategorySchema>;
|
||||
export type ContextConfig = z.infer<typeof ContextConfigSchema>;
|
||||
export type ContextBlob = z.infer<typeof ContextConfigSchema>['blobs'][number];
|
||||
export type ContextDoc = z.infer<typeof ContextConfigSchema>['docs'][number];
|
||||
export type ContextFile = z.infer<typeof ContextConfigSchema>['files'][number];
|
||||
|
||||
// embeddings
|
||||
|
||||
export type Embedding = {
|
||||
@@ -100,40 +22,39 @@ export type Embedding = {
|
||||
embedding: Array<number>;
|
||||
};
|
||||
|
||||
export type DocumentEmbedding = Embedding & {
|
||||
projectionVersion: number;
|
||||
sourceHash: string;
|
||||
unitId: string;
|
||||
visibility: 'page' | 'edgeless' | 'both';
|
||||
blockId?: string;
|
||||
elementId?: string;
|
||||
frameId?: string;
|
||||
};
|
||||
|
||||
export type ChunkSimilarity = {
|
||||
chunk: number;
|
||||
content: string;
|
||||
distance: number | null;
|
||||
};
|
||||
|
||||
export type FileChunkSimilarity = ChunkSimilarity & {
|
||||
fileId: string;
|
||||
blobId: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type BlobChunkSimilarity = ChunkSimilarity & {
|
||||
blobId: string;
|
||||
};
|
||||
|
||||
export type DocChunkSimilarity = ChunkSimilarity & {
|
||||
docId: string;
|
||||
unitId: string;
|
||||
visibility: 'page' | 'edgeless' | 'both';
|
||||
blockId?: string;
|
||||
elementId?: string;
|
||||
frameId?: string;
|
||||
};
|
||||
|
||||
export const CopilotWorkspaceFileSchema = z.object({
|
||||
fileName: z.string(),
|
||||
blobId: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
});
|
||||
|
||||
export type CopilotWorkspaceFileMetadata = z.infer<
|
||||
typeof CopilotWorkspaceFileSchema
|
||||
>;
|
||||
export type CopilotWorkspaceFile = CopilotWorkspaceFileMetadata & {
|
||||
export type CopilotWorkspaceArtifact = {
|
||||
workspaceId: string;
|
||||
fileId: string;
|
||||
artifactId: string;
|
||||
contentHash: string;
|
||||
fileName: string;
|
||||
embeddingStatus: 'processing' | 'ready' | 'failed';
|
||||
mediaType: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { CopilotSessionNotFound } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import {
|
||||
clearEmbeddingContent,
|
||||
ContextBlob,
|
||||
ContextConfigSchema,
|
||||
ContextDoc,
|
||||
ContextEmbedStatus,
|
||||
CopilotContext,
|
||||
DocChunkSimilarity,
|
||||
Embedding,
|
||||
EMBEDDING_DIMENSIONS,
|
||||
FileChunkSimilarity,
|
||||
MinimalContextConfigSchema,
|
||||
} from './common/copilot';
|
||||
|
||||
type UpdateCopilotContextInput = Pick<CopilotContext, 'config'>;
|
||||
|
||||
/**
|
||||
* Copilot Job Model
|
||||
*/
|
||||
@Injectable()
|
||||
export class CopilotContextModel extends BaseModel {
|
||||
// ================ contexts ================
|
||||
|
||||
async create(sessionId: string) {
|
||||
const session = await this.db.aiSession.findFirst({
|
||||
where: { id: sessionId },
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
if (!session) {
|
||||
throw new CopilotSessionNotFound();
|
||||
}
|
||||
|
||||
const row = await this.db.aiContext.create({
|
||||
data: {
|
||||
sessionId,
|
||||
config: {
|
||||
workspaceId: session.workspaceId,
|
||||
blobs: [],
|
||||
docs: [],
|
||||
files: [],
|
||||
categories: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
const row = await this.db.aiContext.findFirst({
|
||||
where: { id },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async getAccessInfo(id: string) {
|
||||
return await this.db.aiContext.findFirst({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
sessionId: true,
|
||||
session: {
|
||||
select: {
|
||||
userId: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getConfig(id: string) {
|
||||
const row = await this.get(id);
|
||||
if (row) {
|
||||
const config = ContextConfigSchema.safeParse(row.config);
|
||||
if (config.success) {
|
||||
return config.data;
|
||||
}
|
||||
const minimalConfig = MinimalContextConfigSchema.safeParse(row.config);
|
||||
if (minimalConfig.success) {
|
||||
// fulfill the missing fields
|
||||
return {
|
||||
blobs: [],
|
||||
docs: [],
|
||||
files: [],
|
||||
categories: [],
|
||||
...minimalConfig.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBySessionId(sessionId: string) {
|
||||
const row = await this.db.aiContext.findFirst({
|
||||
where: { sessionId },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async mergeBlobStatus(
|
||||
workspaceId: string,
|
||||
blobs: ContextBlob[]
|
||||
): Promise<ContextBlob[]> {
|
||||
const canEmbedding = await this.checkEmbeddingAvailable();
|
||||
const finishedBlobs = canEmbedding
|
||||
? await this.listWorkspaceBlobEmbedding(
|
||||
workspaceId,
|
||||
Array.from(new Set(blobs.map(blob => blob.id)))
|
||||
)
|
||||
: [];
|
||||
const finishedBlobSet = new Set(finishedBlobs);
|
||||
|
||||
for (const blob of blobs) {
|
||||
const status = finishedBlobSet.has(blob.id)
|
||||
? ContextEmbedStatus.finished
|
||||
: undefined;
|
||||
// NOTE: when the blob has not been synchronized to the server or is in the embedding queue
|
||||
// the status will be empty, fallback to processing if no status is provided
|
||||
blob.status = status || blob.status || ContextEmbedStatus.processing;
|
||||
}
|
||||
|
||||
return blobs;
|
||||
}
|
||||
|
||||
async mergeDocStatus(workspaceId: string, docs: ContextDoc[]) {
|
||||
const canEmbedding = await this.checkEmbeddingAvailable();
|
||||
const finishedDoc = canEmbedding
|
||||
? await this.listWorkspaceDocEmbedding(
|
||||
workspaceId,
|
||||
Array.from(new Set(docs.map(doc => doc.id)))
|
||||
)
|
||||
: [];
|
||||
const finishedDocSet = new Set(finishedDoc);
|
||||
|
||||
for (const doc of docs) {
|
||||
const status = finishedDocSet.has(doc.id)
|
||||
? ContextEmbedStatus.finished
|
||||
: undefined;
|
||||
// NOTE: when the document has not been synchronized to the server or is in the embedding queue
|
||||
// the status will be empty, fallback to processing if no status is provided
|
||||
doc.status = status || doc.status || ContextEmbedStatus.processing;
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
async update(contextId: string, data: UpdateCopilotContextInput) {
|
||||
const ret = await this.db.aiContext.updateMany({
|
||||
where: {
|
||||
id: contextId,
|
||||
},
|
||||
data: {
|
||||
config: data.config || undefined,
|
||||
},
|
||||
});
|
||||
return ret.count > 0;
|
||||
}
|
||||
|
||||
// ================ embeddings ================
|
||||
|
||||
async checkEmbeddingAvailable(): Promise<boolean> {
|
||||
const [{ count }] = await this.db.$queryRaw<
|
||||
{ count: number }[]
|
||||
>`SELECT count(1) FROM pg_tables WHERE tablename in ('ai_context_embeddings', 'ai_workspace_embeddings')`;
|
||||
return Number(count) === 2;
|
||||
}
|
||||
|
||||
async listWorkspaceBlobEmbedding(
|
||||
workspaceId: string,
|
||||
blobIds?: string[]
|
||||
): Promise<string[]> {
|
||||
const existsIds = await this.db.aiWorkspaceBlobEmbedding
|
||||
.groupBy({
|
||||
where: {
|
||||
workspaceId,
|
||||
blobId: blobIds ? { in: blobIds } : undefined,
|
||||
},
|
||||
by: ['blobId'],
|
||||
})
|
||||
.then(r => r.map(r => r.blobId));
|
||||
return existsIds;
|
||||
}
|
||||
|
||||
async listWorkspaceDocEmbedding(workspaceId: string, docIds?: string[]) {
|
||||
const existsIds = await this.db.aiWorkspaceEmbedding
|
||||
.groupBy({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId: docIds ? { in: docIds } : undefined,
|
||||
},
|
||||
by: ['docId'],
|
||||
})
|
||||
.then(r => r.map(r => r.docId));
|
||||
return existsIds;
|
||||
}
|
||||
|
||||
private processEmbeddings(
|
||||
contextOrWorkspaceId: string,
|
||||
fileOrDocId: string,
|
||||
embeddings: Embedding[],
|
||||
withId = true
|
||||
) {
|
||||
const groups = embeddings.map(e =>
|
||||
[
|
||||
withId ? randomUUID() : undefined,
|
||||
contextOrWorkspaceId,
|
||||
fileOrDocId,
|
||||
e.index,
|
||||
e.content,
|
||||
Prisma.raw(`'[${e.embedding.join(',')}]'`),
|
||||
new Date(),
|
||||
].filter(v => v !== undefined)
|
||||
);
|
||||
return Prisma.join(groups.map(row => Prisma.sql`(${Prisma.join(row)})`));
|
||||
}
|
||||
|
||||
async getFileContent(
|
||||
contextId: string,
|
||||
fileId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
const file = await this.db.aiContextEmbedding.findMany({
|
||||
where: { contextId, fileId, chunk },
|
||||
select: { content: true },
|
||||
orderBy: { chunk: 'asc' },
|
||||
});
|
||||
return file?.map(f => clearEmbeddingContent(f.content)).join('\n');
|
||||
}
|
||||
|
||||
async insertFileEmbedding(
|
||||
contextId: string,
|
||||
fileId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for contextId: ${contextId}, fileId: ${fileId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(contextId, fileId, embeddings);
|
||||
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO "ai_context_embeddings"
|
||||
("id", "context_id", "file_id", "chunk", "content", "embedding", "updated_at") VALUES ${values}
|
||||
ON CONFLICT (context_id, file_id, chunk) DO UPDATE SET
|
||||
content = EXCLUDED.content, embedding = EXCLUDED.embedding, updated_at = excluded.updated_at;
|
||||
`;
|
||||
}
|
||||
|
||||
async deleteFileEmbedding(contextId: string, fileId: string) {
|
||||
await this.db.aiContextEmbedding.deleteMany({
|
||||
where: { contextId, fileId },
|
||||
});
|
||||
}
|
||||
|
||||
async matchFileEmbedding(
|
||||
embedding: number[],
|
||||
contextId: string,
|
||||
topK: number,
|
||||
threshold: number
|
||||
): Promise<Omit<FileChunkSimilarity, 'blobId' | 'name' | 'mimeType'>[]> {
|
||||
const similarityChunks = await this.db.$queryRaw<
|
||||
Array<Omit<FileChunkSimilarity, 'blobId' | 'name' | 'mimeType'>>
|
||||
>`
|
||||
SELECT "file_id" as "fileId", "chunk", "content", "embedding" <=> ${embedding}::vector as "distance"
|
||||
FROM "ai_context_embeddings"
|
||||
WHERE context_id = ${contextId}
|
||||
ORDER BY "distance" ASC
|
||||
LIMIT ${topK};
|
||||
`;
|
||||
return similarityChunks.filter(c => Number(c.distance) <= threshold);
|
||||
}
|
||||
|
||||
async getWorkspaceContent(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
const file = await this.db.aiWorkspaceEmbedding.findMany({
|
||||
where: { workspaceId, docId, chunk },
|
||||
select: { content: true },
|
||||
orderBy: { chunk: 'asc' },
|
||||
});
|
||||
return file?.map(f => clearEmbeddingContent(f.content)).join('\n');
|
||||
}
|
||||
|
||||
async insertWorkspaceEmbedding(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for workspaceId: ${workspaceId}, docId: ${docId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(
|
||||
workspaceId,
|
||||
docId,
|
||||
embeddings,
|
||||
false
|
||||
);
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO "ai_workspace_embeddings"
|
||||
("workspace_id", "doc_id", "chunk", "content", "embedding", "updated_at")
|
||||
VALUES ${values}
|
||||
ON CONFLICT (workspace_id, doc_id, chunk)
|
||||
DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
embedding = EXCLUDED.embedding,
|
||||
updated_at = excluded.updated_at;
|
||||
`;
|
||||
}
|
||||
|
||||
async fulfillEmptyEmbedding(workspaceId: string, docId: string) {
|
||||
const emptyEmbedding = {
|
||||
index: 0,
|
||||
content: '',
|
||||
embedding: Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0),
|
||||
};
|
||||
await this.models.copilotContext.insertWorkspaceEmbedding(
|
||||
workspaceId,
|
||||
docId,
|
||||
[emptyEmbedding]
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWorkspaceEmbedding(workspaceId: string, docId: string) {
|
||||
await this.purgeWorkspaceEmbedding(workspaceId, docId);
|
||||
await this.fulfillEmptyEmbedding(workspaceId, docId);
|
||||
}
|
||||
|
||||
async purgeWorkspaceEmbedding(workspaceId: string, docId: string) {
|
||||
await this.db.aiWorkspaceEmbedding.deleteMany({
|
||||
where: { workspaceId, docId },
|
||||
});
|
||||
}
|
||||
|
||||
async matchWorkspaceEmbedding(
|
||||
embedding: number[],
|
||||
workspaceId: string,
|
||||
topK: number,
|
||||
threshold: number,
|
||||
matchDocIds?: string[]
|
||||
): Promise<DocChunkSimilarity[]> {
|
||||
const similarityChunks = await this.db.$queryRaw<Array<DocChunkSimilarity>>`
|
||||
SELECT
|
||||
w."doc_id" as "docId",
|
||||
w."chunk",
|
||||
w."content",
|
||||
w."embedding" <=> ${embedding}::vector as "distance"
|
||||
FROM "ai_workspace_embeddings" w
|
||||
LEFT JOIN "ai_workspace_ignored_docs" i
|
||||
ON i."workspace_id" = w."workspace_id"
|
||||
AND i."doc_id" = w."doc_id"
|
||||
${matchDocIds?.length ? Prisma.sql`AND w."doc_id" NOT IN (${Prisma.join(matchDocIds)})` : Prisma.empty}
|
||||
WHERE
|
||||
w."workspace_id" = ${workspaceId}
|
||||
AND i."doc_id" IS NULL
|
||||
AND (w."embedding" <=> ${embedding}::vector) <= ${threshold}
|
||||
ORDER BY "distance" ASC
|
||||
LIMIT ${topK};
|
||||
`;
|
||||
|
||||
return similarityChunks;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
CopilotSessionNotFound,
|
||||
} from '../base';
|
||||
import type { PromptAttachment } from '../plugins/copilot/providers/types';
|
||||
import type {
|
||||
SessionFocus,
|
||||
TurnScopeSnapshot,
|
||||
} from '../plugins/copilot/runtime/contracts/shared';
|
||||
import {
|
||||
type ChatMessage as CopilotChatMessage,
|
||||
ChatMessageSchema,
|
||||
@@ -48,6 +52,7 @@ type ChatMessage = {
|
||||
content: string;
|
||||
attachments?: ChatAttachment[] | null;
|
||||
params?: Record<string, any> | null;
|
||||
scopeSnapshot?: TurnScopeSnapshot | null;
|
||||
streamObjects?: ChatStreamObject[] | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
@@ -61,6 +66,7 @@ type StoredChatMessage = Prisma.AiSessionMessageGetPayload<{
|
||||
attachments: true;
|
||||
streamObjects: true;
|
||||
params: true;
|
||||
scopeSnapshot: true;
|
||||
createdAt: true;
|
||||
};
|
||||
}>;
|
||||
@@ -317,6 +323,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
params: this.sanitizeJsonValue(
|
||||
omit(message.params, ['docs']) || undefined
|
||||
),
|
||||
scopeSnapshot: this.sanitizeJsonValue(message.scopeSnapshot),
|
||||
streamObjects: message.streamObjects?.map(o =>
|
||||
this.sanitizeStreamObject(o)
|
||||
),
|
||||
@@ -488,6 +495,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
parentSessionId: true,
|
||||
pinned: true,
|
||||
title: true,
|
||||
focus: true,
|
||||
promptName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -499,6 +507,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
attachments: true,
|
||||
streamObjects: true,
|
||||
params: true,
|
||||
scopeSnapshot: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -516,6 +525,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
parentSessionId: true,
|
||||
pinned: true,
|
||||
title: true,
|
||||
focus: true,
|
||||
promptName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -584,6 +594,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
parentSessionId: true,
|
||||
pinned: true,
|
||||
title: true,
|
||||
focus: true,
|
||||
promptName: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -596,6 +607,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
attachments: true,
|
||||
streamObjects: true,
|
||||
params: true,
|
||||
scopeSnapshot: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
@@ -744,6 +756,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
attachments: true,
|
||||
streamObjects: true,
|
||||
params: true,
|
||||
scopeSnapshot: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -766,6 +779,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
attachments: true,
|
||||
streamObjects: true,
|
||||
params: true,
|
||||
scopeSnapshot: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -791,6 +805,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
content: m.content,
|
||||
attachments: m.attachments || undefined,
|
||||
params: m.params || undefined,
|
||||
scopeSnapshot: m.scopeSnapshot || undefined,
|
||||
streamObjects: m.streamObjects || undefined,
|
||||
createdAt: m.createdAt,
|
||||
sessionId,
|
||||
@@ -813,13 +828,32 @@ export class CopilotSessionModel extends BaseModel {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
message: ChatMessage;
|
||||
focus?: SessionFocus;
|
||||
artifacts?: Array<{
|
||||
artifactId: string;
|
||||
role: string;
|
||||
displayName?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
}) {
|
||||
const haveSession = await this.has(state.sessionId, state.userId);
|
||||
if (!haveSession) {
|
||||
const session = await this.getExists(
|
||||
state.sessionId,
|
||||
{ id: true, workspaceId: true },
|
||||
{ userId: state.userId }
|
||||
);
|
||||
if (!session) {
|
||||
throw new CopilotSessionNotFound();
|
||||
}
|
||||
|
||||
const message = this.sanitizeMessage(state.message);
|
||||
const artifacts = [];
|
||||
const artifactKeys = new Set<string>();
|
||||
for (const artifact of state.artifacts ?? []) {
|
||||
const key = `${artifact.artifactId}:${artifact.role}`;
|
||||
if (artifactKeys.has(key)) continue;
|
||||
artifactKeys.add(key);
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
const created = await this.db.aiSessionMessage.create({
|
||||
data: {
|
||||
sessionId: state.sessionId,
|
||||
@@ -828,8 +862,28 @@ export class CopilotSessionModel extends BaseModel {
|
||||
content: message.content,
|
||||
attachments: message.attachments || undefined,
|
||||
params: message.params || undefined,
|
||||
scopeSnapshot: message.scopeSnapshot || undefined,
|
||||
streamObjects: message.streamObjects || undefined,
|
||||
createdAt: message.createdAt,
|
||||
artifacts: artifacts.length
|
||||
? {
|
||||
create: artifacts.map(artifact => ({
|
||||
role: artifact.role,
|
||||
displayName: this.sanitizeString(artifact.displayName),
|
||||
metadata: this.sanitizeJsonValue(artifact.metadata) as
|
||||
| Prisma.InputJsonObject
|
||||
| undefined,
|
||||
artifact: {
|
||||
connect: {
|
||||
workspaceId_id: {
|
||||
workspaceId: session.workspaceId,
|
||||
id: artifact.artifactId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -839,6 +893,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
attachments: true,
|
||||
streamObjects: true,
|
||||
params: true,
|
||||
scopeSnapshot: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -850,6 +905,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
message.role === AiSessionMessageRole.user
|
||||
? { increment: 1 }
|
||||
: undefined,
|
||||
focus: state.focus,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,75 +1,26 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import {
|
||||
type BlobChunkSimilarity,
|
||||
clearEmbeddingContent,
|
||||
type CopilotWorkspaceFile,
|
||||
type CopilotWorkspaceFileMetadata,
|
||||
type Embedding,
|
||||
type FileChunkSimilarity,
|
||||
type IgnoredDoc,
|
||||
} from './common';
|
||||
import type { IgnoredDoc } from './common';
|
||||
|
||||
@Injectable()
|
||||
export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
constructor(private readonly database: PrismaClient) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
private async listIgnoredDocIds(
|
||||
workspaceId: string,
|
||||
options?: PaginationInput
|
||||
) {
|
||||
return await this.db.aiWorkspaceIgnoredDocs.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
select: {
|
||||
docId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where: { workspaceId },
|
||||
select: { docId: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: options?.offset,
|
||||
take: options?.first,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* find docs to embed, excluding ignored and already embedded docs
|
||||
* newer docs will be list first
|
||||
* @param workspaceId id of the workspace
|
||||
* @returns docIds
|
||||
*/
|
||||
async findDocsToEmbed(workspaceId: string): Promise<string[]> {
|
||||
// NOTE: for unknown reason, the transaction will timeout if call from event handler
|
||||
// so we use an independent client here
|
||||
const docIds = await this.database.$queryRaw<{ id: string }[]>`
|
||||
SELECT s.guid as id
|
||||
FROM snapshots AS s
|
||||
LEFT JOIN ai_workspace_embeddings e
|
||||
ON e.workspace_id = s.workspace_id
|
||||
AND e.doc_id = s.guid
|
||||
LEFT JOIN ai_workspace_ignored_docs id
|
||||
ON id.workspace_id = s.workspace_id
|
||||
AND id.doc_id = s.guid
|
||||
WHERE s.workspace_id = ${workspaceId}
|
||||
AND s.guid <> s.workspace_id
|
||||
AND s.guid NOT LIKE '%$%'
|
||||
AND s.guid NOT LIKE '%:settings:%'
|
||||
AND e.doc_id IS NULL
|
||||
AND id.doc_id IS NULL
|
||||
AND s.blob <> E'\\\\x0000';`;
|
||||
|
||||
return docIds.map(r => r.id);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async updateIgnoredDocs(
|
||||
workspaceId: string,
|
||||
@@ -78,28 +29,17 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
) {
|
||||
const removed = new Set(remove);
|
||||
const ignored = await this.listIgnoredDocIds(workspaceId).then(
|
||||
r => new Set(r.map(r => r.docId).filter(id => !removed.has(id)))
|
||||
rows => new Set(rows.map(row => row.docId).filter(id => !removed.has(id)))
|
||||
);
|
||||
const added = add.filter(id => !ignored.has(id));
|
||||
|
||||
const { count: addedCount } =
|
||||
await this.db.aiWorkspaceIgnoredDocs.createMany({
|
||||
data: added.map(docId => ({
|
||||
workspaceId,
|
||||
docId,
|
||||
})),
|
||||
data: added.map(docId => ({ workspaceId, docId })),
|
||||
});
|
||||
|
||||
const { count: removedCount } =
|
||||
await this.db.aiWorkspaceIgnoredDocs.deleteMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId: {
|
||||
in: Array.from(removed),
|
||||
},
|
||||
},
|
||||
where: { workspaceId, docId: { in: Array.from(removed) } },
|
||||
});
|
||||
|
||||
return addedCount + removedCount;
|
||||
}
|
||||
|
||||
@@ -108,22 +48,25 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
workspaceId: string,
|
||||
options?: PaginationInput
|
||||
): Promise<IgnoredDoc[]> {
|
||||
const row = await this.listIgnoredDocIds(workspaceId, options);
|
||||
const ids = row.map(r => ({ workspaceId, docId: r.docId }));
|
||||
const rows = await this.listIgnoredDocIds(workspaceId, options);
|
||||
const ids = rows.map(row => ({ workspaceId, docId: row.docId }));
|
||||
const docs = await this.models.doc.findMetas(ids);
|
||||
const docsMap = new Map(
|
||||
docs.filter(r => !!r).map(r => [`${r.workspaceId}-${r.docId}`, r])
|
||||
docs.flatMap(doc =>
|
||||
doc ? [[`${doc.workspaceId}-${doc.docId}`, doc] as const] : []
|
||||
)
|
||||
);
|
||||
const authors = await this.models.doc.findAuthors(ids);
|
||||
const authorsMap = new Map(
|
||||
authors.filter(r => !!r).map(r => [`${r.workspaceId}-${r.id}`, r])
|
||||
authors.flatMap(author =>
|
||||
author ? [[`${author.workspaceId}-${author.id}`, author] as const] : []
|
||||
)
|
||||
);
|
||||
|
||||
return row.map(r => {
|
||||
const docMeta = docsMap.get(`${workspaceId}-${r.docId}`);
|
||||
const docAuthor = authorsMap.get(`${workspaceId}-${r.docId}`);
|
||||
return rows.map(row => {
|
||||
const docMeta = docsMap.get(`${workspaceId}-${row.docId}`);
|
||||
const docAuthor = authorsMap.get(`${workspaceId}-${row.docId}`);
|
||||
return {
|
||||
...r,
|
||||
...row,
|
||||
docCreatedAt: docAuthor?.createdAt,
|
||||
docUpdatedAt: docAuthor?.updatedAt,
|
||||
title: docMeta?.title || undefined,
|
||||
@@ -136,377 +79,16 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
|
||||
@Transactional()
|
||||
async countIgnoredDocs(workspaceId: string): Promise<number> {
|
||||
const count = await this.db.aiWorkspaceIgnoredDocs.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
return await this.db.aiWorkspaceIgnoredDocs.count({
|
||||
where: { workspaceId },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async checkIgnoredDocs(workspaceId: string, docIds: string[]) {
|
||||
const ignored = await this.listIgnoredDocIds(workspaceId).then(
|
||||
r => new Set(r.map(r => r.docId))
|
||||
rows => new Set(rows.map(row => row.docId))
|
||||
);
|
||||
|
||||
return docIds.filter(id => ignored.has(id));
|
||||
}
|
||||
|
||||
// check if a docId has only placeholder embeddings
|
||||
@Transactional()
|
||||
async hasPlaceholder(workspaceId: string, docId: string): Promise<boolean> {
|
||||
const [total, nonPlaceholder] = await Promise.all([
|
||||
this.db.aiWorkspaceEmbedding.count({ where: { workspaceId, docId } }),
|
||||
this.db.aiWorkspaceEmbedding.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
NOT: { AND: [{ chunk: 0 }, { content: '' }] },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return total > 0 && nonPlaceholder === 0;
|
||||
}
|
||||
|
||||
private getEmbeddableCondition(
|
||||
workspaceId: string,
|
||||
ignoredDocIds?: string[]
|
||||
): Prisma.SnapshotWhereInput {
|
||||
const condition: Prisma.SnapshotWhereInput['AND'] = [
|
||||
{ id: { not: workspaceId } },
|
||||
{ id: { not: { contains: '$' } } },
|
||||
{ id: { not: { contains: ':settings:' } } },
|
||||
{ blob: { not: new Uint8Array([0, 0]) } },
|
||||
];
|
||||
if (ignoredDocIds && ignoredDocIds.length > 0) {
|
||||
condition.push({ id: { notIn: ignoredDocIds } });
|
||||
}
|
||||
return { workspaceId, AND: condition };
|
||||
}
|
||||
|
||||
async listEmbeddableDocIds(workspaceId: string) {
|
||||
const condition = this.getEmbeddableCondition(workspaceId);
|
||||
const rows = await this.db.snapshot.findMany({
|
||||
where: condition,
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map(r => r.id);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async getEmbeddingStatus(workspaceId: string) {
|
||||
const ignoredDocIds = (await this.listIgnoredDocIds(workspaceId)).map(
|
||||
d => d.docId
|
||||
);
|
||||
const snapshotCondition = this.getEmbeddableCondition(
|
||||
workspaceId,
|
||||
ignoredDocIds
|
||||
);
|
||||
|
||||
const [docTotal, docEmbedded, fileTotal, fileEmbedded] = await Promise.all([
|
||||
this.db.snapshot.findMany({
|
||||
where: snapshotCondition,
|
||||
select: { id: true },
|
||||
}),
|
||||
this.db.snapshot.findMany({
|
||||
where: { ...snapshotCondition, embedding: { some: {} } },
|
||||
select: { id: true },
|
||||
}),
|
||||
this.db.aiWorkspaceFiles.count({ where: { workspaceId } }),
|
||||
this.db.aiWorkspaceFiles.count({
|
||||
where: { workspaceId, embeddings: { some: {} } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const docTotalIds = docTotal.map(d => d.id);
|
||||
const docTotalSet = new Set(docTotalIds);
|
||||
const outdatedDocPrefix = `${workspaceId}:space:`;
|
||||
const duplicateOutdatedDocSet = new Set(
|
||||
docTotalIds
|
||||
.filter(id => id.startsWith(outdatedDocPrefix))
|
||||
.filter(id => docTotalSet.has(id.slice(outdatedDocPrefix.length)))
|
||||
);
|
||||
|
||||
return {
|
||||
total:
|
||||
docTotalIds.filter(id => !duplicateOutdatedDocSet.has(id)).length +
|
||||
fileTotal,
|
||||
embedded:
|
||||
docEmbedded
|
||||
.map(d => d.id)
|
||||
.filter(id => !duplicateOutdatedDocSet.has(id)).length + fileEmbedded,
|
||||
};
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async checkDocNeedEmbedded(workspaceId: string, docId: string) {
|
||||
// NOTE: check if the document needs re-embedding.
|
||||
// 1. first-time embedding when no embedding exists
|
||||
// 2. re-embedding only when the doc has updates newer than the last embedding
|
||||
// AND the last embedding is older than 10 minutes (avoid frequent updates)
|
||||
const result = await this.db.$queryRaw<{ needs_embedding: boolean }[]>`
|
||||
SELECT
|
||||
EXISTS (
|
||||
WITH docs AS (
|
||||
SELECT
|
||||
s.workspace_id,
|
||||
s.guid AS doc_id,
|
||||
s.updated_at
|
||||
FROM
|
||||
snapshots s
|
||||
WHERE
|
||||
s.workspace_id = ${workspaceId}
|
||||
AND s.guid = ${docId}
|
||||
UNION
|
||||
ALL
|
||||
SELECT
|
||||
u.workspace_id,
|
||||
u.guid AS doc_id,
|
||||
u.created_at AS updated_at
|
||||
FROM
|
||||
"updates" u
|
||||
WHERE
|
||||
u.workspace_id = ${workspaceId}
|
||||
AND u.guid = ${docId}
|
||||
)
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
docs
|
||||
LEFT JOIN ai_workspace_embeddings e
|
||||
ON e.workspace_id = docs.workspace_id
|
||||
AND e.doc_id = docs.doc_id
|
||||
WHERE
|
||||
e.updated_at IS NULL
|
||||
OR (docs.updated_at > e.updated_at AND e.updated_at < NOW() - INTERVAL '10 minutes')
|
||||
) AS needs_embedding;
|
||||
`;
|
||||
|
||||
return result[0]?.needs_embedding ?? false;
|
||||
}
|
||||
|
||||
// ================ embeddings ================
|
||||
|
||||
async checkEmbeddingAvailable(): Promise<boolean> {
|
||||
const [{ count }] = await this.db.$queryRaw<
|
||||
{ count: number }[]
|
||||
>`SELECT count(1) FROM pg_tables WHERE tablename in ('ai_workspace_embeddings', 'ai_workspace_file_embeddings', 'ai_workspace_blob_embeddings')`;
|
||||
return Number(count) === 3;
|
||||
}
|
||||
|
||||
private processEmbeddings(
|
||||
workspaceId: string,
|
||||
fileOrBlobId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
const groups = embeddings.map(e =>
|
||||
[
|
||||
workspaceId,
|
||||
fileOrBlobId,
|
||||
e.index,
|
||||
e.content,
|
||||
Prisma.raw(`'[${e.embedding.join(',')}]'`),
|
||||
].filter(v => v !== undefined)
|
||||
);
|
||||
return Prisma.join(groups.map(row => Prisma.sql`(${Prisma.join(row)})`));
|
||||
}
|
||||
|
||||
async addFile(
|
||||
workspaceId: string,
|
||||
file: CopilotWorkspaceFileMetadata
|
||||
): Promise<CopilotWorkspaceFile> {
|
||||
const fileId = randomUUID();
|
||||
const row = await this.db.aiWorkspaceFiles.create({
|
||||
data: { ...file, workspaceId, fileId },
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async getFile(workspaceId: string, fileId: string) {
|
||||
const file = await this.db.aiWorkspaceFiles.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
fileId,
|
||||
},
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async insertFileEmbeddings(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for workspaceId: ${workspaceId}, fileId: ${fileId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(workspaceId, fileId, embeddings);
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO "ai_workspace_file_embeddings"
|
||||
("workspace_id", "file_id", "chunk", "content", "embedding") VALUES ${values}
|
||||
ON CONFLICT (workspace_id, file_id, chunk) DO NOTHING;
|
||||
`;
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
workspaceId: string,
|
||||
options?: {
|
||||
includeRead?: boolean;
|
||||
} & PaginationInput
|
||||
): Promise<CopilotWorkspaceFile[]> {
|
||||
const files = await this.db.aiWorkspaceFiles.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: options?.offset,
|
||||
take: options?.first,
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
async countFiles(workspaceId: string): Promise<number> {
|
||||
const count = await this.db.aiWorkspaceFiles.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
async matchFileEmbedding(
|
||||
workspaceId: string,
|
||||
embedding: number[],
|
||||
topK: number,
|
||||
threshold: number
|
||||
): Promise<FileChunkSimilarity[]> {
|
||||
if (!(await this.allowEmbedding(workspaceId))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const similarityChunks = await this.db.$queryRaw<
|
||||
Array<FileChunkSimilarity>
|
||||
>`
|
||||
SELECT
|
||||
e."file_id" as "fileId",
|
||||
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
|
||||
LIMIT ${topK};
|
||||
`;
|
||||
return similarityChunks.filter(c => Number(c.distance) <= threshold);
|
||||
}
|
||||
|
||||
async getBlobContent(
|
||||
workspaceId: string,
|
||||
blobId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
const blob = await this.db.aiWorkspaceBlobEmbedding.findMany({
|
||||
where: { workspaceId, blobId, chunk },
|
||||
select: { content: true },
|
||||
orderBy: { chunk: 'asc' },
|
||||
});
|
||||
return blob?.map(f => clearEmbeddingContent(f.content)).join('\n');
|
||||
}
|
||||
|
||||
async getBlobChunkSizes(workspaceId: string, blobIds: string[]) {
|
||||
const sizes = await this.db.aiWorkspaceBlobEmbedding.groupBy({
|
||||
by: ['blobId'],
|
||||
_count: { chunk: true },
|
||||
where: { workspaceId, blobId: { in: blobIds } },
|
||||
});
|
||||
return sizes.reduce((acc, cur) => {
|
||||
if (cur._count.chunk) {
|
||||
acc.set(cur.blobId, cur._count.chunk);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<string, number>());
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async insertBlobEmbeddings(
|
||||
workspaceId: string,
|
||||
blobId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for workspaceId: ${workspaceId}, blobId: ${blobId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(workspaceId, blobId, embeddings);
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO "ai_workspace_blob_embeddings"
|
||||
("workspace_id", "blob_id", "chunk", "content", "embedding") VALUES ${values}
|
||||
ON CONFLICT (workspace_id, blob_id, chunk) DO NOTHING;
|
||||
`;
|
||||
}
|
||||
|
||||
async matchBlobEmbedding(
|
||||
workspaceId: string,
|
||||
embedding: number[],
|
||||
topK: number,
|
||||
threshold: number
|
||||
): Promise<BlobChunkSimilarity[]> {
|
||||
if (!(await this.allowEmbedding(workspaceId))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const similarityChunks = await this.db.$queryRaw<
|
||||
Array<BlobChunkSimilarity>
|
||||
>`
|
||||
SELECT
|
||||
e."blob_id" as "blobId",
|
||||
e."chunk",
|
||||
e."content",
|
||||
e."embedding" <=> ${embedding}::vector as "distance"
|
||||
FROM "ai_workspace_blob_embeddings" e
|
||||
WHERE e.workspace_id = ${workspaceId}
|
||||
ORDER BY "distance" ASC
|
||||
LIMIT ${topK};
|
||||
`;
|
||||
return similarityChunks.filter(c => Number(c.distance) <= threshold);
|
||||
}
|
||||
|
||||
async removeBlob(workspaceId: string, blobId: string) {
|
||||
await this.db.$executeRaw`
|
||||
DELETE FROM "ai_workspace_blob_embeddings"
|
||||
WHERE workspace_id = ${workspaceId} AND blob_id = ${blobId};
|
||||
`;
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeFile(workspaceId: string, fileId: string) {
|
||||
// embeddings will be removed by foreign key constraint
|
||||
await this.db.aiWorkspaceFiles.deleteMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
fileId,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private allowEmbedding(workspaceId: string) {
|
||||
return this.models.workspace.allowEmbedding(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { CommentAttachmentModel } from './comment-attachment';
|
||||
import { AppConfigModel } from './config';
|
||||
import { CopilotActionRunModel } from './copilot-action-run';
|
||||
import { CopilotWorkspaceByokConfigModel } from './copilot-byok';
|
||||
import { CopilotContextModel } from './copilot-context';
|
||||
import { CopilotJobModel } from './copilot-job';
|
||||
import { CopilotSessionModel } from './copilot-session';
|
||||
import { CopilotTranscriptTaskModel } from './copilot-transcript-task';
|
||||
@@ -77,7 +76,6 @@ const MODELS = {
|
||||
copilotUsage: CopilotUsageModel,
|
||||
copilotTranscriptTask: CopilotTranscriptTaskModel,
|
||||
copilotActionRun: CopilotActionRunModel,
|
||||
copilotContext: CopilotContextModel,
|
||||
copilotWorkspace: CopilotWorkspaceConfigModel,
|
||||
copilotWorkspaceByokConfig: CopilotWorkspaceByokConfigModel,
|
||||
copilotJob: CopilotJobModel,
|
||||
@@ -153,7 +151,6 @@ export * from './comment';
|
||||
export * from './comment-attachment';
|
||||
export * from './common';
|
||||
export * from './copilot-byok';
|
||||
export * from './copilot-context';
|
||||
export * from './copilot-job';
|
||||
export * from './copilot-session';
|
||||
export * from './copilot-transcript-task';
|
||||
|
||||
Reference in New Issue
Block a user