import { createHash } from 'node:crypto'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Args, Field, Float, ID, InputType, Mutation, ObjectType, Parent, Query, registerEnumType, ResolveField, Resolver, } from '@nestjs/graphql'; import { AiPromptRole } from '@prisma/client'; import { GraphQLJSON, SafeIntResolver } from 'graphql-scalars'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { CallMetric, CopilotDocNotFound, CopilotFailedToCreateMessage, CopilotProviderSideError, CopilotSessionNotFound, type FileUpload, paginate, Paginated, PaginationInput, RequestMutex, Throttle, TooManyRequest, UserFriendlyError, } from '../../base'; import { CurrentUser } from '../../core/auth'; import { Admin } from '../../core/common'; import { DocReader } from '../../core/doc'; import { AccessController } from '../../core/permission'; import { UserType } from '../../core/user'; import type { ListSessionOptions, UpdateChatSession } from '../../models'; import { CopilotCronJobs } from './cron'; import { PromptService } from './prompt'; import { PromptMessage, StreamObject } from './providers'; import { CopilotProviderFactory } from './providers/factory'; import { ChatSessionService } from './session'; import { CopilotStorage } from './storage'; import { type ChatHistory, type ChatMessage, SubmittedMessage } from './types'; export const COPILOT_LOCKER = 'copilot'; // ================== Input Types ================== @InputType() class CreateChatSessionInput { @Field(() => String) workspaceId!: string; @Field(() => String, { nullable: true }) docId?: string; @Field(() => String, { description: 'The prompt name to use for the session', }) promptName!: string; @Field(() => Boolean, { nullable: true }) pinned?: boolean; @Field(() => Boolean, { nullable: true, description: 'true by default, compliant for old version', }) reuseLatestChat?: boolean; } @InputType() class UpdateChatSessionInput implements Omit { @Field(() => String) sessionId!: string; @Field(() => String, { description: 'The workspace id of the session', nullable: true, }) docId!: string | null | undefined; @Field(() => Boolean, { description: 'Whether to pin the session', nullable: true, }) pinned!: boolean | undefined; @Field(() => String, { description: 'The prompt name to use for the session', nullable: true, }) promptName!: string; } @InputType() class ForkChatSessionInput { @Field(() => String) workspaceId!: string; @Field(() => String) docId!: string; @Field(() => String) sessionId!: string; @Field(() => String, { description: 'Identify a message in the array and keep it with all previous messages into a forked session.', nullable: true, }) latestMessageId?: string; } @InputType() class DeleteSessionInput { @Field(() => String) workspaceId!: string; @Field(() => String) docId!: string; @Field(() => [String]) sessionIds!: string[]; } @InputType() class CreateChatMessageInput implements Omit { @Field(() => String) sessionId!: string; @Field(() => String, { nullable: true }) content!: string | undefined; @Field(() => [String], { nullable: true, deprecationReason: 'use blobs' }) attachments!: string[] | undefined; @Field(() => [GraphQLUpload], { nullable: true }) blobs!: Promise[] | undefined; @Field(() => GraphQLJSON, { nullable: true }) params!: Record | undefined; } enum ChatHistoryOrder { asc = 'asc', desc = 'desc', } registerEnumType(ChatHistoryOrder, { name: 'ChatHistoryOrder' }); @InputType() class QueryChatSessionsInput implements Partial { @Field(() => Boolean, { nullable: true }) action: boolean | undefined; @Field(() => Boolean, { nullable: true }) fork: boolean | undefined; @Field(() => Boolean, { nullable: true }) pinned: boolean | undefined; @Field(() => Number, { nullable: true }) limit: number | undefined; @Field(() => Number, { nullable: true }) skip: number | undefined; } @InputType() class QueryChatHistoriesInput extends QueryChatSessionsInput implements Partial { @Field(() => ChatHistoryOrder, { nullable: true }) messageOrder: 'asc' | 'desc' | undefined; @Field(() => ChatHistoryOrder, { nullable: true }) sessionOrder: 'asc' | 'desc' | undefined; @Field(() => String, { nullable: true }) sessionId: string | undefined; @Field(() => Boolean, { nullable: true }) withMessages: boolean | undefined; @Field(() => Boolean, { nullable: true }) withPrompt: boolean | undefined; } // ================== Return Types ================== @ObjectType('StreamObject') class StreamObjectType { @Field(() => String) type!: string; @Field(() => String, { nullable: true }) textDelta?: string; @Field(() => String, { nullable: true }) toolCallId?: string; @Field(() => String, { nullable: true }) toolName?: string; @Field(() => GraphQLJSON, { nullable: true }) args?: any; @Field(() => GraphQLJSON, { nullable: true }) result?: any; } @ObjectType('ChatMessage') class ChatMessageType implements Partial { // id will be null if message is a prompt message @Field(() => ID, { nullable: true }) id!: string | undefined; @Field(() => String) role!: 'system' | 'assistant' | 'user'; @Field(() => String) content!: string; @Field(() => [StreamObjectType], { nullable: true }) streamObjects!: StreamObject[]; @Field(() => [String], { nullable: true }) attachments!: string[]; @Field(() => GraphQLJSON, { nullable: true }) params!: Record | undefined; @Field(() => Date) createdAt!: Date; } @ObjectType('CopilotHistories') class CopilotHistoriesType implements Omit { @Field(() => String) sessionId!: string; @Field(() => String) workspaceId!: string; @Field(() => String, { nullable: true }) docId!: string | null; @Field(() => String, { nullable: true }) parentSessionId!: string | null; @Field(() => String) promptName!: string; @Field(() => String) model!: string; @Field(() => [String]) optionalModels!: string[]; @Field(() => String, { description: 'An mark identifying which view to use to display the session', nullable: true, }) action!: string | null; @Field(() => Boolean) pinned!: boolean; @Field(() => String, { nullable: true }) title!: string | null; @Field(() => Number, { description: 'The number of tokens used in the session', }) tokens!: number; @Field(() => [ChatMessageType]) messages!: ChatMessageType[]; @Field(() => Date) createdAt!: Date; @Field(() => Date) updatedAt!: Date; } @ObjectType() export class PaginatedCopilotHistoriesType extends Paginated( CopilotHistoriesType ) {} @ObjectType('CopilotQuota') class CopilotQuotaType { @Field(() => SafeIntResolver, { nullable: true }) limit?: number; @Field(() => SafeIntResolver) used!: number; } registerEnumType(AiPromptRole, { name: 'CopilotPromptMessageRole', }); @InputType('CopilotPromptConfigInput') @ObjectType() class CopilotPromptConfigType { @Field(() => Float, { nullable: true }) frequencyPenalty!: number | null; @Field(() => Float, { nullable: true }) presencePenalty!: number | null; @Field(() => Float, { nullable: true }) temperature!: number | null; @Field(() => Float, { nullable: true }) topP!: number | null; } @InputType('CopilotPromptMessageInput') @ObjectType() class CopilotPromptMessageType { @Field(() => AiPromptRole) role!: AiPromptRole; @Field(() => String) content!: string; @Field(() => GraphQLJSON, { nullable: true }) params!: Record | null; } @ObjectType() class CopilotPromptType { @Field(() => String) name!: string; @Field(() => String) model!: string; @Field(() => String, { nullable: true }) action!: string | null; @Field(() => CopilotPromptConfigType, { nullable: true }) config!: CopilotPromptConfigType | null; @Field(() => [CopilotPromptMessageType]) messages!: CopilotPromptMessageType[]; } @ObjectType() export class CopilotSessionType { @Field(() => ID) id!: string; @Field(() => String, { nullable: true }) docId!: string | null; @Field(() => Boolean) pinned!: boolean; @Field(() => String, { nullable: true }) title!: string | null; @Field(() => ID, { nullable: true }) parentSessionId!: string | null; @Field(() => String) promptName!: string; @Field(() => String) model!: string; @Field(() => [String]) optionalModels!: string[]; } // ================== Resolver ================== @ObjectType('Copilot') export class CopilotType { @Field(() => ID, { nullable: true }) workspaceId!: string | null; } @Throttle() @Resolver(() => CopilotType) export class CopilotResolver { constructor( private readonly ac: AccessController, private readonly mutex: RequestMutex, private readonly chatSession: ChatSessionService, private readonly storage: CopilotStorage, private readonly docReader: DocReader, private readonly providerFactory: CopilotProviderFactory ) {} @ResolveField(() => CopilotQuotaType, { name: 'quota', description: 'Get the quota of the user in the workspace', complexity: 2, }) async getQuota(@CurrentUser() user: CurrentUser): Promise { return await this.chatSession.getQuota(user.id); } private async assertPermission( user: CurrentUser, options: { workspaceId?: string | null; docId?: string | null } ) { const { workspaceId, docId } = options; if (!workspaceId) { throw new NotFoundException('Workspace not found'); } if (docId) { await this.ac .user(user.id) .doc({ workspaceId, docId }) .allowLocal() .assert('Doc.Update'); } else { await this.ac .user(user.id) .workspace(workspaceId) .allowLocal() .assert('Workspace.Copilot'); } return { userId: user.id, workspaceId, docId: docId || undefined }; } @ResolveField(() => CopilotSessionType, { description: 'Get the session by id', complexity: 2, }) async session( @Parent() copilot: CopilotType, @CurrentUser() user: CurrentUser, @Args('sessionId') sessionId: string ): Promise { await this.assertPermission(user, copilot); const session = await this.chatSession.getSessionInfo(sessionId); if (!session) { throw new NotFoundException('Session not found'); } return this.transformToSessionType(session); } @ResolveField(() => [CopilotSessionType], { description: 'Get the session list in the workspace', deprecationReason: 'use `chats` instead', complexity: 2, }) async sessions( @Parent() copilot: CopilotType, @CurrentUser() user: CurrentUser, @Args('docId', { nullable: true }) maybeDocId?: string, @Args('options', { nullable: true }) options?: QueryChatSessionsInput ): Promise { if (!copilot.workspaceId) { return []; } const appendOptions = await this.assertPermission( user, Object.assign({}, copilot, { docId: maybeDocId }) ); const sessions = await this.chatSession.list( Object.assign({}, options, appendOptions), false ); if (appendOptions.docId) { type Session = ChatHistory & { docId: string }; const filtered = sessions.filter((s): s is Session => !!s.docId); const accessible = await this.ac .user(user.id) .workspace(copilot.workspaceId) .docs(filtered, 'Doc.Update'); return accessible.map(this.transformToSessionType); } else { return sessions.map(this.transformToSessionType); } } @ResolveField(() => [CopilotHistoriesType], { deprecationReason: 'use `chats` instead', }) @CallMetric('ai', 'histories') async histories( @Parent() copilot: CopilotType, @CurrentUser() user: CurrentUser, @Args('docId', { nullable: true }) docId?: string, @Args('options', { nullable: true }) options?: QueryChatHistoriesInput ): Promise { const workspaceId = copilot.workspaceId; if (!workspaceId) { return []; } else { await this.assertPermission(user, { workspaceId, docId }); } const histories = await this.chatSession.list( Object.assign({}, options, { userId: user.id, workspaceId, docId }), true ); return histories.map(h => ({ ...h, // filter out empty messages messages: h.messages.filter( m => m.content || m.attachments?.length ) as ChatMessageType[], })); } @ResolveField(() => PaginatedCopilotHistoriesType, {}) @CallMetric('ai', 'histories') async chats( @Parent() copilot: CopilotType, @CurrentUser() user: CurrentUser, @Args('pagination', PaginationInput.decode) pagination: PaginationInput, @Args('docId', { nullable: true }) docId?: string, @Args('options', { nullable: true }) options?: QueryChatHistoriesInput ): Promise { const workspaceId = copilot.workspaceId; if (!workspaceId) { return paginate([], 'updatedAt', pagination, 0); } else { await this.assertPermission(user, { workspaceId, docId }); } const finalOptions = Object.assign( {}, options, { userId: user.id, workspaceId, docId }, { skip: pagination.offset, limit: pagination.first } ); const totalCount = await this.chatSession.count(finalOptions); const histories = await this.chatSession.list( finalOptions, !!options?.withMessages ); return paginate( histories.map(h => ({ ...h, // filter out empty messages messages: h.messages?.filter( m => m.content || m.attachments?.length ) as ChatMessageType[], })), 'updatedAt', pagination, totalCount ); } @Mutation(() => String, { description: 'Create a chat session', }) @CallMetric('ai', 'chat_session_create') async createCopilotSession( @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => CreateChatSessionInput }) options: CreateChatSessionInput ): Promise { // permission check based on session type await this.assertPermission(user, options); const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { throw new TooManyRequest('Server is busy'); } await this.chatSession.checkQuota(user.id); return await this.chatSession.create({ ...options, pinned: options.pinned ?? false, docId: options.docId ?? null, userId: user.id, }); } @Mutation(() => String, { description: 'Update a chat session', }) @CallMetric('ai', 'chat_session_update') async updateCopilotSession( @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => UpdateChatSessionInput }) options: UpdateChatSessionInput ): Promise { const session = await this.chatSession.get(options.sessionId); if (!session) { throw new CopilotSessionNotFound(); } const config = await this.assertPermission(user, session.config); const { workspaceId, docId: currentDocId } = config; const { docId: newDocId } = options; // check permission if the docId is changed if (newDocId !== undefined && newDocId !== currentDocId) { await this.assertPermission(user, { workspaceId, docId: newDocId }); } const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { throw new TooManyRequest('Server is busy'); } await this.chatSession.checkQuota(user.id); return await this.chatSession.update({ ...options, userId: user.id, }); } @Mutation(() => String, { description: 'Create a chat session', }) @CallMetric('ai', 'chat_session_fork') async forkCopilotSession( @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => ForkChatSessionInput }) options: ForkChatSessionInput ): Promise { await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update'); const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { throw new TooManyRequest('Server is busy'); } if (options.workspaceId === options.docId) { // filter out session create request for root doc throw new CopilotDocNotFound({ docId: options.docId }); } await this.chatSession.checkQuota(user.id); return await this.chatSession.fork({ ...options, userId: user.id, }); } @Mutation(() => [String], { description: 'Cleanup sessions', }) @CallMetric('ai', 'chat_session_cleanup') async cleanupCopilotSession( @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => DeleteSessionInput }) options: DeleteSessionInput ): Promise { await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update'); if (!options.sessionIds.length) { throw new NotFoundException('Session not found'); } const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { throw new TooManyRequest('Server is busy'); } return await this.chatSession.cleanup({ ...options, userId: user.id, }); } @Mutation(() => String, { description: 'Create a chat message', }) @CallMetric('ai', 'chat_message_create') async createCopilotMessage( @CurrentUser() user: CurrentUser, @Args({ name: 'options', type: () => CreateChatMessageInput }) options: CreateChatMessageInput ): Promise { const lockFlag = `${COPILOT_LOCKER}:message:${user?.id}:${options.sessionId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { throw new TooManyRequest('Server is busy'); } const session = await this.chatSession.get(options.sessionId); if (!session || session.config.userId !== user.id) { throw new BadRequestException('Session not found'); } const attachments: PromptMessage['attachments'] = options.attachments || []; if (options.blobs) { const { workspaceId } = session.config; const blobs = await Promise.all(options.blobs); delete options.blobs; for (const blob of blobs) { const uploaded = await this.storage.handleUpload(user.id, blob); const filename = createHash('sha256') .update(uploaded.buffer) .digest('base64url'); const attachment = await this.storage.put( user.id, workspaceId, filename, uploaded.buffer ); attachments.push({ attachment, mimeType: blob.mimetype }); } } try { return await this.chatSession.createMessage({ ...options, attachments }); } catch (e: any) { throw new CopilotFailedToCreateMessage(e.message); } } @Query(() => String, { description: 'Apply updates to a doc using LLM and return the merged markdown.', }) async applyDocUpdates( @CurrentUser() user: CurrentUser, @Args({ name: 'workspaceId', type: () => String }) workspaceId: string, @Args({ name: 'docId', type: () => String }) docId: string, @Args({ name: 'op', type: () => String }) op: string, @Args({ name: 'updates', type: () => String }) updates: string ): Promise { await this.assertPermission(user, { workspaceId, docId }); const docContent = await this.docReader.getDocMarkdown( workspaceId, docId, true ); if (!docContent || !docContent.markdown) { throw new NotFoundException('Doc not found or empty'); } const markdown = docContent.markdown.trim(); // Get LLM provider const provider = await this.providerFactory.getProviderByModel('morph-v3-large'); if (!provider) { throw new BadRequestException('No LLM provider available'); } try { return await provider.text( { modelId: 'morph-v3-large' }, [ { role: 'user', content: `${op}\n${markdown}\n${updates}`, }, ], { reasoning: false } ); } catch (e: any) { if (e instanceof UserFriendlyError) { throw e; } else { throw new CopilotProviderSideError({ provider: provider.type, kind: 'unexpected_response', message: e?.message || 'Unexpected apply response', }); } } } private transformToSessionType( session: Omit ): CopilotSessionType { return { id: session.sessionId, ...session }; } } @Throttle() @Resolver(() => UserType) export class UserCopilotResolver { constructor(private readonly ac: AccessController) {} @ResolveField(() => CopilotType) async copilot( @CurrentUser() user: CurrentUser, @Args('workspaceId', { nullable: true }) workspaceId?: string ): Promise { if (workspaceId) { await this.ac .user(user.id) .workspace(workspaceId) .allowLocal() .assert('Workspace.Copilot'); } return { workspaceId: workspaceId || null }; } } @InputType() class CreateCopilotPromptInput { @Field(() => String) name!: string; @Field(() => String) model!: string; @Field(() => String, { nullable: true }) action!: string | null; @Field(() => CopilotPromptConfigType, { nullable: true }) config!: CopilotPromptConfigType | null; @Field(() => [CopilotPromptMessageType]) messages!: CopilotPromptMessageType[]; } @Admin() @Resolver(() => String) export class PromptsManagementResolver { constructor( private readonly cron: CopilotCronJobs, private readonly promptService: PromptService ) {} @Mutation(() => Boolean, { description: 'Trigger generate missing titles cron job', }) async triggerGenerateTitleCron() { await this.cron.triggerGenerateMissingTitles(); return true; } @Mutation(() => Boolean, { description: 'Trigger cleanup of trashed doc embeddings', }) async triggerCleanupTrashedDocEmbeddings() { await this.cron.triggerCleanupTrashedDocEmbeddings(); return true; } @Query(() => [CopilotPromptType], { description: 'List all copilot prompts', }) async listCopilotPrompts() { const prompts = await this.promptService.list(); return prompts.filter( p => p.messages.length > 0 && // ignore internal prompts !p.name.startsWith('workflow:') && !p.name.startsWith('debug:') && !p.name.startsWith('chat:') && !p.name.startsWith('action:') ); } @Mutation(() => CopilotPromptType, { description: 'Create a copilot prompt', }) async createCopilotPrompt( @Args({ type: () => CreateCopilotPromptInput, name: 'input' }) input: CreateCopilotPromptInput ) { await this.promptService.set( input.name, input.model, input.messages, input.config ); return this.promptService.get(input.name); } @Mutation(() => CopilotPromptType, { description: 'Update a copilot prompt', }) async updateCopilotPrompt( @Args('name') name: string, @Args('messages', { type: () => [CopilotPromptMessageType] }) messages: CopilotPromptMessageType[] ) { await this.promptService.update(name, messages, true); return this.promptService.get(name); } }