mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +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:
@@ -273,7 +273,7 @@ e2e('should mark notification as read', async t => {
|
||||
const count = await app.gql({
|
||||
query: notificationCountQuery,
|
||||
});
|
||||
t.is(count.currentUser!.notifications.totalCount, 0);
|
||||
t.is(count.currentUser!.notificationCount, 0);
|
||||
|
||||
// read again should work
|
||||
for (const notification of notifications) {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { NotificationModule } from './core/notification';
|
||||
import { PermissionModule } from './core/permission';
|
||||
import { QueueDashboardModule } from './core/queue-dashboard';
|
||||
import { QuotaModule } from './core/quota';
|
||||
import { RealtimeModule } from './core/realtime';
|
||||
import { SelfhostModule } from './core/selfhost';
|
||||
import { StaticFileModule } from './core/static-files';
|
||||
import { StorageModule } from './core/storage';
|
||||
@@ -117,6 +118,7 @@ export const FunctionalityModules = [
|
||||
ErrorModule,
|
||||
WebSocketModule,
|
||||
JobModule.forRoot(),
|
||||
RealtimeModule,
|
||||
ModelsModule,
|
||||
ScheduleModule.forRoot(),
|
||||
MonitorModule,
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Module } from '@nestjs/common';
|
||||
import { ServerConfigModule } from '../config';
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { CommentRealtimeProvider } from './realtime';
|
||||
import { CommentResolver } from './resolver';
|
||||
import { CommentService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [PermissionModule, StorageModule, ServerConfigModule],
|
||||
providers: [CommentResolver, CommentService],
|
||||
providers: [CommentResolver, CommentService, CommentRealtimeProvider],
|
||||
exports: [CommentService],
|
||||
})
|
||||
export class CommentModule {}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { decodeWithJson, encodeWithJson } from '../../base/graphql';
|
||||
import { AccessController } from '../permission';
|
||||
import type { RealtimePublisher, RealtimeRegistry } from '../realtime';
|
||||
import type { CommentCursor } from './resolver';
|
||||
import { CommentService } from './service';
|
||||
|
||||
export function commentRoom(workspaceId: string, docId: string) {
|
||||
return `workspace:${workspaceId}:doc:${docId}:comment`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CommentRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
@Optional() private readonly registry?: RealtimeRegistry
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
const input = z.object({
|
||||
workspaceId: z.string(),
|
||||
docId: z.string(),
|
||||
after: z.string().optional(),
|
||||
first: z.number().optional(),
|
||||
});
|
||||
|
||||
this.registry?.registerRequest({
|
||||
name: 'comment.changes.get',
|
||||
input,
|
||||
handle: async (user, payload) => {
|
||||
await this.assertRead(user.id, payload.workspaceId, payload.docId);
|
||||
const cursor: CommentCursor = decodeWithJson(payload.after) ?? {};
|
||||
const changes = await this.service.listCommentChanges(
|
||||
payload.workspaceId,
|
||||
payload.docId,
|
||||
{
|
||||
commentUpdatedAt: cursor.commentUpdatedAt,
|
||||
replyUpdatedAt: cursor.replyUpdatedAt,
|
||||
take: payload.first,
|
||||
}
|
||||
);
|
||||
const endCursor = cursor;
|
||||
for (const change of changes) {
|
||||
if (change.commentId) {
|
||||
endCursor.replyUpdatedAt = change.item.updatedAt;
|
||||
} else {
|
||||
endCursor.commentUpdatedAt = change.item.updatedAt;
|
||||
}
|
||||
}
|
||||
return {
|
||||
changes: changes.map(change => ({
|
||||
id: change.id,
|
||||
action: change.action,
|
||||
item: change.item,
|
||||
commentId: change.commentId ?? undefined,
|
||||
})) as never,
|
||||
startCursor: '',
|
||||
endCursor: encodeWithJson(endCursor),
|
||||
hasNextPage: changes.length > 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
this.registry?.registerTopic({
|
||||
name: 'comment.changed',
|
||||
input: z.object({
|
||||
workspaceId: z.string(),
|
||||
docId: z.string(),
|
||||
}),
|
||||
authorize: async (user, payload) => {
|
||||
await this.assertRead(user.id, payload.workspaceId, payload.docId);
|
||||
},
|
||||
room: (_user, payload) => commentRoom(payload.workspaceId, payload.docId),
|
||||
});
|
||||
}
|
||||
|
||||
private async assertRead(userId: string, workspaceId: string, docId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.doc(docId)
|
||||
.assert('Doc.Comments.Read');
|
||||
}
|
||||
}
|
||||
|
||||
export function publishCommentChanged(
|
||||
publisher: RealtimePublisher | undefined,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
) {
|
||||
publisher?.publish(
|
||||
'comment.changed',
|
||||
{ workspaceId, docId },
|
||||
{ changed: true }
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Optional } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Mutation,
|
||||
@@ -26,9 +27,11 @@ import { Comment, DocMode, Models, Reply } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { ServerFeature, ServerService } from '../config';
|
||||
import { AccessController, DocAction } from '../permission';
|
||||
import type { RealtimePublisher } from '../realtime';
|
||||
import { CommentAttachmentStorage } from '../storage';
|
||||
import { UserType } from '../user';
|
||||
import { WorkspaceType } from '../workspaces';
|
||||
import { publishCommentChanged } from './realtime';
|
||||
import { CommentService } from './service';
|
||||
import {
|
||||
CommentCreateInput,
|
||||
@@ -56,7 +59,8 @@ export class CommentResolver {
|
||||
private readonly commentAttachmentStorage: CommentAttachmentStorage,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly models: Models,
|
||||
private readonly server: ServerService
|
||||
private readonly server: ServerService,
|
||||
@Optional() private readonly realtime?: RealtimePublisher
|
||||
) {
|
||||
// enable comment feature by default
|
||||
this.server.enableFeature(ServerFeature.Comment);
|
||||
@@ -81,6 +85,7 @@ export class CommentResolver {
|
||||
input.docMode,
|
||||
input.mentions
|
||||
);
|
||||
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
|
||||
|
||||
return {
|
||||
...comment,
|
||||
@@ -108,6 +113,7 @@ export class CommentResolver {
|
||||
await this.assertPermission(me, comment, 'Doc.Comments.Update');
|
||||
|
||||
await this.service.updateComment(input);
|
||||
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -126,6 +132,7 @@ export class CommentResolver {
|
||||
await this.assertPermission(me, comment, 'Doc.Comments.Resolve');
|
||||
|
||||
await this.service.resolveComment(input);
|
||||
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -141,6 +148,7 @@ export class CommentResolver {
|
||||
await this.assertPermission(me, comment, 'Doc.Comments.Delete');
|
||||
|
||||
await this.service.deleteComment(id);
|
||||
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -169,6 +177,7 @@ export class CommentResolver {
|
||||
input.mentions,
|
||||
reply
|
||||
);
|
||||
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
|
||||
|
||||
return {
|
||||
...reply,
|
||||
@@ -195,6 +204,7 @@ export class CommentResolver {
|
||||
await this.assertPermission(me, reply, 'Doc.Comments.Update');
|
||||
|
||||
await this.service.updateReply(input);
|
||||
publishCommentChanged(this.realtime, reply.workspaceId, reply.docId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -210,6 +220,7 @@ export class CommentResolver {
|
||||
await this.assertPermission(me, reply, 'Doc.Comments.Delete');
|
||||
|
||||
await this.service.deleteReply(id);
|
||||
publishCommentChanged(this.realtime, reply.workspaceId, reply.docId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -294,6 +305,7 @@ export class CommentResolver {
|
||||
})
|
||||
pagination: PaginationInput
|
||||
): Promise<PaginatedCommentChangeObjectType> {
|
||||
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
|
||||
await this.assertPermission(
|
||||
me,
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MailModule } from '../mail';
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { NotificationJob } from './job';
|
||||
import { NotificationRealtimeProvider } from './realtime';
|
||||
import { NotificationResolver, UserNotificationResolver } from './resolver';
|
||||
import { NotificationService } from './service';
|
||||
|
||||
@@ -15,6 +16,7 @@ import { NotificationService } from './service';
|
||||
NotificationResolver,
|
||||
NotificationService,
|
||||
NotificationJob,
|
||||
NotificationRealtimeProvider,
|
||||
],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function notificationCountRoom(userId: string) {
|
||||
return `user:${userId}:notification`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { RealtimeRegistry } from '../realtime';
|
||||
import { notificationCountRoom } from './realtime-room';
|
||||
import { NotificationService } from './service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly service: NotificationService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.registry?.registerRequest({
|
||||
name: 'notification.count.get',
|
||||
input: z.object({}).strict(),
|
||||
handle: async user => ({
|
||||
count: await this.service.countByUserId(user.id),
|
||||
}),
|
||||
});
|
||||
|
||||
this.registry?.registerTopic({
|
||||
name: 'notification.count.changed',
|
||||
input: z.object({}).strict(),
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('User is required for notification count room');
|
||||
}
|
||||
return notificationCountRoom(user.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,11 @@ export class UserNotificationResolver {
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
description: 'Get user notification count',
|
||||
deprecationReason:
|
||||
'Use realtime subscription "notification.count.changed" instead.',
|
||||
})
|
||||
async notificationCount(@CurrentUser() me: UserType): Promise<number> {
|
||||
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
|
||||
return await this.service.countByUserId(me.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
} from '../../models';
|
||||
import { DocReader } from '../doc';
|
||||
import { Mailer } from '../mail';
|
||||
import type { RealtimePublisher } from '../realtime';
|
||||
import { generateDocPath } from '../utils/doc';
|
||||
import {
|
||||
generateWorkspaceSettingsPath,
|
||||
WorkspaceSettingsTab,
|
||||
} from '../utils/workspace';
|
||||
import { notificationCountRoom } from './realtime-room';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@@ -31,11 +33,22 @@ export class NotificationService {
|
||||
private readonly models: Models,
|
||||
private readonly docReader: DocReader,
|
||||
private readonly mailer: Mailer,
|
||||
private readonly url: URLHelper
|
||||
private readonly url: URLHelper,
|
||||
@Optional() private readonly realtime?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
async cleanExpiredNotifications() {
|
||||
return await this.models.notification.cleanExpiredNotifications();
|
||||
const userIds =
|
||||
await this.models.notification.findExpiredNotificationUserIds();
|
||||
const count = await this.models.notification.cleanExpiredNotifications();
|
||||
if (count > 0) {
|
||||
await Promise.all(
|
||||
userIds.map(userId =>
|
||||
this.publishCountChanged(userId, 'expired-cleanup')
|
||||
)
|
||||
);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async createComment(input: CommentNotificationCreate, isMention?: boolean) {
|
||||
@@ -43,6 +56,7 @@ export class NotificationService {
|
||||
? await this.models.notification.createCommentMention(input)
|
||||
: await this.models.notification.createComment(input);
|
||||
await this.sendCommentEmail(input, isMention);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -93,6 +107,7 @@ export class NotificationService {
|
||||
async createMention(input: MentionNotificationCreate) {
|
||||
const notification = await this.models.notification.createMention(input);
|
||||
await this.sendMentionEmail(input);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -147,6 +162,7 @@ export class NotificationService {
|
||||
NotificationType.Invitation
|
||||
);
|
||||
await this.sendInvitationEmail(input);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -198,6 +214,7 @@ export class NotificationService {
|
||||
NotificationType.InvitationAccepted
|
||||
);
|
||||
await this.sendInvitationAcceptedEmail(input);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -240,18 +257,22 @@ export class NotificationService {
|
||||
|
||||
async createInvitationBlocked(input: InvitationNotificationCreate) {
|
||||
await this.ensureWorkspaceContentExists(input.body.workspaceId);
|
||||
return await this.models.notification.createInvitation(
|
||||
const notification = await this.models.notification.createInvitation(
|
||||
input,
|
||||
NotificationType.InvitationBlocked
|
||||
);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
async createInvitationRejected(input: InvitationNotificationCreate) {
|
||||
await this.ensureWorkspaceContentExists(input.body.workspaceId);
|
||||
return await this.models.notification.createInvitation(
|
||||
const notification = await this.models.notification.createInvitation(
|
||||
input,
|
||||
NotificationType.InvitationRejected
|
||||
);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
async createInvitationReviewRequest(input: InvitationNotificationCreate) {
|
||||
@@ -267,6 +288,7 @@ export class NotificationService {
|
||||
NotificationType.InvitationReviewRequest
|
||||
);
|
||||
await this.sendInvitationReviewRequestEmail(input);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -315,6 +337,7 @@ export class NotificationService {
|
||||
NotificationType.InvitationReviewApproved
|
||||
);
|
||||
await this.sendInvitationReviewApprovedEmail(input);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -354,6 +377,7 @@ export class NotificationService {
|
||||
const notification =
|
||||
await this.models.notification.createInvitationReviewDeclined(input);
|
||||
await this.sendInvitationReviewDeclinedEmail(input);
|
||||
await this.publishCountChanged(input.userId, 'created');
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -397,10 +421,12 @@ export class NotificationService {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.publishCountChanged(userId, 'read');
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string) {
|
||||
await this.models.notification.markAllAsRead(userId);
|
||||
await this.publishCountChanged(userId, 'read-all');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -463,6 +489,26 @@ export class NotificationService {
|
||||
return await this.models.notification.countByUserId(userId);
|
||||
}
|
||||
|
||||
private async publishCountChanged(
|
||||
userId: string,
|
||||
reason: 'created' | 'read' | 'read-all' | 'expired-cleanup'
|
||||
) {
|
||||
if (!this.realtime) return;
|
||||
try {
|
||||
this.realtime.publish(
|
||||
'notification.count.changed',
|
||||
{},
|
||||
{ count: await this.countByUserId(userId), reason },
|
||||
{ room: notificationCountRoom(userId) }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to publish notification count for user ${userId}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private formatWorkspaceInfo(workspace: Workspace) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CurrentUser } from '../../auth';
|
||||
import { RealtimeGateway } from '../gateway';
|
||||
import type { RealtimePublisher } from '../publisher';
|
||||
import { RealtimeRegistry } from '../registry';
|
||||
import { stableStringify } from '../stable-stringify';
|
||||
|
||||
const user: CurrentUser = {
|
||||
id: 'u1',
|
||||
email: 'u1@affine.pro',
|
||||
name: 'User',
|
||||
avatarUrl: null,
|
||||
disabled: false,
|
||||
hasPassword: true,
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
function createGateway(registry: RealtimeRegistry) {
|
||||
return new RealtimeGateway(registry, {
|
||||
attachServer() {},
|
||||
publishLocal() {},
|
||||
} as unknown as RealtimePublisher);
|
||||
}
|
||||
|
||||
test('registry rejects duplicate request and topic handlers', t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const request = {
|
||||
name: 'notification.count.get' as const,
|
||||
input: z.object({}).strict(),
|
||||
handle: async () => ({ count: 0 }),
|
||||
};
|
||||
const topic = {
|
||||
name: 'notification.count.changed' as const,
|
||||
input: z.object({}).strict(),
|
||||
authorize: async () => {},
|
||||
room: () => 'room',
|
||||
};
|
||||
|
||||
registry.registerRequest(request);
|
||||
registry.registerTopic(topic);
|
||||
|
||||
t.throws(() => registry.registerRequest(request), {
|
||||
message: /already registered/,
|
||||
});
|
||||
t.throws(() => registry.registerTopic(topic), {
|
||||
message: /already registered/,
|
||||
});
|
||||
});
|
||||
|
||||
test('gateway handles registered request with version gate', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
registry.registerRequest({
|
||||
name: 'notification.count.get',
|
||||
input: z.object({}).strict(),
|
||||
handle: async currentUser => ({ count: currentUser.id === 'u1' ? 1 : 0 }),
|
||||
});
|
||||
const gateway = createGateway(registry);
|
||||
|
||||
t.deepEqual(
|
||||
await gateway.onRequest(user, {
|
||||
op: 'notification.count.get',
|
||||
input: {},
|
||||
clientVersion: '0.26.0',
|
||||
}),
|
||||
{ data: { count: 1 } }
|
||||
);
|
||||
t.like(
|
||||
await gateway.onRequest(user, {
|
||||
op: 'notification.count.get',
|
||||
input: {},
|
||||
clientVersion: '0.25.0',
|
||||
}),
|
||||
{ error: { code: 'UNSUPPORTED_CLIENT_VERSION' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('gateway authorizes subscription and joins room', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
registry.registerTopic({
|
||||
name: 'comment.changed',
|
||||
input: z.object({ workspaceId: z.string(), docId: z.string() }),
|
||||
authorize: async (_currentUser, input) => {
|
||||
if (input.workspaceId !== 'space') {
|
||||
throw new Error('denied');
|
||||
}
|
||||
},
|
||||
room: (_currentUser, input) => `workspace:${input.workspaceId}`,
|
||||
});
|
||||
const gateway = createGateway(registry);
|
||||
const joined: string[] = [];
|
||||
const client = {
|
||||
id: 'socket-1',
|
||||
join: async (room: string) => {
|
||||
joined.push(room);
|
||||
},
|
||||
leave: async (room: string) => {
|
||||
joined.splice(joined.indexOf(room), 1);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await gateway.onSubscribe(user, client as never, {
|
||||
topic: 'comment.changed',
|
||||
input: { workspaceId: 'space', docId: 'doc' },
|
||||
clientVersion: '0.26.0',
|
||||
});
|
||||
|
||||
t.deepEqual(joined, ['workspace:space']);
|
||||
t.deepEqual(result, {
|
||||
data: {
|
||||
subscriptionId: `socket-1:comment.changed:${stableStringify({
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
})}`,
|
||||
},
|
||||
});
|
||||
|
||||
t.like(
|
||||
await gateway.onSubscribe(user, client as never, {
|
||||
topic: 'comment.changed',
|
||||
input: { workspaceId: 'other', docId: 'doc' },
|
||||
clientVersion: '0.26.0',
|
||||
}),
|
||||
{ error: { code: 'INTERNAL_SERVER_ERROR' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('stableStringify is deterministic for subscription input keys', t => {
|
||||
t.is(
|
||||
stableStringify({ docId: 'doc', workspaceId: 'space' }),
|
||||
stableStringify({ workspaceId: 'space', docId: 'doc' })
|
||||
);
|
||||
});
|
||||
|
||||
test('stableStringify follows JSON semantics for subscription input keys', t => {
|
||||
t.is(stableStringify({ after: undefined }), stableStringify({}));
|
||||
t.is(stableStringify([undefined]), '[null]');
|
||||
t.is(
|
||||
stableStringify(new Date('2026-01-02T03:04:05.000Z')),
|
||||
'"2026-01-02T03:04:05.000Z"'
|
||||
);
|
||||
});
|
||||
|
||||
test('gateway removes subscriptions on socket disconnect', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
registry.registerTopic({
|
||||
name: 'notification.count.changed',
|
||||
input: z.object({}).strict(),
|
||||
authorize: async () => {},
|
||||
room: () => 'user:u1:notification-count',
|
||||
});
|
||||
const gateway = createGateway(registry);
|
||||
const client = {
|
||||
id: 'socket-1',
|
||||
join: async () => {},
|
||||
leave: async () => {},
|
||||
};
|
||||
|
||||
await gateway.onSubscribe(user, client as never, {
|
||||
topic: 'notification.count.changed',
|
||||
input: {},
|
||||
clientVersion: '0.26.0',
|
||||
});
|
||||
t.is((gateway as any).subscriptions.size, 1);
|
||||
|
||||
gateway.handleDisconnect(client as never);
|
||||
|
||||
t.is((gateway as any).subscriptions.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
RealtimeRequestEnvelope,
|
||||
RealtimeSubscribeEnvelope,
|
||||
RealtimeUnsubscribeEnvelope,
|
||||
} from '@affine/realtime';
|
||||
import { applyDecorators, Logger, UseInterceptors } from '@nestjs/common';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
SubscribeMessage as RawSubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { ClsInterceptor } from 'nestjs-cls';
|
||||
import semver from 'semver';
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
|
||||
import {
|
||||
GatewayErrorWrapper,
|
||||
OnEvent,
|
||||
UnsupportedClientVersion,
|
||||
} from '../../base';
|
||||
import { CurrentUser } from '../auth';
|
||||
import { RealtimePublisher } from './publisher';
|
||||
import { RealtimeRegistry } from './registry';
|
||||
import { stableStringify } from './stable-stringify';
|
||||
import type { RealtimePublishPayload } from './types';
|
||||
|
||||
const SubscribeMessage = (event: string) =>
|
||||
applyDecorators(GatewayErrorWrapper(event), RawSubscribeMessage(event));
|
||||
|
||||
const MIN_REALTIME_CLIENT_VERSION = new semver.Range('>=0.26.0-0', {
|
||||
includePrerelease: true,
|
||||
});
|
||||
|
||||
@WebSocketGateway()
|
||||
@UseInterceptors(ClsInterceptor)
|
||||
export class RealtimeGateway implements OnGatewayInit, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(RealtimeGateway.name);
|
||||
private readonly subscriptions = new Map<
|
||||
string,
|
||||
{ socketId: string; room: string }
|
||||
>();
|
||||
|
||||
@WebSocketServer()
|
||||
private readonly server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly registry: RealtimeRegistry,
|
||||
private readonly publisher: RealtimePublisher
|
||||
) {}
|
||||
|
||||
afterInit(_server: Server) {
|
||||
this.publisher.attachServer(this.server);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
for (const [subscriptionId, subscription] of this.subscriptions) {
|
||||
if (subscription.socketId === client.id) {
|
||||
this.subscriptions.delete(subscriptionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('realtime:request')
|
||||
async onRequest(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody() envelope: RealtimeRequestEnvelope
|
||||
) {
|
||||
this.assertVersion(envelope.clientVersion);
|
||||
const handler = this.registry.getRequest(envelope.op);
|
||||
const input = handler.input.parse(envelope.input);
|
||||
return { data: await handler.handle(user, input as never) };
|
||||
}
|
||||
|
||||
@SubscribeMessage('realtime:subscribe')
|
||||
async onSubscribe(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() envelope: RealtimeSubscribeEnvelope
|
||||
) {
|
||||
this.assertVersion(envelope.clientVersion);
|
||||
const handler = this.registry.getTopic(envelope.topic);
|
||||
const input = handler.input.parse(envelope.input);
|
||||
await handler.authorize(user, input as never);
|
||||
const room = handler.room(user, input as never);
|
||||
await client.join(room);
|
||||
const subscriptionId = `${client.id}:${envelope.topic}:${stableStringify(input)}`;
|
||||
this.subscriptions.set(subscriptionId, {
|
||||
socketId: client.id,
|
||||
room,
|
||||
});
|
||||
return { data: { subscriptionId } };
|
||||
}
|
||||
|
||||
@SubscribeMessage('realtime:unsubscribe')
|
||||
async onUnsubscribe(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() envelope: RealtimeUnsubscribeEnvelope
|
||||
) {
|
||||
this.assertVersion(envelope.clientVersion);
|
||||
if (!envelope.subscriptionId) {
|
||||
return { data: { ok: true } };
|
||||
}
|
||||
const subscription = this.subscriptions.get(envelope.subscriptionId);
|
||||
if (subscription?.socketId === client.id) {
|
||||
await client.leave(subscription.room);
|
||||
this.subscriptions.delete(envelope.subscriptionId);
|
||||
}
|
||||
return { data: { ok: true } };
|
||||
}
|
||||
|
||||
@OnEvent('realtime.topic.changed', { suppressError: true })
|
||||
onRealtimeTopicChanged(payload: RealtimePublishPayload) {
|
||||
try {
|
||||
this.publisher.publishLocal(payload);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to publish realtime event', error);
|
||||
}
|
||||
}
|
||||
|
||||
private assertVersion(clientVersion?: string) {
|
||||
if (
|
||||
!clientVersion ||
|
||||
!semver.valid(clientVersion) ||
|
||||
!MIN_REALTIME_CLIENT_VERSION.test(clientVersion)
|
||||
) {
|
||||
throw new UnsupportedClientVersion({
|
||||
clientVersion: clientVersion ?? 'unset_or_invalid',
|
||||
requiredVersion: '>=0.26.0',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { RealtimeGateway } from './gateway';
|
||||
import { RealtimePublisher } from './publisher';
|
||||
import { RealtimeRegistry } from './registry';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RealtimeRegistry, RealtimePublisher, RealtimeGateway],
|
||||
exports: [RealtimeRegistry, RealtimePublisher],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
|
||||
export { RealtimePublisher } from './publisher';
|
||||
export { RealtimeRegistry } from './registry';
|
||||
export type { RealtimeRequestHandler, RealtimeTopicHandler } from './types';
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { RealtimeEvent, RealtimeTopicName } from '@affine/realtime';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Server } from 'socket.io';
|
||||
|
||||
import { EventBus } from '../../base';
|
||||
import { RealtimeRegistry } from './registry';
|
||||
import { stableStringify } from './stable-stringify';
|
||||
import type { RealtimePublishPayload } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class RealtimePublisher {
|
||||
private readonly logger = new Logger(RealtimePublisher.name);
|
||||
private server?: Server;
|
||||
|
||||
constructor(
|
||||
private readonly registry: RealtimeRegistry,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
attachServer(server: Server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
publish<Topic extends RealtimeTopicName>(
|
||||
topic: Topic,
|
||||
input: RealtimePublishPayload<Topic>['input'],
|
||||
event: RealtimePublishPayload<Topic>['event'],
|
||||
options?: { room?: string }
|
||||
) {
|
||||
const payload = {
|
||||
topic,
|
||||
input,
|
||||
event,
|
||||
room: options?.room,
|
||||
} as RealtimePublishPayload<Topic>;
|
||||
try {
|
||||
this.publishLocal(payload);
|
||||
this.event.broadcast('realtime.topic.changed', payload);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to publish realtime topic ${topic}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
publishLocal(payload: RealtimePublishPayload) {
|
||||
const handler = this.registry.getTopic(payload.topic);
|
||||
const room = payload.room ?? handler.room(null, payload.input as never);
|
||||
const envelope: RealtimeEvent = {
|
||||
topic: payload.topic,
|
||||
inputKey: stableStringify(payload.input),
|
||||
sentAt: Date.now(),
|
||||
event: payload.event as never,
|
||||
};
|
||||
this.server?.to(room).emit('realtime:event', envelope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { RealtimeRequestName, RealtimeTopicName } from '@affine/realtime';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { RealtimeRequestHandler, RealtimeTopicHandler } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class RealtimeRegistry {
|
||||
private readonly requests = new Map<
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestHandler<RealtimeRequestName>
|
||||
>();
|
||||
private readonly topics = new Map<
|
||||
RealtimeTopicName,
|
||||
RealtimeTopicHandler<RealtimeTopicName>
|
||||
>();
|
||||
|
||||
registerRequest<Op extends RealtimeRequestName>(
|
||||
handler: RealtimeRequestHandler<Op>
|
||||
) {
|
||||
if (this.requests.has(handler.name)) {
|
||||
throw new Error(
|
||||
`Realtime request handler already registered: ${handler.name}`
|
||||
);
|
||||
}
|
||||
this.requests.set(
|
||||
handler.name,
|
||||
handler as RealtimeRequestHandler<RealtimeRequestName>
|
||||
);
|
||||
}
|
||||
|
||||
registerTopic<Topic extends RealtimeTopicName>(
|
||||
handler: RealtimeTopicHandler<Topic>
|
||||
) {
|
||||
if (this.topics.has(handler.name)) {
|
||||
throw new Error(
|
||||
`Realtime topic handler already registered: ${handler.name}`
|
||||
);
|
||||
}
|
||||
this.topics.set(
|
||||
handler.name,
|
||||
handler as RealtimeTopicHandler<RealtimeTopicName>
|
||||
);
|
||||
}
|
||||
|
||||
getRequest(name: RealtimeRequestName) {
|
||||
const handler = this.requests.get(name);
|
||||
if (!handler) {
|
||||
throw new Error(`Realtime request handler not found: ${name}`);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
getTopic(name: RealtimeTopicName) {
|
||||
const handler = this.topics.get(name);
|
||||
if (!handler) {
|
||||
throw new Error(`Realtime topic handler not found: ${name}`);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function stableStringify(value: unknown): string {
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return 'null';
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(',')}]`;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return JSON.stringify(value.toJSON());
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.filter(key => {
|
||||
const property = record[key];
|
||||
return (
|
||||
property !== undefined &&
|
||||
typeof property !== 'function' &&
|
||||
typeof property !== 'symbol'
|
||||
);
|
||||
})
|
||||
.sort()
|
||||
.map(key => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
RealtimeTopicEventOf,
|
||||
RealtimeTopicInputOf,
|
||||
RealtimeTopicName,
|
||||
} from '@affine/realtime';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { CurrentUser } from '../auth';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'realtime.topic.changed': RealtimePublishPayload;
|
||||
}
|
||||
}
|
||||
|
||||
export type RealtimeRequestHandler<Op extends RealtimeRequestName> = {
|
||||
name: Op;
|
||||
input: z.ZodType<RealtimeRequestInputOf<Op>>;
|
||||
handle(
|
||||
user: CurrentUser,
|
||||
input: RealtimeRequestInputOf<Op>
|
||||
): Promise<RealtimeRequestOutputOf<Op>>;
|
||||
};
|
||||
|
||||
export type RealtimeTopicHandler<Topic extends RealtimeTopicName> = {
|
||||
name: Topic;
|
||||
input: z.ZodType<RealtimeTopicInputOf<Topic>>;
|
||||
authorize(
|
||||
user: CurrentUser,
|
||||
input: RealtimeTopicInputOf<Topic>
|
||||
): Promise<void>;
|
||||
room(user: CurrentUser | null, input: RealtimeTopicInputOf<Topic>): string;
|
||||
};
|
||||
|
||||
export type RealtimePublishPayload<
|
||||
Topic extends RealtimeTopicName = RealtimeTopicName,
|
||||
> = {
|
||||
topic: Topic;
|
||||
input: RealtimeTopicInputOf<Topic>;
|
||||
event: RealtimeTopicEventOf<Topic>;
|
||||
room?: string;
|
||||
};
|
||||
@@ -311,6 +311,15 @@ export class NotificationModel extends BaseModel {
|
||||
return row as UnionNotification;
|
||||
}
|
||||
|
||||
async findExpiredNotificationUserIds() {
|
||||
const rows = await this.db.notification.findMany({
|
||||
distinct: ['userId'],
|
||||
select: { userId: true },
|
||||
where: { createdAt: { lte: new Date(Date.now() - ONE_YEAR) } },
|
||||
});
|
||||
return rows.map(row => row.userId);
|
||||
}
|
||||
|
||||
async cleanExpiredNotifications() {
|
||||
const { count } = await this.db.notification.deleteMany({
|
||||
// delete notifications that are older than one year
|
||||
|
||||
@@ -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) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +519,7 @@ type Copilot {
|
||||
|
||||
"""Get the session list in the workspace"""
|
||||
sessions(docId: String, options: QueryChatSessionsInput): [CopilotSessionType!]! @deprecated(reason: "use `chats` instead")
|
||||
transcriptTask(blobId: String, taskId: String): TranscriptionResultType
|
||||
transcriptTask(blobId: String, taskId: String): TranscriptionResultType @deprecated(reason: "Use realtime subscription \"copilot.transcript.task.changed\" instead.")
|
||||
workspaceId: ID
|
||||
}
|
||||
|
||||
@@ -1975,7 +1975,7 @@ type Query {
|
||||
publicUserById(id: String!): PublicUserType
|
||||
|
||||
"""query workspace embedding status"""
|
||||
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus!
|
||||
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus! @deprecated(reason: "Use realtime subscription \"workspace.embedding.progress.changed\" instead.")
|
||||
revealedAccessTokens: [RevealedAccessToken!]! @deprecated(reason: "use currentUser.revealedAccessTokens")
|
||||
|
||||
"""server config"""
|
||||
@@ -2668,7 +2668,7 @@ type UserType {
|
||||
name: String!
|
||||
|
||||
"""Get user notification count"""
|
||||
notificationCount: Int!
|
||||
notificationCount: Int! @deprecated(reason: "Use realtime subscription \"notification.count.changed\" instead.")
|
||||
|
||||
"""Get current user notifications"""
|
||||
notifications(pagination: PaginationInput!): PaginatedNotificationObjectType!
|
||||
|
||||
Reference in New Issue
Block a user