mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 01:42:55 +08:00
#### PR Dependency Tree * **PR #14452** 👈 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 * **Bug Fixes** * Improved null-safety, dependency tracking, upload validation, and error logging for more reliable uploads, clipboard, calendar linking, telemetry, PDF/theme printing, and preview/zoom behavior. * Tightened handling of all-day calendar events (missing date now reported). * **Deprecations** * Removed deprecated RadioButton and RadioButtonGroup; use RadioGroup. * **Chores** * Unified and upgraded linting/config, reorganized imports, and standardized binary handling for more consistent builds and tooling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1028 lines
27 KiB
TypeScript
1028 lines
27 KiB
TypeScript
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,
|
|
sniffMime,
|
|
Throttle,
|
|
TooManyRequest,
|
|
UserFriendlyError,
|
|
} from '../../base';
|
|
import { CurrentUser } from '../../core/auth';
|
|
import { Admin } from '../../core/common';
|
|
import { DocReader } from '../../core/doc';
|
|
import { AccessController, DocAction } from '../../core/permission';
|
|
import { UserType } from '../../core/user';
|
|
import type { ListSessionOptions, UpdateChatSession } from '../../models';
|
|
import { CopilotCronJobs } from './cron';
|
|
import { PromptService } from './prompt/service';
|
|
import { CopilotProviderFactory } from './providers/factory';
|
|
import type { PromptMessage, StreamObject } from './providers/types';
|
|
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<
|
|
UpdateChatSession,
|
|
'userId' | 'title'
|
|
> {
|
|
@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, { nullable: true })
|
|
docId!: string | undefined;
|
|
|
|
@Field(() => [String])
|
|
sessionIds!: string[];
|
|
}
|
|
|
|
@InputType()
|
|
class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> {
|
|
@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 })
|
|
blob!: Promise<FileUpload> | undefined;
|
|
|
|
@Field(() => [GraphQLUpload], { nullable: true })
|
|
blobs!: Promise<FileUpload>[] | undefined;
|
|
|
|
@Field(() => GraphQLJSON, { nullable: true })
|
|
params!: Record<string, any> | undefined;
|
|
}
|
|
|
|
enum ChatHistoryOrder {
|
|
asc = 'asc',
|
|
desc = 'desc',
|
|
}
|
|
|
|
registerEnumType(ChatHistoryOrder, { name: 'ChatHistoryOrder' });
|
|
|
|
@InputType()
|
|
class QueryChatSessionsInput implements Partial<ListSessionOptions> {
|
|
@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<ListSessionOptions>
|
|
{
|
|
@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<ChatMessage> {
|
|
// 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<string, string> | undefined;
|
|
|
|
@Field(() => Date)
|
|
createdAt!: Date;
|
|
}
|
|
|
|
@ObjectType('CopilotHistories')
|
|
class CopilotHistoriesType implements Omit<ChatHistory, 'userId'> {
|
|
@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<string, string> | 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()
|
|
class CopilotModelType {
|
|
@Field(() => String)
|
|
id!: string;
|
|
|
|
@Field(() => String)
|
|
name!: string;
|
|
}
|
|
|
|
@ObjectType()
|
|
export class CopilotModelsType {
|
|
@Field(() => String)
|
|
defaultModel!: string;
|
|
|
|
@Field(() => [CopilotModelType])
|
|
optionalModels!: CopilotModelType[];
|
|
|
|
@Field(() => [CopilotModelType])
|
|
proModels!: CopilotModelType[];
|
|
}
|
|
|
|
@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 {
|
|
private readonly modelNames = new Map<string, string>();
|
|
|
|
constructor(
|
|
private readonly ac: AccessController,
|
|
private readonly mutex: RequestMutex,
|
|
private readonly prompt: PromptService,
|
|
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<CopilotQuotaType> {
|
|
return await this.chatSession.getQuota(user.id);
|
|
}
|
|
|
|
private async assertPermission(
|
|
user: CurrentUser,
|
|
options: { workspaceId?: string | null; docId?: string | null },
|
|
fallbackAction?: DocAction
|
|
) {
|
|
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(fallbackAction ?? 'Doc.Update');
|
|
} else {
|
|
await this.ac
|
|
.user(user.id)
|
|
.workspace(workspaceId)
|
|
.allowLocal()
|
|
.assert('Workspace.Copilot');
|
|
}
|
|
return { userId: user.id, workspaceId, docId: docId || undefined };
|
|
}
|
|
|
|
@ResolveField(() => CopilotModelsType, {
|
|
description:
|
|
'List available models for a prompt, with human-readable names',
|
|
complexity: 2,
|
|
})
|
|
async models(
|
|
@Args('promptName') promptName: string
|
|
): Promise<CopilotModelsType> {
|
|
const prompt = await this.prompt.get(promptName);
|
|
if (!prompt) {
|
|
throw new NotFoundException('Prompt not found');
|
|
}
|
|
const convertModels = (ids: string[]) => {
|
|
return ids
|
|
.map(id => ({ id, name: this.modelNames.get(id) }))
|
|
.filter(m => !!m.name) as CopilotModelType[];
|
|
};
|
|
const proModels = prompt.config?.proModels || [];
|
|
const missing = new Set(
|
|
[...prompt.optionalModels, ...proModels].filter(
|
|
id => !this.modelNames.has(id)
|
|
)
|
|
);
|
|
if (missing.size) {
|
|
for (const model of missing) {
|
|
if (this.modelNames.has(model)) continue;
|
|
const provider = await this.providerFactory.getProviderByModel(model);
|
|
if (provider?.configured()) {
|
|
for (const m of provider.models) {
|
|
if (m.name) this.modelNames.set(m.id, m.name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
defaultModel: prompt.model,
|
|
optionalModels: convertModels(prompt.optionalModels),
|
|
proModels: convertModels(proModels),
|
|
};
|
|
}
|
|
|
|
@ResolveField(() => CopilotSessionType, {
|
|
description: 'Get the session by id',
|
|
complexity: 2,
|
|
})
|
|
async session(
|
|
@Parent() copilot: CopilotType,
|
|
@CurrentUser() user: CurrentUser,
|
|
@Args('sessionId') sessionId: string
|
|
): Promise<CopilotSessionType> {
|
|
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<CopilotSessionType[]> {
|
|
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<CopilotHistoriesType[]> {
|
|
const workspaceId = copilot.workspaceId;
|
|
if (!workspaceId) {
|
|
return [];
|
|
} else {
|
|
await this.assertPermission(user, { workspaceId, docId }, 'Doc.Read');
|
|
}
|
|
|
|
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<PaginatedCopilotHistoriesType> {
|
|
const workspaceId = copilot.workspaceId;
|
|
if (!workspaceId) {
|
|
return paginate([], 'updatedAt', pagination, 0);
|
|
} else {
|
|
await this.assertPermission(user, { workspaceId, docId }, 'Doc.Read');
|
|
}
|
|
|
|
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<string> {
|
|
// 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<string> {
|
|
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<string> {
|
|
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<string[]> {
|
|
const { workspaceId, docId, sessionIds } = options;
|
|
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');
|
|
}
|
|
if (!sessionIds.length) {
|
|
throw new NotFoundException('Session not found');
|
|
}
|
|
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${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<string> {
|
|
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.blob || options.blobs) {
|
|
const { workspaceId } = session.config;
|
|
|
|
const blobs = await Promise.all(
|
|
options.blob ? [options.blob] : options.blobs || []
|
|
);
|
|
delete options.blob;
|
|
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: sniffMime(uploaded.buffer, blob.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.',
|
|
deprecationReason: 'use Mutation.applyDocUpdates',
|
|
})
|
|
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<string> {
|
|
return this.applyDocUpdatesInternal(user, workspaceId, docId, op, updates);
|
|
}
|
|
|
|
@Mutation(() => String, {
|
|
description:
|
|
'Apply updates to a doc using LLM and return the merged markdown.',
|
|
name: 'applyDocUpdates',
|
|
})
|
|
async applyDocUpdatesMutation(
|
|
@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<string> {
|
|
return this.applyDocUpdatesInternal(user, workspaceId, docId, op, updates);
|
|
}
|
|
|
|
private async applyDocUpdatesInternal(
|
|
user: CurrentUser,
|
|
workspaceId: string,
|
|
docId: string,
|
|
op: string,
|
|
updates: string
|
|
): Promise<string> {
|
|
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: `<instruction>${op}</instruction>\n<code>${markdown}</code>\n<update>${updates}</update>`,
|
|
},
|
|
],
|
|
{ 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<ChatHistory, 'messages'>
|
|
): 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<CopilotType> {
|
|
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, modified: true });
|
|
return this.promptService.get(name);
|
|
}
|
|
}
|