mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 11:36:25 +08:00
feat(server): realtime notification & task status (#14934)
#### PR Dependency Tree * **PR #14934** 👈 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** * Full realtime platform added: live notifications, comments, embedding progress, and transcription task updates via realtime subscriptions. * **Chores** * Frontend switched from polling/GraphQL queries to realtime channels; legacy query fields marked deprecated and client libs updated to use realtime APIs. [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14934) <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #14934** 👈 * **PR #14936** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export { CopilotEmbeddingRealtimeProvider } from './realtime';
|
||||
export { CopilotContextResolver, CopilotContextRootResolver } from './resolver';
|
||||
export { CopilotContextService } from './service';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OnEvent } from '../../../base';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import type {
|
||||
RealtimePublisher,
|
||||
RealtimeRegistry,
|
||||
} from '../../../core/realtime';
|
||||
import { Models } from '../../../models';
|
||||
import { CopilotContextService } from './service';
|
||||
|
||||
export function workspaceEmbeddingRoom(workspaceId: string) {
|
||||
return `workspace:${workspaceId}:embedding-progress`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly models: Models,
|
||||
private readonly context: CopilotContextService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
const input = z.object({ workspaceId: z.string() });
|
||||
|
||||
this.registry?.registerRequest({
|
||||
name: 'workspace.embedding.progress.get',
|
||||
input,
|
||||
handle: async (user, payload) => {
|
||||
await this.assertCopilot(user.id, payload.workspaceId);
|
||||
if (!this.context.canEmbedding) {
|
||||
return { total: 0, embedded: 0 };
|
||||
}
|
||||
return await this.models.copilotWorkspace.getEmbeddingStatus(
|
||||
payload.workspaceId
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
this.registry?.registerTopic({
|
||||
name: 'workspace.embedding.progress.changed',
|
||||
input,
|
||||
authorize: async (user, payload) => {
|
||||
await this.assertCopilot(user.id, payload.workspaceId);
|
||||
},
|
||||
room: (_user, payload) => workspaceEmbeddingRoom(payload.workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.doc.embed.finished', { suppressError: true })
|
||||
async onDocEmbedFinished(payload: Events['workspace.doc.embed.finished']) {
|
||||
await this.publishContext(payload.contextId, 'finished');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.doc.embed.failed', { suppressError: true })
|
||||
async onDocEmbedFailed(payload: Events['workspace.doc.embed.failed']) {
|
||||
await this.publishContext(payload.contextId, 'failed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.file.embed.finished', { suppressError: true })
|
||||
async onFileEmbedFinished(payload: Events['workspace.file.embed.finished']) {
|
||||
await this.publishContext(payload.contextId, 'finished');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.file.embed.failed', { suppressError: true })
|
||||
async onFileEmbedFailed(payload: Events['workspace.file.embed.failed']) {
|
||||
await this.publishContext(payload.contextId, 'failed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blob.embed.finished', { suppressError: true })
|
||||
async onBlobEmbedFinished(payload: Events['workspace.blob.embed.finished']) {
|
||||
await this.publishContext(payload.contextId, 'finished');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blob.embed.failed', { suppressError: true })
|
||||
async onBlobEmbedFailed(payload: Events['workspace.blob.embed.failed']) {
|
||||
await this.publishContext(payload.contextId, 'failed');
|
||||
}
|
||||
|
||||
private async publishContext(
|
||||
contextId: string,
|
||||
reason: 'finished' | 'failed'
|
||||
) {
|
||||
if (!this.publisher) return;
|
||||
const context = await this.context.get(contextId);
|
||||
this.publisher.publish(
|
||||
'workspace.embedding.progress.changed',
|
||||
{ workspaceId: context.workspaceId },
|
||||
{ reason },
|
||||
{ room: workspaceEmbeddingRoom(context.workspaceId) }
|
||||
);
|
||||
}
|
||||
|
||||
private async assertCopilot(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
}
|
||||
}
|
||||
@@ -412,12 +412,15 @@ export class CopilotContextRootResolver {
|
||||
@Throttle('strict')
|
||||
@Query(() => ContextWorkspaceEmbeddingStatus, {
|
||||
description: 'query workspace embedding status',
|
||||
deprecationReason:
|
||||
'Use realtime subscription "workspace.embedding.progress.changed" instead.',
|
||||
})
|
||||
@CallMetric('ai', 'context_query_workspace_embedding_status')
|
||||
async queryWorkspaceEmbeddingStatus(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
): Promise<ContextWorkspaceEmbeddingStatus> {
|
||||
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CopilotContextResolver,
|
||||
CopilotContextRootResolver,
|
||||
CopilotContextService,
|
||||
CopilotEmbeddingRealtimeProvider,
|
||||
} from './context';
|
||||
import { ConversationInboxService } from './conversation/inbox';
|
||||
import { ConversationPolicy } from './conversation/policy';
|
||||
@@ -55,6 +56,7 @@ import { CopilotStorage } from './storage';
|
||||
import {
|
||||
CopilotTranscriptionResolver,
|
||||
CopilotTranscriptionService,
|
||||
CopilotTranscriptRealtimeProvider,
|
||||
} from './transcript';
|
||||
import {
|
||||
CopilotWorkspaceEmbeddingConfigResolver,
|
||||
@@ -106,11 +108,15 @@ export const COPILOT_RUNTIME_PROVIDERS = [
|
||||
TurnPersistence,
|
||||
];
|
||||
|
||||
export const COPILOT_CONTEXT_PROVIDERS = [CopilotContextResolver];
|
||||
export const COPILOT_CONTEXT_PROVIDERS = [
|
||||
CopilotContextResolver,
|
||||
CopilotEmbeddingRealtimeProvider,
|
||||
];
|
||||
|
||||
export const COPILOT_TRANSCRIPT_PROVIDERS = [
|
||||
CopilotTranscriptionService,
|
||||
CopilotTranscriptionResolver,
|
||||
CopilotTranscriptRealtimeProvider,
|
||||
];
|
||||
|
||||
export const COPILOT_WORKSPACE_PROVIDERS = [
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { CopilotTranscriptRealtimeProvider } from './realtime';
|
||||
export { CopilotTranscriptionResolver } from './resolver';
|
||||
export { CopilotTranscriptionService } from './service';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CopilotTranscriptionJobNotFound } from '../../../base';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import type { RealtimeRegistry } from '../../../core/realtime';
|
||||
import { CopilotTranscriptionService, transcriptTaskRoom } from './service';
|
||||
|
||||
@Injectable()
|
||||
export class CopilotTranscriptRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly transcript: CopilotTranscriptionService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.registry?.registerRequest({
|
||||
name: 'copilot.transcript.task.get',
|
||||
input: z
|
||||
.object({
|
||||
workspaceId: z.string(),
|
||||
blobId: z.string().optional(),
|
||||
taskId: z.string().optional(),
|
||||
})
|
||||
.refine(input => input.blobId || input.taskId),
|
||||
handle: async (user, input) => {
|
||||
await this.assertCopilot(user.id, input.workspaceId);
|
||||
return {
|
||||
task: await this.transcript.queryTask(
|
||||
user.id,
|
||||
input.workspaceId,
|
||||
input.taskId,
|
||||
input.blobId
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
this.registry?.registerTopic({
|
||||
name: 'copilot.transcript.task.changed',
|
||||
input: z.object({
|
||||
workspaceId: z.string(),
|
||||
taskId: z.string(),
|
||||
}),
|
||||
authorize: async (user, input) => {
|
||||
await this.assertCopilot(user.id, input.workspaceId);
|
||||
const task = await this.transcript.queryTask(
|
||||
user.id,
|
||||
input.workspaceId,
|
||||
input.taskId
|
||||
);
|
||||
if (!task) {
|
||||
throw new CopilotTranscriptionJobNotFound();
|
||||
}
|
||||
},
|
||||
room: (_user, input) =>
|
||||
transcriptTaskRoom(input.workspaceId, input.taskId),
|
||||
});
|
||||
}
|
||||
|
||||
private async assertCopilot(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
}
|
||||
}
|
||||
@@ -416,6 +416,8 @@ export class CopilotTranscriptionResolver {
|
||||
|
||||
@ResolveField(() => TranscriptionResultType, {
|
||||
nullable: true,
|
||||
deprecationReason:
|
||||
'Use realtime subscription "copilot.transcript.task.changed" instead.',
|
||||
})
|
||||
async transcriptTask(
|
||||
@Parent() copilot: CopilotType,
|
||||
@@ -425,6 +427,7 @@ export class CopilotTranscriptionResolver {
|
||||
@Args('blobId', { nullable: true })
|
||||
blobId?: string
|
||||
): Promise<TranscriptionResultType | null> {
|
||||
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
|
||||
if (!copilot.workspaceId) return null;
|
||||
if (!taskId && !blobId) return null;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
OnJob,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import type { RealtimePublisher } from '../../../core/realtime';
|
||||
import { Models } from '../../../models';
|
||||
import { CopilotAccessPolicy } from '../access';
|
||||
import { PromptService } from '../prompt';
|
||||
@@ -32,6 +33,10 @@ const TRANSCRIPT_ACTION_ID = 'transcript.audio.gemini';
|
||||
const TRANSCRIPT_ACTION_VERSION = 'v1';
|
||||
const TRANSCRIPT_STRATEGY = 'gemini';
|
||||
|
||||
export function transcriptTaskRoom(workspaceId: string, taskId: string) {
|
||||
return `copilot:transcript:${workspaceId}:${taskId}`;
|
||||
}
|
||||
|
||||
export type TranscriptionJob = {
|
||||
id: string;
|
||||
status: AiJobStatus;
|
||||
@@ -62,7 +67,8 @@ export class CopilotTranscriptionService {
|
||||
private readonly tasks: TaskPolicy,
|
||||
private readonly prompts: PromptService,
|
||||
private readonly actionBridge: ActionRuntimeBridge,
|
||||
@Optional() private readonly access?: CopilotAccessPolicy
|
||||
@Optional() private readonly access?: CopilotAccessPolicy,
|
||||
@Optional() private readonly realtime?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
private parseTaskPayload(payload: unknown): TranscriptionPayloadV2 {
|
||||
@@ -252,6 +258,7 @@ export class CopilotTranscriptionService {
|
||||
modelId: model,
|
||||
});
|
||||
await this.models.copilotTranscriptTask.markRunning(task.id);
|
||||
this.publishTaskChanged(workspaceId, task.id, AiJobStatus.running);
|
||||
|
||||
return { id: task.id, status: AiJobStatus.running, infos };
|
||||
}
|
||||
@@ -294,6 +301,7 @@ export class CopilotTranscriptionService {
|
||||
retryOf: task.actionRunId ?? undefined,
|
||||
});
|
||||
await this.models.copilotTranscriptTask.markRunning(taskId);
|
||||
this.publishTaskChanged(workspaceId, taskId, AiJobStatus.running);
|
||||
return {
|
||||
id: taskId,
|
||||
status: AiJobStatus.running,
|
||||
@@ -389,6 +397,11 @@ export class CopilotTranscriptionService {
|
||||
},
|
||||
onRunCreated: async ({ runId }) => {
|
||||
await this.models.copilotTranscriptTask.markRunning(taskId, runId);
|
||||
this.publishTaskChanged(
|
||||
task.workspaceId,
|
||||
taskId,
|
||||
AiJobStatus.running
|
||||
);
|
||||
},
|
||||
prepareStructuredRoutes: {
|
||||
stepId: 'transcribe',
|
||||
@@ -425,6 +438,7 @@ export class CopilotTranscriptionService {
|
||||
protectedResult: parsedResult,
|
||||
errorCode: null,
|
||||
});
|
||||
this.publishTaskChanged(task.workspaceId, taskId, AiJobStatus.finished);
|
||||
} catch (error) {
|
||||
await this.models.copilotTranscriptTask.complete(taskId, {
|
||||
status: 'failed',
|
||||
@@ -434,7 +448,27 @@ export class CopilotTranscriptionService {
|
||||
errorCode:
|
||||
error instanceof Error ? error.message : 'transcript_task_failed',
|
||||
});
|
||||
this.publishTaskChanged(
|
||||
task.workspaceId,
|
||||
taskId,
|
||||
AiJobStatus.failed,
|
||||
error instanceof Error ? error.message : 'transcript_task_failed'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private publishTaskChanged(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
status: AiJobStatus,
|
||||
error?: string
|
||||
) {
|
||||
this.realtime?.publish(
|
||||
'copilot.transcript.task.changed',
|
||||
{ workspaceId, taskId },
|
||||
{ taskId, status, error },
|
||||
{ room: transcriptTaskRoom(workspaceId, taskId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user