import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; import { AiSessionMessageRole, Prisma } from '@prisma/client'; import { omit } from 'lodash-es'; import { CopilotPromptInvalid, CopilotSessionDeleted, CopilotSessionInvalidInput, 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, } from '../plugins/copilot/types'; import { BaseModel } from './base'; export enum SessionType { Workspace = 'workspace', // docId is null and pinned is false Pinned = 'pinned', // pinned is true Doc = 'doc', // docId points to specific document } type ChatPrompt = { name: string; action?: string | null; }; type ChatAttachment = PromptAttachment; type ChatStreamObject = { type: 'text-delta' | 'reasoning' | 'tool-call' | 'tool-result'; textDelta?: string; toolCallId?: string; toolName?: string; args?: Record; result?: any; rawArgumentsText?: string; argumentParseError?: string; thought?: string; }; type ChatMessage = { id?: string | undefined; compatSubmissionId?: string | null; role: 'system' | 'assistant' | 'user'; content: string; attachments?: ChatAttachment[] | null; params?: Record | null; scopeSnapshot?: TurnScopeSnapshot | null; streamObjects?: ChatStreamObject[] | null; createdAt: Date; }; type StoredChatMessage = Prisma.AiSessionMessageGetPayload<{ select: { id: true; compatSubmissionId: true; role: true; content: true; attachments: true; streamObjects: true; params: true; scopeSnapshot: true; createdAt: true; }; }>; type PureChatSession = { sessionId: string; workspaceId: string; docId?: string | null; pinned?: boolean; title: string | null; messages?: ChatMessage[]; // connect ids userId: string; parentSessionId?: string | null; }; type ChatSession = PureChatSession & { // connect ids promptName: string; promptAction: string | null; }; type ChatSessionWithPrompt = PureChatSession & { prompt: ChatPrompt; }; type ChatSessionBaseState = Pick; export type ForkSessionOptions = Omit< ChatSession, 'messages' | 'promptName' | 'promptAction' > & { prompt: { name: string; action: string | null | undefined }; messages: ChatMessage[]; }; type UpdateChatSessionMessage = ChatSessionBaseState & { messages: ChatMessage[]; }; export type UpdateChatSessionOptions = ChatSessionBaseState & Pick< Partial, 'docId' | 'pinned' | 'promptName' | 'promptAction' | 'title' >; export type UpdateChatSession = ChatSessionBaseState & UpdateChatSessionOptions; export type ListSessionOptions = Pick< Partial, 'sessionId' | 'workspaceId' | 'docId' | 'pinned' > & { userId: string | undefined; action?: boolean; fork?: boolean; limit?: number; skip?: number; sessionOrder?: 'asc' | 'desc'; messageOrder?: 'asc' | 'desc'; // extra condition withPrompt?: boolean; withMessages?: boolean; }; export type CleanupSessionOptions = Pick< ChatSession, 'userId' | 'workspaceId' | 'docId' > & { sessionIds: string[]; }; @Injectable() export class CopilotSessionModel extends BaseModel { private noActionPromptCondition(): Prisma.AiSessionWhereInput { return { OR: [{ promptAction: null }, { promptAction: '' }], }; } private sanitizeString(value: T): T { if (typeof value !== 'string') { return value; } return value.replaceAll('\0', '') as T; } private sanitizeJsonValue(value: T): T { if (typeof value === 'string') { return this.sanitizeString(value) as T; } if (Array.isArray(value)) { return value.map(v => this.sanitizeJsonValue(v)) as T; } if ( value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype ) { return Object.fromEntries( Object.entries(value).map(([k, v]) => [k, this.sanitizeJsonValue(v)]) ) as T; } return value; } private sanitizeStreamObject(stream: ChatStreamObject): ChatStreamObject { switch (stream.type) { case 'text-delta': case 'reasoning': return { ...stream, textDelta: this.sanitizeString(stream.textDelta), }; case 'tool-call': return { ...stream, toolCallId: this.sanitizeString(stream.toolCallId) ?? '', toolName: this.sanitizeString(stream.toolName) ?? '', args: this.sanitizeJsonValue(stream.args), rawArgumentsText: this.sanitizeString(stream.rawArgumentsText), argumentParseError: this.sanitizeString(stream.argumentParseError), thought: this.sanitizeString(stream.thought), }; case 'tool-result': return { ...stream, toolCallId: this.sanitizeString(stream.toolCallId) ?? '', toolName: this.sanitizeString(stream.toolName) ?? '', args: this.sanitizeJsonValue(stream.args), result: this.sanitizeJsonValue(stream.result), rawArgumentsText: this.sanitizeString(stream.rawArgumentsText), argumentParseError: this.sanitizeString(stream.argumentParseError), }; } } private sanitizeAttachments( attachments?: ChatAttachment[] | null ): ChatAttachment[] | undefined { if (!attachments?.length) { return undefined; } return attachments .map(attachment => { if (typeof attachment === 'string') { return this.sanitizeString(attachment) ?? ''; } if ('attachment' in attachment) { return { attachment: this.sanitizeString(attachment.attachment) ?? attachment.attachment, mimeType: this.sanitizeString(attachment.mimeType) ?? attachment.mimeType, }; } switch (attachment.kind) { case 'url': return { ...attachment, url: this.sanitizeString(attachment.url) ?? attachment.url, mimeType: this.sanitizeString(attachment.mimeType) ?? attachment.mimeType, fileName: this.sanitizeString(attachment.fileName) ?? attachment.fileName, providerHint: attachment.providerHint ? { provider: this.sanitizeString(attachment.providerHint.provider) ?? attachment.providerHint.provider, kind: this.sanitizeString(attachment.providerHint.kind) ?? attachment.providerHint.kind, } : undefined, }; case 'data': case 'bytes': return { ...attachment, data: this.sanitizeString(attachment.data) ?? attachment.data, mimeType: this.sanitizeString(attachment.mimeType) ?? attachment.mimeType, fileName: this.sanitizeString(attachment.fileName) ?? attachment.fileName, providerHint: attachment.providerHint ? { provider: this.sanitizeString(attachment.providerHint.provider) ?? attachment.providerHint.provider, kind: this.sanitizeString(attachment.providerHint.kind) ?? attachment.providerHint.kind, } : undefined, }; case 'file_handle': return { ...attachment, fileHandle: this.sanitizeString(attachment.fileHandle) ?? attachment.fileHandle, mimeType: this.sanitizeString(attachment.mimeType) ?? attachment.mimeType, fileName: this.sanitizeString(attachment.fileName) ?? attachment.fileName, providerHint: attachment.providerHint ? { provider: this.sanitizeString(attachment.providerHint.provider) ?? attachment.providerHint.provider, kind: this.sanitizeString(attachment.providerHint.kind) ?? attachment.providerHint.kind, } : undefined, }; } return attachment; }) .filter(attachment => { if (typeof attachment === 'string') { return !!attachment; } if ('attachment' in attachment) { return !!attachment.attachment && !!attachment.mimeType; } switch (attachment.kind) { case 'url': return !!attachment.url; case 'data': case 'bytes': return !!attachment.data && !!attachment.mimeType; case 'file_handle': return !!attachment.fileHandle; } return false; }); } private sanitizeMessage(message: ChatMessage): ChatMessage { return { ...message, compatSubmissionId: this.sanitizeString(message.compatSubmissionId), content: this.sanitizeString(message.content) ?? '', attachments: this.sanitizeAttachments(message.attachments), params: this.sanitizeJsonValue( omit(message.params, ['docs']) || undefined ), scopeSnapshot: this.sanitizeJsonValue(message.scopeSnapshot), streamObjects: message.streamObjects?.map(o => this.sanitizeStreamObject(o) ), }; } private toPublicMessage(message: StoredChatMessage): CopilotChatMessage { const { compatSubmissionId: _compatSubmissionId, ...publicMessage } = message; return ChatMessageSchema.parse({ ...publicMessage, attachments: publicMessage.attachments ?? undefined, streamObjects: publicMessage.streamObjects ?? undefined, params: publicMessage.params ?? undefined, }); } private isCountedUserMessage( message: Pick ): boolean { return message.role === AiSessionMessageRole.user; } getSessionType(session: Pick): SessionType { if (session.pinned) return SessionType.Pinned; if (!session.docId) return SessionType.Workspace; return SessionType.Doc; } checkSessionPrompt( session: Pick, prompt: Partial ): boolean { const sessionType = this.getSessionType(session); const { name: promptName, action: promptAction } = prompt; // workspace and pinned sessions cannot use action prompts if ( [SessionType.Workspace, SessionType.Pinned].includes(sessionType) && !!promptAction?.trim() ) { throw new CopilotPromptInvalid( `${promptName} are not allowed for ${sessionType} sessions` ); } return true; } @Transactional() async create(state: ChatSession, reuseChat = false): Promise { // find and return existing session if session is chat session if (reuseChat && !state.promptAction) { const sessionId = await this.find(state); if (sessionId) return sessionId; } if (state.pinned) { await this.unpin(state.workspaceId, state.userId); } const session = await this.db.aiSession.create({ data: { id: state.sessionId, workspaceId: state.workspaceId, docId: state.docId, pinned: state.pinned ?? false, // connect userId: state.userId, promptName: state.promptName, promptAction: state.promptAction, parentSessionId: state.parentSessionId, }, select: { id: true }, }); return session.id; } @Transactional() async createWithPrompt( state: ChatSessionWithPrompt, reuseChat = false ): Promise { const { prompt, ...rest } = state; return await this.models.copilotSession.create( { ...rest, promptName: prompt.name, promptAction: prompt.action ?? null }, reuseChat ); } @Transactional() async fork(options: ForkSessionOptions): Promise { if (options.pinned) { await this.unpin(options.workspaceId, options.userId); } const { messages, ...forkedState } = options; // create session const sessionId = await this.createWithPrompt({ ...forkedState, messages: [], }); if (options.messages.length) { // save message await this.models.copilotSession.updateMessages({ ...forkedState, sessionId, messages, }); } return sessionId; } @Transactional() async has( sessionId: string, userId: string, params?: Prisma.AiSessionCountArgs['where'] ) { return await this.db.aiSession .count({ where: { id: sessionId, userId, ...params } }) .then(c => c > 0); } @Transactional() async find(state: PureChatSession) { const extraCondition: Record = {}; if (state.parentSessionId) { // also check session id if provided session is forked session extraCondition.id = state.sessionId; extraCondition.parentSessionId = state.parentSessionId; } const session = await this.db.aiSession.findFirst({ where: { userId: state.userId, workspaceId: state.workspaceId, docId: state.docId, parentSessionId: null, ...this.noActionPromptCondition(), ...extraCondition, }, select: { id: true, deletedAt: true }, }); if (session?.deletedAt) throw new CopilotSessionDeleted(); return session?.id; } @Transactional() async getExists