feat(server): attachment embedding (#13348)

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

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

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

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

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

* **Chores**
* Refactored and cleaned up code to support new blob entity and
embedding logic, including renaming and updating internal methods and
types.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-07-31 06:07:28 +08:00
committed by GitHub
parent b6a5bc052e
commit feb42e34be
24 changed files with 689 additions and 84 deletions
@@ -1520,14 +1520,17 @@ test('TextStreamParser should process a sequence of message chunks', t => {
// ==================== context ====================
test('should be able to manage context', async t => {
const { context, prompt, session, event, jobs, storage } = t.context;
const { context, db, event, jobs, prompt, session, storage, workspace } =
t.context;
const ws = await workspace.create(userId);
await prompt.set(promptName, 'model', [
{ role: 'system', content: 'hello {{word}}' },
]);
const chatSession = await session.create({
docId: 'test',
workspaceId: 'test',
workspaceId: ws.id,
userId,
promptName,
pinned: false,
@@ -1608,6 +1611,36 @@ test('should be able to manage context', async t => {
t.is(result[0].fileId, file.id, 'should match file id');
}
// blob record
{
const blobId = 'test-blob';
await storage.put(userId, session.workspaceId, blobId, buffer);
await db.blob.create({
data: {
workspaceId: session.workspaceId,
key: blobId,
size: buffer.length,
mime: 'application/pdf',
},
});
await jobs.embedPendingBlob({
userId,
workspaceId: session.workspaceId,
blobId,
});
const result = await t.context.context.matchWorkspaceBlobs(
session.workspaceId,
'test',
1,
undefined,
1
);
t.is(result.length, 1, 'should match blob embedding');
t.is(result[0].blobId, blobId, 'should match blob id');
}
// doc record
const addDoc = async () => {
@@ -74,6 +74,17 @@ Generated by [AVA](https://avajs.dev).
},
]
> should match workspace blob embedding
[
{
blobId: 'blob-test',
chunk: 0,
content: 'blob content',
distance: 0,
},
]
> should find docs to embed
1
@@ -89,13 +89,14 @@ test('should get null for non-exist job', async t => {
test('should update context', async t => {
const { id: contextId } = await t.context.copilotContext.create(sessionId);
const config = await t.context.copilotContext.getConfig(contextId);
const config = (await t.context.copilotContext.getConfig(contextId))!;
t.assert(config, 'should get context config');
const doc = {
id: docId,
createdAt: Date.now(),
};
config?.docs.push(doc);
config.docs.push(doc);
await t.context.copilotContext.update(contextId, { config });
const config1 = await t.context.copilotContext.getConfig(contextId);
@@ -164,7 +165,7 @@ test('should insert embedding by doc id', async t => {
);
{
const ret = await t.context.copilotContext.listWorkspaceEmbedding(
const ret = await t.context.copilotContext.listWorkspaceDocEmbedding(
workspace.id,
[docId]
);
@@ -320,7 +321,7 @@ test('should merge doc status correctly', async t => {
const hasEmbeddingStub = Sinon.stub(
t.context.copilotContext,
'listWorkspaceEmbedding'
'listWorkspaceDocEmbedding'
).resolves([]);
const stubResult = await t.context.copilotContext.mergeDocStatus(
@@ -145,6 +145,52 @@ test('should insert and search embedding', async t => {
}
}
{
await t.context.db.blob.create({
data: {
workspaceId: workspace.id,
key: 'blob-test',
mime: 'text/plain',
size: 1,
},
});
const blobId = 'blob-test';
await t.context.copilotWorkspace.insertBlobEmbeddings(
workspace.id,
blobId,
[
{
index: 0,
content: 'blob content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
{
const ret = await t.context.copilotWorkspace.matchBlobEmbedding(
workspace.id,
Array.from({ length: 1024 }, () => 0.9),
1,
1
);
t.snapshot(cleanObject(ret), 'should match workspace blob embedding');
}
await t.context.copilotWorkspace.removeBlob(workspace.id, blobId);
{
const ret = await t.context.copilotWorkspace.matchBlobEmbedding(
workspace.id,
Array.from({ length: 1024 }, () => 0.9),
1,
1
);
t.deepEqual(ret, [], 'should not match after removal');
}
}
{
const docId = randomUUID();
await t.context.doc.upsert({
@@ -369,7 +369,6 @@ export async function listContextDocAndFiles(
docs {
id
status
error
createdAt
}
files {