@studious-lms/server 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/.env.example +3 -1
  2. package/.github/workflows/npm-publish.yml +1 -0
  3. package/AGENTS.md +84 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +31 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/instrument.js +9 -4
  8. package/dist/instrument.js.map +1 -1
  9. package/dist/lib/fileUpload.d.ts +15 -0
  10. package/dist/lib/fileUpload.d.ts.map +1 -1
  11. package/dist/lib/fileUpload.js +43 -2
  12. package/dist/lib/fileUpload.js.map +1 -1
  13. package/dist/models/cloudStorage.d.ts +11 -0
  14. package/dist/models/cloudStorage.d.ts.map +1 -1
  15. package/dist/models/cloudStorage.js +18 -2
  16. package/dist/models/cloudStorage.js.map +1 -1
  17. package/dist/models/conversation.d.ts +1 -0
  18. package/dist/models/conversation.d.ts.map +1 -1
  19. package/dist/models/labChat.d.ts +40 -0
  20. package/dist/models/labChat.d.ts.map +1 -1
  21. package/dist/models/labChat.js +32 -2
  22. package/dist/models/labChat.js.map +1 -1
  23. package/dist/models/message.d.ts +3 -0
  24. package/dist/models/message.d.ts.map +1 -1
  25. package/dist/pipelines/aiLabChat.d.ts +2 -0
  26. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  27. package/dist/pipelines/aiLabChat.js +60 -16
  28. package/dist/pipelines/aiLabChat.js.map +1 -1
  29. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  30. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.js +30 -2
  32. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +30 -5
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/generateVisualization.d.ts +33 -0
  37. package/dist/pipelines/generateVisualization.d.ts.map +1 -0
  38. package/dist/pipelines/generateVisualization.js +79 -0
  39. package/dist/pipelines/generateVisualization.js.map +1 -0
  40. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  41. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  42. package/dist/pipelines/labChatPrompt.js +13 -14
  43. package/dist/pipelines/labChatPrompt.js.map +1 -1
  44. package/dist/routers/_app.d.ts +1118 -70
  45. package/dist/routers/_app.d.ts.map +1 -1
  46. package/dist/routers/_app.js +4 -2
  47. package/dist/routers/_app.js.map +1 -1
  48. package/dist/routers/admin.d.ts +297 -1
  49. package/dist/routers/admin.d.ts.map +1 -1
  50. package/dist/routers/admin.js +43 -2
  51. package/dist/routers/admin.js.map +1 -1
  52. package/dist/routers/class.d.ts.map +1 -1
  53. package/dist/routers/class.js +16 -3
  54. package/dist/routers/class.js.map +1 -1
  55. package/dist/routers/conversation.d.ts +1 -0
  56. package/dist/routers/conversation.d.ts.map +1 -1
  57. package/dist/routers/labChat.d.ts +10 -0
  58. package/dist/routers/labChat.d.ts.map +1 -1
  59. package/dist/routers/labChat.js +6 -3
  60. package/dist/routers/labChat.js.map +1 -1
  61. package/dist/routers/message.d.ts +37 -4
  62. package/dist/routers/message.d.ts.map +1 -1
  63. package/dist/routers/message.js +24 -7
  64. package/dist/routers/message.js.map +1 -1
  65. package/dist/routers/module.d.ts +1 -1
  66. package/dist/routers/parent.d.ts +18 -0
  67. package/dist/routers/parent.d.ts.map +1 -1
  68. package/dist/routers/parent.js +10 -3
  69. package/dist/routers/parent.js.map +1 -1
  70. package/dist/routers/studentProgress.d.ts +44 -0
  71. package/dist/routers/studentProgress.d.ts.map +1 -1
  72. package/dist/routers/studentProgress.js +12 -3
  73. package/dist/routers/studentProgress.js.map +1 -1
  74. package/dist/routers/visualization.d.ts +124 -0
  75. package/dist/routers/visualization.d.ts.map +1 -0
  76. package/dist/routers/visualization.js +41 -0
  77. package/dist/routers/visualization.js.map +1 -0
  78. package/dist/routes/aiStream.js +32 -2
  79. package/dist/routes/aiStream.js.map +1 -1
  80. package/dist/seedDatabase.d.ts.map +1 -1
  81. package/dist/seedDatabase.js +107 -3
  82. package/dist/seedDatabase.js.map +1 -1
  83. package/dist/services/adminClass.d.ts +153 -0
  84. package/dist/services/adminClass.d.ts.map +1 -0
  85. package/dist/services/adminClass.js +196 -0
  86. package/dist/services/adminClass.js.map +1 -0
  87. package/dist/services/adminInstitution.d.ts +2 -0
  88. package/dist/services/adminInstitution.d.ts.map +1 -1
  89. package/dist/services/adminInstitution.js +11 -4
  90. package/dist/services/adminInstitution.js.map +1 -1
  91. package/dist/services/adminMetrics.d.ts +8 -0
  92. package/dist/services/adminMetrics.d.ts.map +1 -1
  93. package/dist/services/adminMetrics.js +30 -3
  94. package/dist/services/adminMetrics.js.map +1 -1
  95. package/dist/services/adminSpam.d.ts +76 -0
  96. package/dist/services/adminSpam.d.ts.map +1 -0
  97. package/dist/services/adminSpam.js +163 -0
  98. package/dist/services/adminSpam.js.map +1 -0
  99. package/dist/services/adminSql.d.ts +36 -0
  100. package/dist/services/adminSql.d.ts.map +1 -0
  101. package/dist/services/adminSql.js +172 -0
  102. package/dist/services/adminSql.js.map +1 -0
  103. package/dist/services/adminTelemetry.d.ts +56 -0
  104. package/dist/services/adminTelemetry.d.ts.map +1 -0
  105. package/dist/services/adminTelemetry.js +147 -0
  106. package/dist/services/adminTelemetry.js.map +1 -0
  107. package/dist/services/adminUser.d.ts +45 -1
  108. package/dist/services/adminUser.d.ts.map +1 -1
  109. package/dist/services/adminUser.js +53 -13
  110. package/dist/services/adminUser.js.map +1 -1
  111. package/dist/services/aiChatStream.d.ts +15 -0
  112. package/dist/services/aiChatStream.d.ts.map +1 -1
  113. package/dist/services/aiChatStream.js +144 -74
  114. package/dist/services/aiChatStream.js.map +1 -1
  115. package/dist/services/aiGeneration.d.ts +4 -0
  116. package/dist/services/aiGeneration.d.ts.map +1 -0
  117. package/dist/services/aiGeneration.js +88 -0
  118. package/dist/services/aiGeneration.js.map +1 -0
  119. package/dist/services/aiProposalTools.d.ts +4 -1
  120. package/dist/services/aiProposalTools.d.ts.map +1 -1
  121. package/dist/services/aiProposalTools.js +128 -3
  122. package/dist/services/aiProposalTools.js.map +1 -1
  123. package/dist/services/aiProposals.d.ts +70 -0
  124. package/dist/services/aiProposals.d.ts.map +1 -0
  125. package/dist/services/aiProposals.js +363 -0
  126. package/dist/services/aiProposals.js.map +1 -0
  127. package/dist/services/auth.d.ts.map +1 -1
  128. package/dist/services/auth.js +10 -4
  129. package/dist/services/auth.js.map +1 -1
  130. package/dist/services/conversation.d.ts +1 -0
  131. package/dist/services/conversation.d.ts.map +1 -1
  132. package/dist/services/fees.d.ts.map +1 -1
  133. package/dist/services/fees.js +7 -3
  134. package/dist/services/fees.js.map +1 -1
  135. package/dist/services/labChat.d.ts +10 -0
  136. package/dist/services/labChat.d.ts.map +1 -1
  137. package/dist/services/labChat.js +52 -3
  138. package/dist/services/labChat.js.map +1 -1
  139. package/dist/services/message.d.ts +24 -8
  140. package/dist/services/message.d.ts.map +1 -1
  141. package/dist/services/message.js +170 -117
  142. package/dist/services/message.js.map +1 -1
  143. package/dist/services/module.d.ts.map +1 -1
  144. package/dist/services/module.js +23 -4
  145. package/dist/services/module.js.map +1 -1
  146. package/dist/services/parentPortal.d.ts +14 -0
  147. package/dist/services/parentPortal.d.ts.map +1 -1
  148. package/dist/services/parentPortal.js +72 -2
  149. package/dist/services/parentPortal.js.map +1 -1
  150. package/dist/services/studentProgress.d.ts +50 -0
  151. package/dist/services/studentProgress.d.ts.map +1 -1
  152. package/dist/services/studentProgress.js +107 -2
  153. package/dist/services/studentProgress.js.map +1 -1
  154. package/dist/services/visualization.d.ts +72 -0
  155. package/dist/services/visualization.d.ts.map +1 -0
  156. package/dist/services/visualization.js +122 -0
  157. package/dist/services/visualization.js.map +1 -0
  158. package/dist/utils/inference.d.ts +10 -0
  159. package/dist/utils/inference.d.ts.map +1 -1
  160. package/dist/utils/inference.js +77 -2
  161. package/dist/utils/inference.js.map +1 -1
  162. package/dist/utils/sentryTrpc.d.ts +3 -0
  163. package/dist/utils/sentryTrpc.d.ts.map +1 -0
  164. package/dist/utils/sentryTrpc.js +26 -0
  165. package/dist/utils/sentryTrpc.js.map +1 -0
  166. package/dist/workers/processors/createRunJobWorker.js +17 -16
  167. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  168. package/package.json +1 -1
  169. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  170. package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
  171. package/prisma/schema.prisma +32 -0
  172. package/src/index.ts +30 -4
  173. package/src/instrument.ts +8 -2
  174. package/src/lib/fileUpload.ts +49 -0
  175. package/src/models/cloudStorage.ts +19 -0
  176. package/src/models/labChat.ts +34 -0
  177. package/src/pipelines/aiLabChat.ts +79 -18
  178. package/src/pipelines/aiLabChatContract.ts +35 -0
  179. package/src/pipelines/aiNewtonChat.ts +34 -2
  180. package/src/pipelines/generateVisualization.ts +107 -0
  181. package/src/pipelines/labChatPrompt.ts +11 -12
  182. package/src/routers/_app.ts +2 -0
  183. package/src/routers/admin.ts +72 -0
  184. package/src/routers/class.ts +14 -1
  185. package/src/routers/labChat.ts +5 -0
  186. package/src/routers/message.ts +34 -5
  187. package/src/routers/parent.ts +11 -0
  188. package/src/routers/studentProgress.ts +15 -0
  189. package/src/routers/visualization.ts +60 -0
  190. package/src/routes/aiStream.ts +32 -0
  191. package/src/seedDatabase.ts +107 -2
  192. package/src/services/adminClass.ts +222 -0
  193. package/src/services/adminInstitution.ts +9 -2
  194. package/src/services/adminMetrics.ts +35 -0
  195. package/src/services/adminSpam.ts +224 -0
  196. package/src/services/adminSql.ts +214 -0
  197. package/src/services/adminTelemetry.ts +194 -0
  198. package/src/services/adminUser.ts +66 -11
  199. package/src/services/aiChatStream.ts +83 -7
  200. package/src/services/aiGeneration.ts +92 -0
  201. package/src/services/aiProposalTools.ts +166 -1
  202. package/src/services/aiProposals.ts +428 -0
  203. package/src/services/auth.ts +8 -2
  204. package/src/services/fees.ts +5 -1
  205. package/src/services/labChat.ts +65 -0
  206. package/src/services/message.ts +205 -142
  207. package/src/services/module.ts +29 -2
  208. package/src/services/parentPortal.ts +85 -0
  209. package/src/services/studentProgress.ts +135 -0
  210. package/src/services/visualization.ts +147 -0
  211. package/src/utils/inference.ts +85 -0
  212. package/src/utils/sentryTrpc.ts +25 -0
  213. package/src/workers/processors/createRunJobWorker.ts +17 -17
  214. package/tests/unit/lib/fileUploadSession.test.ts +67 -0
  215. package/tests/unit/services/adminClass.test.ts +102 -0
  216. package/tests/unit/services/adminSpam.test.ts +108 -0
  217. package/tests/unit/services/adminSql.test.ts +56 -0
  218. package/tests/unit/services/adminTelemetry.test.ts +47 -0
  219. package/tests/unit/services/adminUser.test.ts +77 -0
  220. package/tests/unit/services/visualization.test.ts +130 -0
  221. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
  222. package/tests/unit/utils/sentryTrpc.test.ts +15 -0
