1
19import { Inject, Injectable } from "@nestjs/common";
20import type { Request } from "express";
21
22import { AiConfigSchema, type AiGateway } from "@factory/ai";
23import type { PrismaClient } from "@prisma/client";
24import { PrismaService } from "../../common/prisma.service.js";
25import { RedisService } from "../../common/redis.service.js";
26import { AuditService } from "../../common/audit.service.js";
27import { ApiError } from "../../common/errors.js";
28import { assertCan } from "../../lib/authorize.js";
29import {
30 AI_GATEWAY,
31 embedDimension,
32 mapAiError,
33 primeWorkspaceCache,
34 resetWorkspaceCache,
35 tokensPerMonthFor,
36 type AiBudgetGuard,
37 type WorkspaceAiRow,
38} from "./gateway-provider.js";
39import { AiBudgetGuardService } from "./budget-guard.service.js";
40import type { ChatInput, CreateConversationInput } from "./dto.js";
41import { RAG_CONV_PREFIX, WRITING_CONV_PREFIX } from "./prompts.js";
42import { DB_EXTRACT_CONV_PREFIX, DB_FORMULA_CONV_PREFIX, DB_GEN_CONV_PREFIX } from "./db-prompts.js";
43import { GENERATE_CONV_PREFIX } from "./generator/generate.service.js";
44import { ASSISTANT_CONV_PREFIX } from "./assistant/assistant-prompts.js";
45
46
50export const HIDDEN_LEDGER_PREFIX = "ai-usage:";
51
52@Injectable()
53export class AiService {
54 constructor(
55 @Inject(PrismaService) private readonly prisma: PrismaClient,
56 @Inject(AuditService) private readonly audit: AuditService,
57 @Inject(AI_GATEWAY) private readonly gateway: AiGateway,
58 @Inject(AiBudgetGuardService) private readonly guardService: AiBudgetGuardService
59 ) {}
60
61
63 private get guard(): AiBudgetGuard {
64 return this.guardService.guard;
65 }
66
67
68
69
74 async ping(workspaceId: string, userId: string) {
75 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
76 await primeWorkspaceCache(this.prisma, workspaceId);
77 const config = this.gateway.resolve(workspaceId);
78 const row = await this.prisma.workspace.findUniqueOrThrow({ where: { id: workspaceId }, select: { usageLimits: true, settings: true, billingPlan: true } });
79 const parsed = AiConfigSchema.safeParse(((row.settings ?? {}) as { ai?: unknown }).ai ?? {});
80 return {
81 data: {
82 provider: config.provider,
83 model: config.chatModel,
84 embedModel: config.embedModel,
85 embeddingDimension: embedDimension(),
86 budget: {
87 limit: tokensPerMonthFor(row as unknown as WorkspaceAiRow, parsed.success ? parsed.data : null),
88 used: await this.guard.usedTokens(workspaceId),
89 },
90 },
91 };
92 }
93
94
95
96 private async requireConversation(conversationId: string, userId: string) {
97 const conversation = await this.prisma.aiConversation.findUnique({ where: { id: conversationId } });
98 if (!conversation || conversation.userId !== userId || conversation.deletedAt) throw ApiError.notFound("Conversation not found.");
99 return conversation;
100 }
101
102 async listConversations(workspaceId: string, userId: string) {
103 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
104 const rows = await this.prisma.aiConversation.findMany({
105 where: {
106 workspaceId,
107 userId,
108 deletedAt: null,
109
110
111
112
113
114
115 AND: [
116 { title: { not: { startsWith: HIDDEN_LEDGER_PREFIX } } },
117 { title: { not: { startsWith: WRITING_CONV_PREFIX } } },
118 { title: { not: { startsWith: RAG_CONV_PREFIX } } },
119 { title: { not: { startsWith: DB_GEN_CONV_PREFIX } } },
120 { title: { not: { startsWith: DB_EXTRACT_CONV_PREFIX } } },
121 { title: { not: { startsWith: DB_FORMULA_CONV_PREFIX } } },
122 { title: { not: { startsWith: GENERATE_CONV_PREFIX } } },
123
124
125 { title: { not: { startsWith: ASSISTANT_CONV_PREFIX } } }
126 ]
127 },
128 orderBy: { updatedAt: "desc" },
129 take: 50,
130 select: { id: true, title: true, context: true, createdAt: true, updatedAt: true, _count: { select: { messages: true } } }
131 });
132 return { data: rows.map((r) => ({ ...r, messageCount: r._count.messages })) };
133 }
134
135 async createConversation(workspaceId: string, userId: string, input: CreateConversationInput, req?: Request) {
136 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
137 const conversation = await this.prisma.aiConversation.create({
138 data: {
139 workspaceId,
140 userId,
141 title: input.title ?? null,
142 context: input.context && Object.keys(input.context).length > 0 ? (input.context as object) : undefined
143 }
144 });
145
146
147
148 await this.audit.audit(req, req?.auth, {
149 action: "ai.conversation_created",
150 resourceType: "ai_conversation",
151 resourceId: conversation.id,
152 workspaceId,
153 metadata: { context: input.context ?? null }
154 });
155 return conversation;
156 }
157
158 async renameConversation(conversationId: string, userId: string, title: string) {
159 const conversation = await this.requireConversation(conversationId, userId);
160 return this.prisma.aiConversation.update({
161 where: { id: conversation.id },
162 data: { title: title.slice(0, 200) }
163 });
164 }
165
166
167 async deleteConversation(conversationId: string, userId: string) {
168 const conversation = await this.requireConversation(conversationId, userId);
169 await this.prisma.aiConversation.update({
170 where: { id: conversation.id },
171 data: { deletedAt: new Date() }
172 });
173 return { deleted: true };
174 }
175
176 async getConversation(conversationId: string, userId: string) {
177 const conversation = await this.requireConversation(conversationId, userId);
178 const messages = await this.prisma.aiMessage.findMany({
179 where: { conversationId: conversation.id },
180 orderBy: { createdAt: "asc" },
181 take: 200
182 });
183 return { data: { ...conversation, messages } };
184 }
185
186 async listMessages(conversationId: string, userId: string, query: { cursor?: string; limit?: string }) {
187 const conversation = await this.requireConversation(conversationId, userId);
188 const limit = Math.min(query.limit ? Number.parseInt(query.limit, 10) : 50, 200) || 50;
189 const rows = await this.prisma.aiMessage.findMany({
190 where: { conversationId: conversation.id },
191 ...(query.cursor
192 ? { cursor: { id: query.cursor }, skip: 1 }
193 : {}),
194 orderBy: { createdAt: "asc" },
195 take: limit + 1
196 });
197 const page = rows.slice(0, limit);
198 const next = rows.length > limit ? page[page.length - 1] : undefined;
199 return {
200 data: page,
201 nextCursor: next ? next.id : null
202 };
203 }
204
205
206
207
219 async chat(workspaceId: string, userId: string, input: ChatInput, req: Request) {
220 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
221 await primeWorkspaceCache(this.prisma, workspaceId);
222 await this.guardCheck(workspaceId, req);
223
224
225 let conversation;
226 if (input.conversationId) {
227 const existing = await this.prisma.aiConversation.findUnique({ where: { id: input.conversationId } });
228 if (!existing || existing.userId !== userId || existing.workspaceId !== workspaceId || existing.deletedAt) {
229 throw ApiError.notFound("Conversation not found.");
230 }
231 conversation = existing;
232 } else {
233 conversation = await this.prisma.aiConversation.create({
234 data: {
235 workspaceId,
236 userId,
237 title: input.message.slice(0, 60),
238 context: input.context ? (input.context as object) : undefined
239 }
240 });
241 }
242
243
244 const history = await this.prisma.aiMessage.findMany({
245 where: { conversationId: conversation.id },
246 orderBy: { createdAt: "desc" },
247 take: 20,
248 select: { role: true, content: true }
249 });
250 const messages = [
251 ...history.reverse().map((m) => ({ role: m.role as "user" | "assistant" | "system" | "tool", content: m.content })),
252 { role: "user" as const, content: input.message }
253 ];
254
255 const chatModel = this.gateway.resolve(workspaceId).chatModel;
256 const startedAt = Date.now();
257 let content = "";
258 let usage = { in: 0, out: 0, total: 0 };
259 try {
260 for await (const chunk of this.gateway.chat({
261 workspaceId,
262 userId,
263 capability: "chat",
264 messages,
265 metadata: { conversationId: conversation.id }
266 })) {
267 if (chunk.delta) content += chunk.delta;
268 if (chunk.done && chunk.usage) usage = chunk.usage;
269 }
270 } catch (err) {
271 throw mapAiError(err);
272 }
273 const latencyMs = Date.now() - startedAt;
274
275 const [userRow, assistantRow] = await this.prisma.$transaction([
276 this.prisma.aiMessage.create({
277 data: { conversationId: conversation.id, role: "user", content: input.message }
278 }),
279 this.prisma.aiMessage.create({
280 data: {
281 conversationId: conversation.id,
282 role: "assistant",
283 content,
284 model: chatModel,
285 tokensIn: usage.in,
286 tokensOut: usage.out,
287 latencyMs,
288 metadata: asJson({ feature: "chat" })
289 }
290 }),
291 this.prisma.aiConversation.update({
292 where: { id: conversation.id },
293 data: { updatedAt: new Date() }
294 })
295 ]);
296
297 await this.audit.audit(req, req.auth, {
298 action: "ai.chat",
299 resourceType: "ai_message",
300 resourceId: conversation.id,
301 workspaceId,
302 metadata: { model: chatModel, tokensIn: usage.in, tokensOut: usage.out, latencyMs }
303 });
304
305 return {
306 conversationId: conversation.id,
307 role: "assistant",
308 content,
309 model: chatModel,
310 tokensIn: usage.in,
311 tokensOut: usage.out,
312 latencyMs,
313 userMessageId: userRow.id,
314 assistantMessageId: assistantRow.id
315 };
316 }
317
318
322 private async guardCheck(workspaceId: string, req?: Request): Promise<void> {
323 try {
324 await this.guard.check(workspaceId, undefined, req);
325 } catch (err) {
326 throw mapAiError(err);
327 }
328 }
329
330
331
332
333 flushCacheForTests(): void {
334 resetWorkspaceCache();
335 }
336}
337
338function asJson(v: unknown): object {
339 return v as object;
340}
341