feat(server): refactor copilot (#14892)

#### PR Dependency Tree


* **PR #14892** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-05-04 00:36:47 +08:00
committed by GitHub
parent fa8f1a096c
commit d64f368623
239 changed files with 35859 additions and 16777 deletions
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { Prisma as PrismaClient } from '@prisma/client';
import { BaseModel } from './base';
export type AiActionRunStatus =
| 'created'
| 'running'
| 'succeeded'
| 'failed'
| 'aborted';
function nullableJson(
value: unknown
): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue {
return value === undefined
? PrismaClient.JsonNull
: (value as Prisma.InputJsonValue);
}
@Injectable()
export class CopilotActionRunModel extends BaseModel {
async create(
input: Pick<
Prisma.AiActionRunCreateArgs['data'],
'userId' | 'workspaceId' | 'actionId' | 'actionVersion'
> & { inputSnapshot?: unknown } & Omit<
Partial<Prisma.AiActionRunCreateArgs['data']>,
'inputSnapshot'
>
) {
return await this.db.aiActionRun.create({
data: {
userId: input.userId,
workspaceId: input.workspaceId,
docId: input.docId ?? null,
sessionId: input.sessionId ?? null,
userMessageId: input.userMessageId ?? null,
compatSubmissionId: input.compatSubmissionId ?? null,
actionId: input.actionId,
actionVersion: input.actionVersion,
status: 'created',
attempt: input.attempt ?? 1,
retryOf: input.retryOf ?? null,
inputSnapshot: nullableJson(input.inputSnapshot),
},
});
}
async markRunning(id: string) {
return await this.db.aiActionRun.update({
where: { id },
data: { status: 'running' },
});
}
async complete(
id: string,
input: Omit<
Prisma.AiActionRunUpdateArgs['data'],
'artifacts' | 'result' | 'trace'
> & {
result?: unknown;
artifacts?: unknown;
trace?: unknown;
}
) {
return await this.db.aiActionRun.update({
where: { id },
data: {
status: input.status,
result: nullableJson(input.result),
artifacts: nullableJson(input.artifacts),
resultSummary: input.resultSummary ?? null,
errorCode: input.errorCode ?? null,
trace: nullableJson(input.trace),
assistantMessageId: input.assistantMessageId ?? null,
},
});
}
async get(id: string) {
const row = await this.db.aiActionRun.findUnique({ where: { id } });
return row ?? null;
}
async countSucceededByUser(userId: string) {
return await this.db.aiActionRun.count({
where: {
userId,
status: 'succeeded',
NOT: {
actionId: {
startsWith: 'transcript.audio.',
},
},
},
});
}
async countLegacyPromptActionSessionsWithoutRun(userId: string) {
return await this.db.aiSession.count({
where: {
userId,
promptAction: {
not: null,
},
NOT: {
promptAction: '',
},
actionRuns: {
none: {},
},
},
});
}
}
@@ -11,6 +11,10 @@ import {
} from '../base';
import { getTokenEncoder } from '../native';
import type { PromptAttachment } from '../plugins/copilot/providers/types';
import {
type ChatMessage as CopilotChatMessage,
ChatMessageSchema,
} from '../plugins/copilot/types';
import { BaseModel } from './base';
export enum SessionType {
@@ -34,10 +38,14 @@ type ChatStreamObject = {
toolName?: string;
args?: Record<string, any>;
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;
@@ -46,6 +54,19 @@ type ChatMessage = {
createdAt: Date;
};
type StoredChatMessage = Prisma.AiSessionMessageGetPayload<{
select: {
id: true;
compatSubmissionId: true;
role: true;
content: true;
attachments: true;
streamObjects: true;
params: true;
createdAt: true;
};
}>;
type PureChatSession = {
sessionId: string;
workspaceId: string;
@@ -84,7 +105,10 @@ type UpdateChatSessionMessage = ChatSessionBaseState & {
};
export type UpdateChatSessionOptions = ChatSessionBaseState &
Pick<Partial<ChatSession>, 'docId' | 'pinned' | 'promptName' | 'title'>;
Pick<
Partial<ChatSession>,
'docId' | 'pinned' | 'promptName' | 'promptAction' | 'title'
> & { promptModel?: string };
export type UpdateChatSession = ChatSessionBaseState & UpdateChatSessionOptions;
@@ -114,6 +138,26 @@ export type CleanupSessionOptions = Pick<
@Injectable()
export class CopilotSessionModel extends BaseModel {
private noActionPromptCondition(): Prisma.AiSessionWhereInput {
return {
OR: [{ promptAction: null }, { promptAction: '' }],
};
}
private async ensurePromptCompatRecord(prompt: ChatPrompt) {
await this.db.aiPrompt.upsert({
where: { name: prompt.name },
update: {},
create: {
name: prompt.name,
action: prompt.action,
model: prompt.model,
optionalModels: [],
config: {},
},
});
}
private sanitizeString<T extends string | null | undefined>(value: T): T {
if (typeof value !== 'string') {
return value;
@@ -154,6 +198,9 @@ export class CopilotSessionModel extends BaseModel {
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 {
@@ -162,6 +209,8 @@ export class CopilotSessionModel extends BaseModel {
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),
};
}
}
@@ -279,6 +328,7 @@ export class CopilotSessionModel extends BaseModel {
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(
@@ -290,6 +340,23 @@ export class CopilotSessionModel extends BaseModel {
};
}
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<StoredChatMessage, 'role'>
): boolean {
return message.role === AiPromptRole.user;
}
getSessionType(session: Pick<ChatSession, 'docId' | 'pinned'>): SessionType {
if (session.pinned) return SessionType.Pinned;
if (!session.docId) return SessionType.Workspace;
@@ -316,13 +383,6 @@ export class CopilotSessionModel extends BaseModel {
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<string> {
// find and return existing session if session is chat session
@@ -358,6 +418,7 @@ export class CopilotSessionModel extends BaseModel {
reuseChat = false
): Promise<string> {
const { prompt, ...rest } = state;
await this.ensurePromptCompatRecord(prompt);
return await this.models.copilotSession.create(
{ ...rest, promptName: prompt.name, promptAction: prompt.action ?? null },
reuseChat
@@ -414,7 +475,7 @@ export class CopilotSessionModel extends BaseModel {
workspaceId: state.workspaceId,
docId: state.docId,
parentSessionId: null,
prompt: { action: { equals: null } },
...this.noActionPromptCondition(),
...extraCondition,
},
select: { id: true, deletedAt: true },
@@ -464,22 +525,28 @@ export class CopilotSessionModel extends BaseModel {
});
}
@Transactional()
async getMeta(sessionId: string) {
return await this.getExists(sessionId, {
id: true,
userId: true,
workspaceId: true,
docId: true,
parentSessionId: true,
pinned: true,
title: true,
promptName: true,
tokenCost: true,
createdAt: true,
updatedAt: true,
});
}
private getListConditions(
options: ListSessionOptions
): Prisma.AiSessionWhereInput {
const { userId, sessionId, workspaceId, docId, action, fork } = options;
function getNullCond<T>(
maybeBool: boolean | undefined,
wrap: (ret: { not: null } | null) => T = ret => ret as T
): T | undefined {
return maybeBool === true
? wrap({ not: null })
: maybeBool === false
? wrap(null)
: undefined;
}
function getEqCond<T>(maybeValue: T | undefined): T | undefined {
return maybeValue !== undefined ? maybeValue : undefined;
}
@@ -492,8 +559,13 @@ export class CopilotSessionModel extends BaseModel {
id: getEqCond(sessionId),
deletedAt: null,
pinned: getEqCond(options.pinned),
prompt: getNullCond(action, ret => ({ action: ret })),
parentSessionId: getNullCond(fork),
...(action === false ? this.noActionPromptCondition() : {}),
...(action === true ? { NOT: this.noActionPromptCondition() } : {}),
...(fork === true
? { parentSessionId: { not: null } }
: fork === false
? { parentSessionId: null }
: {}),
},
];
@@ -505,7 +577,7 @@ export class CopilotSessionModel extends BaseModel {
workspaceId: workspaceId,
docId: docId ?? null,
id: getEqCond(sessionId),
prompt: { action: null },
...this.noActionPromptCondition(),
// should only find forked session
parentSessionId: { not: null },
deletedAt: null,
@@ -587,7 +659,7 @@ export class CopilotSessionModel extends BaseModel {
docId: true,
parentSessionId: true,
pinned: true,
prompt: true,
promptAction: true,
},
{ userId }
);
@@ -597,7 +669,7 @@ export class CopilotSessionModel extends BaseModel {
// not allow to update action session
if (!internalCall) {
if (session.prompt.action) {
if (session.promptAction) {
throw new CopilotSessionInvalidInput(
`Cannot update action: ${session.id}`
);
@@ -608,12 +680,29 @@ export class CopilotSessionModel extends BaseModel {
}
}
let nextPromptAction: string | null | undefined;
if (promptName) {
const prompt = await this.db.aiPrompt.findFirst({
where: { name: promptName },
});
// always not allow to update to action prompt
if (!prompt || prompt.action) {
if (options.promptModel) {
await this.ensurePromptCompatRecord({
name: promptName,
action: options.promptAction,
model: options.promptModel,
});
}
nextPromptAction = options.promptAction;
if (nextPromptAction === undefined) {
const prompt = await this.db.aiPrompt.findFirst({
where: { name: promptName },
select: { action: true },
});
if (!prompt) {
throw new CopilotSessionInvalidInput(
`Prompt ${promptName} not found or not available for session ${sessionId}`
);
}
nextPromptAction = prompt.action ?? null;
}
if (nextPromptAction) {
throw new CopilotSessionInvalidInput(
`Prompt ${promptName} not found or not available for session ${sessionId}`
);
@@ -626,7 +715,13 @@ export class CopilotSessionModel extends BaseModel {
await this.db.aiSession.update({
where: { id: sessionId },
data: { docId, promptName, pinned, title: sanitizedTitle },
data: {
docId,
promptName,
promptAction: nextPromptAction,
pinned,
title: sanitizedTitle,
},
});
return sessionId;
@@ -672,6 +767,48 @@ export class CopilotSessionModel extends BaseModel {
});
}
@Transactional()
async getMessage(sessionId: string, messageId: string) {
const message = await this.db.aiSessionMessage.findFirst({
where: { id: messageId, sessionId },
select: {
id: true,
compatSubmissionId: true,
role: true,
content: true,
attachments: true,
streamObjects: true,
params: true,
createdAt: true,
},
});
return message ? this.toPublicMessage(message) : null;
}
@Transactional()
async findMessageByCompatSubmissionId(
sessionId: string,
compatSubmissionId: string
) {
const message = await this.db.aiSessionMessage.findFirst({
where: { sessionId, compatSubmissionId },
select: {
id: true,
compatSubmissionId: true,
role: true,
content: true,
attachments: true,
streamObjects: true,
params: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
});
return message ? this.toPublicMessage(message) : null;
}
private calculateTokenSize(messages: any[], model: string): number {
const encoder = getTokenEncoder(model);
const content = messages.map(m => m.content).join('');
@@ -694,10 +831,13 @@ export class CopilotSessionModel extends BaseModel {
);
await this.db.aiSessionMessage.createMany({
data: sanitizedMessages.map(m => ({
...m,
compatSubmissionId: m.compatSubmissionId || undefined,
role: m.role,
content: m.content,
attachments: m.attachments || undefined,
params: m.params || undefined,
streamObjects: m.streamObjects || undefined,
createdAt: m.createdAt,
sessionId,
})),
});
@@ -714,18 +854,120 @@ export class CopilotSessionModel extends BaseModel {
}
}
@Transactional()
async appendMessage(state: {
sessionId: string;
userId: string;
prompt: { model: string };
message: ChatMessage;
}) {
const haveSession = await this.has(state.sessionId, state.userId);
if (!haveSession) {
throw new CopilotSessionNotFound();
}
const message = this.sanitizeMessage(state.message);
const tokenCost = this.calculateTokenSize([message], state.prompt.model);
const created = await this.db.aiSessionMessage.create({
data: {
sessionId: state.sessionId,
compatSubmissionId: message.compatSubmissionId || undefined,
role: message.role,
content: message.content,
attachments: message.attachments || undefined,
params: message.params || undefined,
streamObjects: message.streamObjects || undefined,
createdAt: message.createdAt,
},
select: {
id: true,
compatSubmissionId: true,
role: true,
content: true,
attachments: true,
streamObjects: true,
params: true,
createdAt: true,
},
});
await this.db.aiSession.update({
where: { id: state.sessionId },
data: {
messageCost:
message.role === AiPromptRole.user ? { increment: 1 } : undefined,
tokenCost: { increment: tokenCost },
},
});
return this.toPublicMessage(created);
}
@Transactional()
async trimAfterMessage(
sessionId: string,
messageId: string,
removeTargetMessage = false
) {
const session = await this.getExists(sessionId, {
id: true,
});
if (!session) {
throw new CopilotSessionNotFound();
}
const messages = await this.getMessages(
sessionId,
{ id: true, role: true, content: true, params: true },
{ createdAt: 'asc' }
);
const messageIndex = messages.findIndex(({ id }) => id === messageId);
if (messageIndex < 0) {
throw new CopilotSessionNotFound();
}
const ids = messages
.slice(messageIndex + (removeTargetMessage ? 0 : 1))
.map(({ id }) => id);
if (!ids.length) {
return;
}
await this.db.aiSessionMessage.deleteMany({ where: { id: { in: ids } } });
const remainingMessages = await this.getMessages(sessionId, {
role: true,
});
const userMessageCount = remainingMessages.filter(message =>
this.isCountedUserMessage(message)
).length;
if (userMessageCount <= 1) {
await this.db.aiSession.update({
where: { id: sessionId },
data: { title: null },
});
}
}
@Transactional()
async revertLatestMessage(
sessionId: string,
removeLatestUserMessage: boolean
) {
const id = await this.getExists(sessionId, { id: true }).then(
session => session?.id
);
if (!id) {
const session = await this.getExists(sessionId, {
id: true,
});
if (!session) {
throw new CopilotSessionNotFound();
}
const messages = await this.getMessages(id, { id: true, role: true });
const messages = await this.getMessages(session.id, {
id: true,
role: true,
content: true,
});
const ids = messages
.slice(
messages.findLastIndex(({ role }) => role === AiPromptRole.user) +
@@ -737,14 +979,16 @@ export class CopilotSessionModel extends BaseModel {
await this.db.aiSessionMessage.deleteMany({ where: { id: { in: ids } } });
// clear the title if there only one round of conversation left
const remainingMessages = await this.getMessages(id, { role: true });
const userMessageCount = remainingMessages.filter(
m => m.role === AiPromptRole.user
const remainingMessages = await this.getMessages(session.id, {
role: true,
});
const userMessageCount = remainingMessages.filter(message =>
this.isCountedUserMessage(message)
).length;
if (userMessageCount <= 1) {
await this.db.aiSession.update({
where: { id },
where: { id: session.id },
data: { title: null },
});
}
@@ -755,11 +999,26 @@ export class CopilotSessionModel extends BaseModel {
async countUserMessages(userId: string): Promise<number> {
const sessions = await this.db.aiSession.findMany({
where: { userId },
select: { messageCost: true, prompt: { select: { action: true } } },
select: { messageCost: true, promptAction: true },
});
return sessions
.map(({ messageCost, prompt: { action } }) => (action ? 1 : messageCost))
const regularMessageCost = sessions
.filter(({ promptAction }) => !promptAction)
.map(({ messageCost }) => messageCost)
.reduce((prev, cost) => prev + cost, 0);
const [actionRunCost, legacyActionSessionCost, transcriptSettlementCost] =
await Promise.all([
this.models.copilotActionRun.countSucceededByUser(userId),
this.models.copilotActionRun.countLegacyPromptActionSessionsWithoutRun(
userId
),
this.models.copilotTranscriptTask.countSettledByUser(userId),
]);
return (
regularMessageCost +
actionRunCost +
legacyActionSessionCost +
transcriptSettlementCost
);
}
async cleanupEmptySessions(earlyThen: Date) {
@@ -799,7 +1058,7 @@ export class CopilotSessionModel extends BaseModel {
deletedAt: null,
messages: { some: {} },
// only generate titles for non-actions sessions
prompt: { action: null },
...this.noActionPromptCondition(),
},
select: {
id: true,
@@ -0,0 +1,124 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { Prisma as PrismaClient } from '@prisma/client';
import { BaseModel } from './base';
function nullableJson(
value: unknown
): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue {
return value === undefined
? PrismaClient.JsonNull
: (value as Prisma.InputJsonValue);
}
function isRecordNotFound(error: unknown) {
return (
error instanceof PrismaClient.PrismaClientKnownRequestError &&
error.code === 'P2025'
);
}
@Injectable()
export class CopilotTranscriptTaskModel extends BaseModel {
async create(
input: Pick<
Prisma.AiTranscriptTaskCreateArgs['data'],
| 'userId'
| 'workspaceId'
| 'blobId'
| 'strategy'
| 'recipeId'
| 'recipeVersion'
> &
Partial<Prisma.AiTranscriptTaskCreateArgs['data']>
) {
return await this.db.aiTranscriptTask.create({
data: {
userId: input.userId,
workspaceId: input.workspaceId,
blobId: input.blobId,
status: 'pending',
strategy: input.strategy,
recipeId: input.recipeId,
recipeVersion: input.recipeVersion,
inputSnapshot: nullableJson(input.inputSnapshot),
publicMeta: nullableJson(input.publicMeta),
},
});
}
async get(id: string) {
const row = await this.db.aiTranscriptTask.findUnique({ where: { id } });
return row ?? null;
}
async getWithUser(
userId: string,
workspaceId: string,
taskId?: string,
blobId?: string
) {
if (!taskId && !blobId) return null;
const row = await this.db.aiTranscriptTask.findFirst({
where: {
userId,
workspaceId,
...(taskId ? { id: taskId } : {}),
...(blobId ? { blobId } : {}),
},
orderBy: { createdAt: 'desc' },
});
return row ?? null;
}
async markRunning(id: string, actionRunId?: string | null) {
try {
return await this.db.aiTranscriptTask.update({
where: { id },
data: {
status: 'running',
...(actionRunId ? { actionRunId } : {}),
errorCode: null,
},
});
} catch (error) {
if (isRecordNotFound(error)) return null;
throw error;
}
}
async complete(id: string, input: Prisma.AiTranscriptTaskUpdateArgs['data']) {
try {
return await this.db.aiTranscriptTask.update({
where: { id },
data: {
status: input.status,
...(input.actionRunId ? { actionRunId: input.actionRunId } : {}),
publicMeta: nullableJson(input.publicMeta),
protectedResult: nullableJson(input.protectedResult),
errorCode: input.errorCode ?? null,
},
});
} catch (error) {
if (isRecordNotFound(error)) return null;
throw error;
}
}
async settle(id: string) {
const task = await this.get(id);
if (!task) return null;
return await this.db.aiTranscriptTask.update({
where: { id },
data: { status: 'settled', settledAt: task.settledAt ?? new Date() },
});
}
async countSettledByUser(userId: string) {
return await this.db.aiTranscriptTask.count({
where: { userId, status: 'settled' },
});
}
}
@@ -16,9 +16,11 @@ import { CalendarSubscriptionModel } from './calendar-subscription';
import { CommentModel } from './comment';
import { CommentAttachmentModel } from './comment-attachment';
import { AppConfigModel } from './config';
import { CopilotActionRunModel } from './copilot-action-run';
import { CopilotContextModel } from './copilot-context';
import { CopilotJobModel } from './copilot-job';
import { CopilotSessionModel } from './copilot-session';
import { CopilotTranscriptTaskModel } from './copilot-transcript-task';
import { CopilotWorkspaceConfigModel } from './copilot-workspace';
import { DocModel } from './doc';
import { DocUserModel } from './doc-user';
@@ -56,6 +58,8 @@ const MODELS = {
notification: NotificationModel,
userSettings: UserSettingsModel,
copilotSession: CopilotSessionModel,
copilotTranscriptTask: CopilotTranscriptTaskModel,
copilotActionRun: CopilotActionRunModel,
copilotContext: CopilotContextModel,
copilotWorkspace: CopilotWorkspaceConfigModel,
copilotJob: CopilotJobModel,
@@ -132,6 +136,7 @@ export * from './common';
export * from './copilot-context';
export * from './copilot-job';
export * from './copilot-session';
export * from './copilot-transcript-task';
export * from './copilot-workspace';
export * from './doc';
export * from './doc-user';