import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; import { AiPromptRole, Prisma } from '@prisma/client'; import { omit } from 'lodash-es'; import { CopilotPromptInvalid, CopilotSessionDeleted, CopilotSessionInvalidInput, CopilotSessionNotFound, } from '../base'; import { getTokenEncoder } from '../native'; 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; model: string; }; type ChatAttachment = { attachment: string; mimeType: string } | string; type ChatStreamObject = { type: 'text-delta' | 'reasoning' | 'tool-call' | 'tool-result'; textDelta?: string; toolCallId?: string; toolName?: string; args?: Record; result?: any; }; type ChatMessage = { id?: string | undefined; role: 'system' | 'assistant' | 'user'; content: string; attachments?: ChatAttachment[] | null; params?: Record | null; streamObjects?: ChatStreamObject[] | null; createdAt: Date; }; 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; model: string }; messages: ChatMessage[]; }; type UpdateChatSessionMessage = ChatSessionBaseState & { prompt: { model: string }; messages: ChatMessage[]; }; export type UpdateChatSessionOptions = ChatSessionBaseState & Pick, 'docId' | 'pinned' | 'promptName' | '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 { 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; } // NOTE: just for test, remove it after copilot prompt model is ready async createPrompt(name: string, model: string, action?: string) { await this.db.aiPrompt.create({ data: { name, model, action: action ?? null }, }); } @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, prompt: { action: { equals: null } }, ...extraCondition, }, select: { id: true, deletedAt: true }, }); if (session?.deletedAt) throw new CopilotSessionDeleted(); return session?.id; } @Transactional() async getExists