@@ -1,26 +1,70 @@
1
1
  import type { Prisma } from '@prisma/client';
2
2
  import { TRPCError } from '@trpc/server';
3
3
  import { prisma } from '../lib/prisma.js';
4
+ import { classifyUser, suspectedUserWhere } from './adminSpam.js';
5
+ import { listClassesForUserForAdmin } from './adminClass.js';
6
+
7
+ export type AdminUserSpamFilter = 'all' | 'suspected' | 'clean';
8
+ export type AdminUserActivityFilter = 'all' | 'active' | 'inactive';
9
+ export type AdminUserSort = 'username' | 'recent' | 'activity';
4
10
 
5
11
  export async function searchUsersForAdmin(input: {
6
12
  page: number;
7
13
  pageSize: number;
8
14
  query?: string;
15
+ emailDomain?: string;
16
+ schoolId?: string;
17
+ verified?: boolean;
18
+ spam?: AdminUserSpamFilter;
19
+ activity?: AdminUserActivityFilter;
20
+ sort?: AdminUserSort;
9
21
  }) {
10
22
  const skip = input.page * input.pageSize;
11
- const where: Prisma.UserWhereInput = {};
23
+ const filters: Prisma.UserWhereInput[] = [];
24
+
12
25
  const q = input.query?.trim();
13
26
  if (q) {
14
- where.OR = [
15
- { username: { contains: q, mode: 'insensitive' } },
16
- { email: { contains: q, mode: 'insensitive' } },
17
- ];
27
+ filters.push({
28
+ OR: [
29
+ { username: { contains: q, mode: 'insensitive' } },
30
+ { email: { contains: q, mode: 'insensitive' } },
31
+ ],
32
+ });
33
+ }
34
+
35
+ const domain = input.emailDomain?.trim().replace(/^@/, '');
36
+ if (domain) {
37
+ filters.push({ email: { endsWith: `@${domain}`, mode: 'insensitive' } });
38
+ }
39
+
40
+ if (input.schoolId) filters.push({ schoolId: input.schoolId });
41
+ if (input.verified !== undefined) filters.push({ verified: input.verified });
42
+
43
+ if (input.spam === 'suspected') filters.push(suspectedUserWhere());
44
+ if (input.spam === 'clean') filters.push({ NOT: suspectedUserWhere() });
45
+
46
+ // "Active" means the account is attached to at least one class — the cheapest
47
+ // proxy for a real account that is actually using the platform.
48
+ if (input.activity === 'active') {
49
+ filters.push({ OR: [{ teacherIn: { some: {} } }, { studentIn: { some: {} } }] });
50
+ }
51
+ if (input.activity === 'inactive') {
52
+ filters.push({ teacherIn: { none: {} }, studentIn: { none: {} } });
18
53
  }
19
54
 
55
+ const where: Prisma.UserWhereInput = filters.length ? { AND: filters } : {};
56
+
57
+ const orderBy: Prisma.UserOrderByWithRelationInput[] =
58
+ input.sort === 'recent'
59
+ ? [{ profile: { createdAt: 'desc' } }, { username: 'asc' }]
60
+ : input.sort === 'activity'
61
+ ? [{ submissions: { _count: 'desc' } }, { username: 'asc' }]
62
+ : [{ username: 'asc' }];
63
+
20
64
  const [items, total] = await Promise.all([
21
65
  prisma.user.findMany({
22
66
  where,
23
- orderBy: { username: 'asc' },
67
+ orderBy,
24
68
  skip,
25
69
  take: input.pageSize,
26
70
  select: {
@@ -28,9 +72,15 @@ export async function searchUsersForAdmin(input: {
28
72
  username: true,
29
73
  email: true,
30
74
  verified: true,
75
+ profile: { select: { createdAt: true } },
31
76
  school: { select: { id: true, name: true } },
32
77
  _count: {
33
- select: { teacherIn: true, studentIn: true, sessions: true },
78
+ select: {
79
+ teacherIn: true,
80
+ studentIn: true,
81
+ submissions: true,
82
+ sessions: { where: { type: 'AUTH' } },
83
+ },
34
84
  },
35
85
  },
36
86
  }),
@@ -38,7 +88,7 @@ export async function searchUsersForAdmin(input: {
38
88
  ]);
39
89
 
40
90
  return {
41
- items,
91
+ items: items.map((user) => ({ ...user, signals: classifyUser(user) })),
42
92
  total,
43
93
  page: input.page,
44
94
  pageSize: input.pageSize,
@@ -70,15 +120,18 @@ export async function getUserForAdmin(id: string) {
70
120
  },
71
121
  },
72
122
  sessions: {
123
+ where: { type: 'AUTH' },
73
124
  orderBy: { createdAt: 'desc' },
74
125
  take: 10,
75
- select: { id: true, type: true, createdAt: true, expiresAt: true },
126
+ // Session.id is the bearer token — never return it from admin APIs.
127
+ select: { type: true, createdAt: true, expiresAt: true },
76
128
  },
77
129
  _count: {
78
130
  select: {
79
131
  teacherIn: true,
80
132
  studentIn: true,
81
- sessions: true,
133
+ // Keep the count consistent with the AUTH-only session list above.
134
+ sessions: { where: { type: 'AUTH' } },
82
135
  submissions: true,
83
136
  files: true,
84
137
  },
@@ -89,5 +142,7 @@ export async function getUserForAdmin(id: string) {
89
142
  if (!user) {
90
143
  throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
91
144
  }
92
- return user;
145
+
146
+ const classes = await listClassesForUserForAdmin(id);
147
+ return { ...user, classes, signals: classifyUser(user) };
93
148
  }
@@ -14,12 +14,34 @@
14
14
  import { streamText, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
15
15
  import { openai } from '@ai-sdk/openai';
16
16
  import { getRedis } from '../lib/redis.js';
17
+ import { prisma } from '../lib/prisma.js';
17
18
  import { pusher, chatChannel } from '../lib/pusher.js';
18
19
  import { logger } from '../utils/logger.js';
19
20
  import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
20
21
 
21
22
  const STREAM_REDIS_TTL = 600; // 10 minutes
22
23
  const STREAM_KEY_PREFIX = 'stream:';
24
+ const CANCEL_KEY_PREFIX = 'stream:cancel:';
25
+ const CANCEL_CHECK_INTERVAL_MS = 1_000;
26
+
27
+ function streamCancelKey(aiMessageId: string): string {
28
+ return `${CANCEL_KEY_PREFIX}${aiMessageId}`;
29
+ }
30
+
31
+ /**
32
+ * Flag an in-flight stream for cancellation. `streamChatResponse` polls this
33
+ * key between chunks and aborts the model stream when it appears. The worker
34
+ * may run in a different process from the API, so the signal goes through
35
+ * Redis rather than an in-memory AbortController registry.
36
+ */
37
+ export async function requestStreamCancellation(
38
+ aiMessageId: string,
39
+ ): Promise<void> {
40
+ const redis = getRedis();
41
+ if (redis) {
42
+ await redis.set(streamCancelKey(aiMessageId), '1', 'EX', STREAM_REDIS_TTL);
43
+ }
44
+ }
23
45
 
24
46
  /** Default streaming model — matches the existing streamAIResponse path. */
25
47
  export const DEFAULT_STREAM_MODEL = 'gpt-4o-mini';
@@ -53,6 +75,10 @@ export type StreamPart =
53
75
  /** Set after generated files are persisted on the message. */
54
76
  fileId?: string;
55
77
  fileName?: string;
78
+ /** Links a propose_* tool call to its pending creation in message meta. */
79
+ proposalId?: string;
80
+ /** Class-files folder the generated file was saved into, if any. */
81
+ folderId?: string;
56
82
  status: 'executing' | 'completed' | 'failed';
57
83
  };
58
84
 
@@ -61,6 +87,7 @@ export interface GeneratedDocFileLink {
61
87
  fileId: string;
62
88
  fileName: string;
63
89
  format?: string;
90
+ folderId?: string;
64
91
  }
65
92
 
66
93
  /** Apply pipeline-resolved formats onto generate_document tool parts. */
@@ -136,6 +163,7 @@ export function enrichStreamPartsWithGeneratedFiles(
136
163
  fileName: file.fileName,
137
164
  status: 'completed',
138
165
  ...(file.format ? { format: file.format } : {}),
166
+ ...(file.folderId ? { folderId: file.folderId } : {}),
139
167
  };
140
168
  });
141
169
  }
@@ -162,7 +190,7 @@ function toolFieldFromInput(input: unknown, key: string): string | undefined {
162
190
 
163
191
  /** Pull a human-meaningful label out of a tool's input args, if present. */
164
192
  function toolTitleFromInput(input: unknown): string | undefined {
165
- for (const key of ['title', 'name', 'query'] as const) {
193
+ for (const key of ['targetTitle', 'title', 'name', 'query'] as const) {
166
194
  const val = toolFieldFromInput(input, key);
167
195
  if (val) return val;
168
196
  }
@@ -191,6 +219,9 @@ export interface StreamChatResult {
191
219
  toolCalls: StreamedToolCall[];
192
220
  /** Ordered text/tool parts, in the exact order the model produced them. */
193
221
  parts: StreamPart[];
222
+ /** True when the stream was stopped by a cancellation request; `text` and
223
+ * `parts` hold whatever was produced before the abort. */
224
+ cancelled: boolean;
194
225
  }
195
226
 
196
227
  /**
@@ -218,11 +249,39 @@ export async function streamChatResponse(
218
249
 
219
250
  const redis = getRedis();
220
251
  const streamKey = `${STREAM_KEY_PREFIX}${aiMessageId}`;
252
+ const cancelKey = streamCancelKey(aiMessageId);
221
253
  if (redis) {
222
254
  await redis.set(streamKey, '', 'EX', STREAM_REDIS_TTL);
255
+ // A stale flag from a previously cancelled run on the same message id
256
+ // (e.g. a rerun) must not kill this stream instantly.
257
+ await redis.del(cancelKey).catch(() => {});
223
258
  }
224
259
 
225
260
  let fullText = '';
261
+ let cancelled = false;
262
+ let lastCancelCheck = 0;
263
+ const abortController = new AbortController();
264
+
265
+ // Poll the cancel flag at most once a second: Redis when available,
266
+ // otherwise the message row (the cancel mutation flips it to CANCELLED).
267
+ const checkCancelled = async (): Promise<boolean> => {
268
+ if (cancelled) return true;
269
+ const now = Date.now();
270
+ if (now - lastCancelCheck < CANCEL_CHECK_INTERVAL_MS) return false;
271
+ lastCancelCheck = now;
272
+ if (redis) {
273
+ const flag = await redis.get(cancelKey).catch(() => null);
274
+ if (flag) cancelled = true;
275
+ } else {
276
+ const row = await prisma.message
277
+ .findUnique({ where: { id: aiMessageId }, select: { status: true } })
278
+ .catch(() => null);
279
+ if (row?.status === 'CANCELLED') cancelled = true;
280
+ }
281
+ if (cancelled) abortController.abort();
282
+ return cancelled;
283
+ };
284
+
226
285
  const parts: StreamPart[] = [];
227
286
  const toolCalls: StreamedToolCall[] = [];
228
287
  // Map AI SDK toolCallId -> index in `parts` so the executing indicator can
@@ -256,13 +315,20 @@ export async function streamChatResponse(
256
315
  temperature,
257
316
  maxOutputTokens,
258
317
  stopWhen: stepCountIs(maxSteps),
318
+ abortSignal: abortController.signal,
259
319
  });
260
320
 
261
321
  // `fullStream` yields text deltas AND tool events in a single, correctly
262
322
  // ordered sequence — this is what makes inline tool calls possible. We
263
323
  // build an ordered `parts` list and broadcast each event as it happens.
264
- for await (const part of result.fullStream) {
265
- switch (part.type) {
324
+ try {
325
+ for await (const part of result.fullStream) {
326
+ if (await checkCancelled()) break;
327
+ if (part.type === 'abort') {
328
+ cancelled = true;
329
+ break;
330
+ }
331
+ switch (part.type) {
266
332
  case 'text-delta': {
267
333
  appendText(part.text);
268
334
  if (redis) {
@@ -333,10 +399,19 @@ export async function streamChatResponse(
333
399
  });
334
400
  break;
335
401
  }
336
- default:
337
- // ignore reasoning/source/step/finish/raw parts
338
- break;
402
+ default:
403
+ // ignore reasoning/source/step/finish/raw parts
404
+ break;
405
+ }
339
406
  }
407
+ } catch (streamError) {
408
+ // Aborting the model stream mid-iteration surfaces as an error from
409
+ // `fullStream`; when it is our own cancellation, keep the partials.
410
+ if (!cancelled) throw streamError;
411
+ }
412
+
413
+ if (cancelled) {
414
+ logger.info('AI chat stream cancelled', { aiMessageId, conversationId });
340
415
  }
341
416
 
342
417
  const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
@@ -356,7 +431,7 @@ export async function streamChatResponse(
356
431
  attribution,
357
432
  });
358
433
 
359
- return { text: fullText, toolCalls, parts };
434
+ return { text: fullText, toolCalls, parts, cancelled };
360
435
  } catch (error) {
361
436
  await logInferenceUsage({
362
437
  model,
@@ -371,6 +446,7 @@ export async function streamChatResponse(
371
446
  } finally {
372
447
  if (redis) {
373
448
  await redis.del(streamKey).catch(() => {});
449
+ await redis.del(cancelKey).catch(() => {});
374
450
  }
375
451
  }
376
452
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * AI generation cancellation service.
3
+ *
4
+ * Cancels an in-flight AI chat generation (lab chat / Newton chat): flags the
5
+ * stream for abort via Redis so the worker's `streamChatResponse` loop stops,
6
+ * marks the linked RunJob CANCELLED (so a still-queued job never runs), and
7
+ * flips the placeholder message to CANCELLED immediately. The pipeline then
8
+ * persists whatever partial text/tool parts had already streamed.
9
+ */
10
+ import { TRPCError } from "@trpc/server";
11
+ import { JobStatus } from "@prisma/client";
12
+ import { prisma } from "../lib/prisma.js";
13
+ import { chatChannel, pusher } from "../lib/pusher.js";
14
+ import { isAIUser } from "../utils/aiUser.js";
15
+ import { requestStreamCancellation } from "./aiChatStream.js";
16
+ import { logger } from "../utils/logger.js";
17
+
18
+ export async function cancelAiGeneration(userId: string, messageId: string) {
19
+ const message = await prisma.message.findFirst({
20
+ where: {
21
+ id: messageId,
22
+ conversation: { members: { some: { userId } } },
23
+ },
24
+ select: {
25
+ id: true,
26
+ senderId: true,
27
+ status: true,
28
+ conversationId: true,
29
+ },
30
+ });
31
+
32
+ if (!message || !isAIUser(message.senderId)) {
33
+ throw new TRPCError({
34
+ code: "NOT_FOUND",
35
+ message: "AI message not found or access denied",
36
+ });
37
+ }
38
+
39
+ if (message.status !== JobStatus.PENDING) {
40
+ throw new TRPCError({
41
+ code: "BAD_REQUEST",
42
+ message: "No generation in progress for this message",
43
+ });
44
+ }
45
+
46
+ // Signal the in-flight stream (worker process) to abort.
47
+ await requestStreamCancellation(messageId);
48
+
49
+ // Mark the backing RunJob CANCELLED so a queued-but-not-started job is
50
+ // skipped by the worker, and the audit record reflects the stop.
51
+ await prisma.runJob.updateMany({
52
+ where: {
53
+ status: { in: ["PENDING", "RUNNING"] },
54
+ input: { path: ["aiMessageId"], equals: messageId },
55
+ },
56
+ data: {
57
+ status: "CANCELLED",
58
+ error: "Cancelled by user",
59
+ finishedAt: new Date(),
60
+ },
61
+ });
62
+
63
+ // Flip the placeholder to CANCELLED right away: this is both the instant
64
+ // UI signal and the durable cancel marker the stream loop falls back to
65
+ // when Redis is unavailable. The pipeline later fills in partial content.
66
+ const updated = await prisma.message.update({
67
+ where: { id: messageId },
68
+ data: { status: JobStatus.CANCELLED },
69
+ select: { id: true, conversationId: true, status: true },
70
+ });
71
+
72
+ logger.info("AI generation cancellation requested", {
73
+ messageId,
74
+ conversationId: message.conversationId,
75
+ userId,
76
+ });
77
+
78
+ try {
79
+ await pusher.trigger(chatChannel(updated.conversationId), "message-updated", {
80
+ id: updated.id,
81
+ conversationId: updated.conversationId,
82
+ status: updated.status,
83
+ });
84
+ } catch (broadcastError) {
85
+ logger.error("Failed to broadcast cancelled AI generation:", {
86
+ error: broadcastError,
87
+ messageId,
88
+ });
89
+ }
90
+
91
+ return { success: true };
92
+ }
@@ -22,6 +22,9 @@ import type {
22
22
  WorksheetToCreate,
23
23
  SectionToCreate,
24
24
  DocToCreate,
25
+ AssignmentUpdate,
26
+ WorksheetUpdate,
27
+ SectionUpdate,
25
28
  } from '../pipelines/aiLabChatContract.js';
26
29
 
27
30
  export interface LabProposals {
@@ -29,10 +32,21 @@ export interface LabProposals {
29
32
  worksheets: WorksheetToCreate[];
30
33
  sections: SectionToCreate[];
31
34
  docs: DocToCreate[];
35
+ assignmentUpdates: AssignmentUpdate[];
36
+ worksheetUpdates: WorksheetUpdate[];
37
+ sectionUpdates: SectionUpdate[];
32
38
  }
33
39
 
34
40
  export function createLabProposals(): LabProposals {
35
- return { assignments: [], worksheets: [], sections: [], docs: [] };
41
+ return {
42
+ assignments: [],
43
+ worksheets: [],
44
+ sections: [],
45
+ docs: [],
46
+ assignmentUpdates: [],
47
+ worksheetUpdates: [],
48
+ sectionUpdates: [],
49
+ };
36
50
  }
37
51
 
38
52
  function normalizeDueDate(input?: string | null): string {
@@ -82,6 +96,34 @@ interface ProposeAssignmentArgs {
82
96
  acceptWorksheet?: boolean;
83
97
  }
84
98
 
99
+ interface ProposeUpdateAssignmentArgs {
100
+ id: string;
101
+ targetTitle: string;
102
+ title?: string;
103
+ instructions?: string;
104
+ dueDate?: string;
105
+ type?: AssignmentType;
106
+ maxGrade?: number;
107
+ acceptFiles?: boolean;
108
+ acceptExtendedResponse?: boolean;
109
+ acceptWorksheet?: boolean;
110
+ sectionId?: string | null;
111
+ }
112
+
113
+ interface ProposeUpdateWorksheetArgs {
114
+ id: string;
115
+ targetTitle: string;
116
+ name?: string;
117
+ addQuestions?: ProposeWorksheetArgs['questions'];
118
+ }
119
+
120
+ interface ProposeUpdateSectionArgs {
121
+ id: string;
122
+ targetTitle: string;
123
+ name: string;
124
+ color?: string | null;
125
+ }
126
+
85
127
  interface GenerateDocumentArgs {
86
128
  title: string;
87
129
  content: string;
@@ -235,6 +277,129 @@ export function labProposalTools(proposals: LabProposals): ToolSet {
235
277
  },
236
278
  },
237
279
 
280
+ propose_update_assignment: {
281
+ description:
282
+ 'Propose changes to an EXISTING assignment. Only pass the fields that should change; use the assignment id from CLASS CONTEXT.',
283
+ inputSchema: jsonSchema({
284
+ type: 'object',
285
+ properties: {
286
+ id: { type: 'string', description: 'Database id of the existing assignment' },
287
+ targetTitle: { type: 'string', description: "The assignment's current title (shown on the approval card)" },
288
+ title: { type: 'string', description: 'New title, if renaming' },
289
+ instructions: { type: 'string' },
290
+ dueDate: { type: 'string', description: 'ISO 8601 datetime' },
291
+ type: { type: 'string', enum: ASSIGNMENT_TYPES as unknown as string[] },
292
+ maxGrade: { type: 'number' },
293
+ acceptFiles: { type: 'boolean' },
294
+ acceptExtendedResponse: { type: 'boolean' },
295
+ acceptWorksheet: { type: 'boolean' },
296
+ sectionId: { type: 'string', description: 'Move to this section (id from CLASS CONTEXT)' },
297
+ },
298
+ required: ['id', 'targetTitle'],
299
+ }),
300
+ execute: async (args: ProposeUpdateAssignmentArgs) => {
301
+ proposals.assignmentUpdates.push({
302
+ ...args,
303
+ dueDate: args.dueDate ? normalizeDueDate(args.dueDate) : undefined,
304
+ });
305
+ return `Update to assignment "${args.targetTitle}" proposed.`;
306
+ },
307
+ },
308
+
309
+ propose_update_worksheet: {
310
+ description:
311
+ 'Propose changes to an EXISTING worksheet: rename it and/or append new questions. Use the worksheet id from CLASS CONTEXT.',
312
+ inputSchema: jsonSchema({
313
+ type: 'object',
314
+ properties: {
315
+ id: { type: 'string', description: 'Database id of the existing worksheet' },
316
+ targetTitle: { type: 'string', description: "The worksheet's current name (shown on the approval card)" },
317
+ name: { type: 'string', description: 'New name, if renaming' },
318
+ addQuestions: {
319
+ type: 'array',
320
+ description: 'New questions to append to the worksheet',
321
+ items: {
322
+ type: 'object',
323
+ properties: {
324
+ type: { type: 'string', enum: QUESTION_TYPES as unknown as string[] },
325
+ question: { type: 'string' },
326
+ answer: { type: 'string' },
327
+ options: {
328
+ type: 'array',
329
+ items: {
330
+ type: 'object',
331
+ properties: {
332
+ text: { type: 'string' },
333
+ isCorrect: { type: 'boolean' },
334
+ },
335
+ required: ['text', 'isCorrect'],
336
+ },
337
+ },
338
+ markScheme: {
339
+ type: 'array',
340
+ items: {
341
+ type: 'object',
342
+ properties: {
343
+ points: { type: 'number' },
344
+ description: { type: 'string' },
345
+ },
346
+ required: ['points', 'description'],
347
+ },
348
+ },
349
+ points: { type: 'number' },
350
+ },
351
+ required: ['type', 'question'],
352
+ },
353
+ },
354
+ },
355
+ required: ['id', 'targetTitle'],
356
+ }),
357
+ execute: async ({ id, targetTitle, name, addQuestions }: ProposeUpdateWorksheetArgs) => {
358
+ proposals.worksheetUpdates.push({
359
+ id,
360
+ targetTitle,
361
+ name,
362
+ addQuestions: (addQuestions ?? []).map((q, index) => ({
363
+ type: q.type,
364
+ question: q.question,
365
+ answer: q.answer ?? '',
366
+ options: (q.options ?? []).map((o) => ({
367
+ id: v4(),
368
+ text: o.text,
369
+ isCorrect: o.isCorrect,
370
+ })),
371
+ markScheme: (q.markScheme ?? []).map((m) => ({
372
+ id: v4(),
373
+ points: m.points,
374
+ description: m.description,
375
+ })),
376
+ points: q.points ?? 0,
377
+ order: index,
378
+ })),
379
+ });
380
+ return `Update to worksheet "${targetTitle}" proposed.`;
381
+ },
382
+ },
383
+
384
+ propose_update_section: {
385
+ description:
386
+ 'Propose changes to an EXISTING section (rename and/or recolor). Use the section id from CLASS CONTEXT. Always pass name (the resulting name, even if unchanged).',
387
+ inputSchema: jsonSchema({
388
+ type: 'object',
389
+ properties: {
390
+ id: { type: 'string', description: 'Database id of the existing section' },
391
+ targetTitle: { type: 'string', description: "The section's current name (shown on the approval card)" },
392
+ name: { type: 'string', description: 'Resulting section name (pass the current name if only changing color)' },
393
+ color: { type: 'string', description: 'Optional hex color like #3b82f6' },
394
+ },
395
+ required: ['id', 'targetTitle', 'name'],
396
+ }),
397
+ execute: async ({ id, targetTitle, name, color }: ProposeUpdateSectionArgs) => {
398
+ proposals.sectionUpdates.push({ id, targetTitle, name, color: color ?? null });
399
+ return `Update to section "${targetTitle}" proposed.`;
400
+ },
401
+ },
402
+
238
403
  generate_document: {
239
404
  description:
240
405
  'Generate a downloadable file and attach it to this message. Pick the best format for the request — pptx for slides, xlsx/csv for data, docx for editable handouts, pdf for print-ready docs, png/jpeg for charts/diagrams/figures (NOT pdf for visuals). Use py/js/ts only when the teacher explicitly wants source code. Always set format explicitly.',