@studious-lms/server 1.26.0 → 1.27.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 (129) 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/models/cloudStorage.d.ts +11 -0
  5. package/dist/models/cloudStorage.d.ts.map +1 -1
  6. package/dist/models/cloudStorage.js +18 -2
  7. package/dist/models/cloudStorage.js.map +1 -1
  8. package/dist/models/conversation.d.ts +1 -0
  9. package/dist/models/conversation.d.ts.map +1 -1
  10. package/dist/models/labChat.d.ts +40 -0
  11. package/dist/models/labChat.d.ts.map +1 -1
  12. package/dist/models/labChat.js +32 -2
  13. package/dist/models/labChat.js.map +1 -1
  14. package/dist/models/message.d.ts +3 -0
  15. package/dist/models/message.d.ts.map +1 -1
  16. package/dist/pipelines/aiLabChat.d.ts +2 -0
  17. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  18. package/dist/pipelines/aiLabChat.js +60 -16
  19. package/dist/pipelines/aiLabChat.js.map +1 -1
  20. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  21. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  22. package/dist/pipelines/aiLabChatContract.js +30 -2
  23. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  24. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  25. package/dist/pipelines/aiNewtonChat.js +30 -5
  26. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  27. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  28. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  29. package/dist/pipelines/labChatPrompt.js +13 -14
  30. package/dist/pipelines/labChatPrompt.js.map +1 -1
  31. package/dist/routers/_app.d.ts +220 -10
  32. package/dist/routers/_app.d.ts.map +1 -1
  33. package/dist/routers/admin.d.ts +0 -1
  34. package/dist/routers/admin.d.ts.map +1 -1
  35. package/dist/routers/conversation.d.ts +1 -0
  36. package/dist/routers/conversation.d.ts.map +1 -1
  37. package/dist/routers/labChat.d.ts +10 -0
  38. package/dist/routers/labChat.d.ts.map +1 -1
  39. package/dist/routers/labChat.js +6 -3
  40. package/dist/routers/labChat.js.map +1 -1
  41. package/dist/routers/message.d.ts +37 -4
  42. package/dist/routers/message.d.ts.map +1 -1
  43. package/dist/routers/message.js +24 -7
  44. package/dist/routers/message.js.map +1 -1
  45. package/dist/routers/parent.d.ts +18 -0
  46. package/dist/routers/parent.d.ts.map +1 -1
  47. package/dist/routers/parent.js +10 -3
  48. package/dist/routers/parent.js.map +1 -1
  49. package/dist/routers/studentProgress.d.ts +44 -0
  50. package/dist/routers/studentProgress.d.ts.map +1 -1
  51. package/dist/routers/studentProgress.js +12 -3
  52. package/dist/routers/studentProgress.js.map +1 -1
  53. package/dist/routes/aiStream.js +32 -2
  54. package/dist/routes/aiStream.js.map +1 -1
  55. package/dist/seedDatabase.d.ts.map +1 -1
  56. package/dist/seedDatabase.js +107 -3
  57. package/dist/seedDatabase.js.map +1 -1
  58. package/dist/services/adminUser.d.ts +0 -1
  59. package/dist/services/adminUser.d.ts.map +1 -1
  60. package/dist/services/adminUser.js +7 -4
  61. package/dist/services/adminUser.js.map +1 -1
  62. package/dist/services/aiChatStream.d.ts +15 -0
  63. package/dist/services/aiChatStream.d.ts.map +1 -1
  64. package/dist/services/aiChatStream.js +144 -74
  65. package/dist/services/aiChatStream.js.map +1 -1
  66. package/dist/services/aiGeneration.d.ts +4 -0
  67. package/dist/services/aiGeneration.d.ts.map +1 -0
  68. package/dist/services/aiGeneration.js +88 -0
  69. package/dist/services/aiGeneration.js.map +1 -0
  70. package/dist/services/aiProposalTools.d.ts +4 -1
  71. package/dist/services/aiProposalTools.d.ts.map +1 -1
  72. package/dist/services/aiProposalTools.js +128 -3
  73. package/dist/services/aiProposalTools.js.map +1 -1
  74. package/dist/services/aiProposals.d.ts +70 -0
  75. package/dist/services/aiProposals.d.ts.map +1 -0
  76. package/dist/services/aiProposals.js +363 -0
  77. package/dist/services/aiProposals.js.map +1 -0
  78. package/dist/services/conversation.d.ts +1 -0
  79. package/dist/services/conversation.d.ts.map +1 -1
  80. package/dist/services/labChat.d.ts +10 -0
  81. package/dist/services/labChat.d.ts.map +1 -1
  82. package/dist/services/labChat.js +52 -3
  83. package/dist/services/labChat.js.map +1 -1
  84. package/dist/services/message.d.ts +24 -8
  85. package/dist/services/message.d.ts.map +1 -1
  86. package/dist/services/message.js +170 -117
  87. package/dist/services/message.js.map +1 -1
  88. package/dist/services/parentPortal.d.ts +14 -0
  89. package/dist/services/parentPortal.d.ts.map +1 -1
  90. package/dist/services/parentPortal.js +72 -2
  91. package/dist/services/parentPortal.js.map +1 -1
  92. package/dist/services/studentProgress.d.ts +50 -0
  93. package/dist/services/studentProgress.d.ts.map +1 -1
  94. package/dist/services/studentProgress.js +107 -2
  95. package/dist/services/studentProgress.js.map +1 -1
  96. package/dist/utils/inference.d.ts +10 -0
  97. package/dist/utils/inference.d.ts.map +1 -1
  98. package/dist/utils/inference.js +77 -2
  99. package/dist/utils/inference.js.map +1 -1
  100. package/dist/workers/processors/createRunJobWorker.js +17 -16
  101. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  102. package/package.json +1 -1
  103. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  104. package/prisma/schema.prisma +8 -0
  105. package/src/models/cloudStorage.ts +19 -0
  106. package/src/models/labChat.ts +34 -0
  107. package/src/pipelines/aiLabChat.ts +79 -18
  108. package/src/pipelines/aiLabChatContract.ts +35 -0
  109. package/src/pipelines/aiNewtonChat.ts +34 -2
  110. package/src/pipelines/labChatPrompt.ts +11 -12
  111. package/src/routers/labChat.ts +5 -0
  112. package/src/routers/message.ts +34 -5
  113. package/src/routers/parent.ts +11 -0
  114. package/src/routers/studentProgress.ts +15 -0
  115. package/src/routes/aiStream.ts +32 -0
  116. package/src/seedDatabase.ts +107 -2
  117. package/src/services/adminUser.ts +5 -2
  118. package/src/services/aiChatStream.ts +83 -7
  119. package/src/services/aiGeneration.ts +92 -0
  120. package/src/services/aiProposalTools.ts +166 -1
  121. package/src/services/aiProposals.ts +428 -0
  122. package/src/services/labChat.ts +65 -0
  123. package/src/services/message.ts +205 -142
  124. package/src/services/parentPortal.ts +85 -0
  125. package/src/services/studentProgress.ts +135 -0
  126. package/src/utils/inference.ts +85 -0
  127. package/src/workers/processors/createRunJobWorker.ts +17 -17
  128. package/tests/unit/services/adminUser.test.ts +75 -0
  129. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
@@ -9,9 +9,12 @@ import {
9
9
  confirmMessageUpload,
10
10
  markAsRead,
11
11
  markMentionsAsRead,
12
- markSuggestionCreated,
13
12
  getUnreadCount,
13
+ regenerateAIResponse,
14
+ setAIMessageFeedback,
14
15
  } from "../services/message.js";
16
+ import { resolveAiProposal } from "../services/aiProposals.js";
17
+ import { cancelAiGeneration } from "../services/aiGeneration.js";
15
18
 
16
19
  export const messageRouter = createTRPCRouter({
17
20
  list: protectedProcedure
@@ -101,6 +104,12 @@ export const messageRouter = createTRPCRouter({
101
104
  .input(z.object({ messageId: z.string() }))
102
105
  .mutation(({ ctx, input }) => deleteMessage(ctx.user!.id, input.messageId)),
103
106
 
107
+ cancelAiGeneration: protectedProcedure
108
+ .input(z.object({ messageId: z.string() }))
109
+ .mutation(({ ctx, input }) =>
110
+ cancelAiGeneration(ctx.user!.id, input.messageId)
111
+ ),
112
+
104
113
  markAsRead: protectedProcedure
105
114
  .input(z.object({ conversationId: z.string() }))
106
115
  .mutation(({ ctx, input }) =>
@@ -113,16 +122,16 @@ export const messageRouter = createTRPCRouter({
113
122
  markMentionsAsRead(ctx.user!.id, input.conversationId)
114
123
  ),
115
124
 
116
- markSuggestionCreated: protectedProcedure
125
+ resolveAiProposal: protectedProcedure
117
126
  .input(
118
127
  z.object({
119
128
  messageId: z.string(),
120
- type: z.enum(["assignment", "worksheet", "section"]),
121
- index: z.number().int().min(0),
129
+ proposalId: z.string(),
130
+ action: z.enum(["approve", "dismiss"]),
122
131
  })
123
132
  )
124
133
  .mutation(({ ctx, input }) =>
125
- markSuggestionCreated(ctx.user!.id, input)
134
+ resolveAiProposal(ctx.user!.id, input)
126
135
  ),
127
136
 
128
137
  getUnreadCount: protectedProcedure
@@ -130,4 +139,24 @@ export const messageRouter = createTRPCRouter({
130
139
  .query(({ ctx, input }) =>
131
140
  getUnreadCount(ctx.user!.id, input.conversationId)
132
141
  ),
142
+
143
+ regenerateAIResponse: protectedProcedure
144
+ .input(z.object({ messageId: z.string() }))
145
+ .mutation(({ ctx, input }) =>
146
+ regenerateAIResponse(ctx.user!.id, input.messageId)
147
+ ),
148
+
149
+ setAIFeedback: protectedProcedure
150
+ .input(
151
+ z.object({
152
+ messageId: z.string(),
153
+ feedback: z.enum(["UP", "DOWN"]).nullable(),
154
+ })
155
+ )
156
+ .mutation(({ ctx, input }) =>
157
+ setAIMessageFeedback(ctx.user!.id, {
158
+ messageId: input.messageId,
159
+ feedback: input.feedback,
160
+ })
161
+ ),
133
162
  });
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
3
  import {
4
+ addParentToStudent,
4
5
  getChildAttendance,
5
6
  getChildGrades,
6
7
  linkParentToStudent,
@@ -20,6 +21,16 @@ export const parentRouter = createTRPCRouter({
20
21
  )
21
22
  .mutation(({ ctx, input }) => linkParentToStudent(ctx.user!.id, input)),
22
23
 
24
+ addToStudent: protectedProcedure
25
+ .input(
26
+ z.object({
27
+ schoolId: z.string(),
28
+ studentUserId: z.string(),
29
+ parentIdentifier: z.string().min(1),
30
+ })
31
+ )
32
+ .mutation(({ ctx, input }) => addParentToStudent(ctx.user!.id, input)),
33
+
23
34
  unlinkStudent: protectedProcedure
24
35
  .input(
25
36
  z.object({
@@ -7,6 +7,7 @@ import {
7
7
  import {
8
8
  chatAboutStudentProgress,
9
9
  dismissStudentRecommendation,
10
+ generateFollowUpAssignment,
10
11
  getClassProgressRecommendations,
11
12
  getStudentProgressFocus,
12
13
  getStudentProgressForecast,
@@ -66,6 +67,20 @@ export const studentProgressRouter = createTRPCRouter({
66
67
  dismissStudentRecommendation(ctx.user!.id, input),
67
68
  ),
68
69
 
70
+ generateFollowUpAssignment: protectedTeacherProcedure
71
+ .input(
72
+ z.object({
73
+ classId: z.string(),
74
+ studentId: z.string(),
75
+ submissionId: z.string(),
76
+ prompt: z.string().min(1).max(2000),
77
+ dueDate: z.string().datetime().optional(),
78
+ }),
79
+ )
80
+ .mutation(({ ctx, input }) =>
81
+ generateFollowUpAssignment(ctx.user!.id, input),
82
+ ),
83
+
69
84
  chat: protectedClassMemberProcedure
70
85
  .input(
71
86
  progressInputSchema.extend({
@@ -453,6 +453,33 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
453
453
  Connection: 'keep-alive',
454
454
  });
455
455
 
456
+ // Narration is mirrored to other open editors as batched `modifier-chunk`
457
+ // events so their overlay streams live without a Pusher trigger per token.
458
+ const CHUNK_FLUSH_INTERVAL_MS = 250;
459
+ const CHUNK_FLUSH_MAX_CHARS = 500;
460
+ let chunkBuffer = '';
461
+ let lastChunkFlush = 0;
462
+ const flushChunks = async (force = false) => {
463
+ if (!chunkBuffer) return;
464
+ const now = Date.now();
465
+ if (
466
+ !force &&
467
+ now - lastChunkFlush < CHUNK_FLUSH_INTERVAL_MS &&
468
+ chunkBuffer.length < CHUNK_FLUSH_MAX_CHARS
469
+ ) {
470
+ return;
471
+ }
472
+ const payload = chunkBuffer;
473
+ chunkBuffer = '';
474
+ lastChunkFlush = now;
475
+ await pusher.trigger(channel, 'modifier-chunk', {
476
+ resourceId,
477
+ chunk: payload,
478
+ }).catch((err: unknown) => {
479
+ logger.warn('Failed to broadcast modifier-chunk', { err, resourceId });
480
+ });
481
+ };
482
+
456
483
  // Called after a tool persists a single field — apply it live everywhere.
457
484
  const onFieldUpdate = async (update: ModifierFieldUpdate) => {
458
485
  await pusher.trigger(channel, 'modifier-field-updated', {
@@ -466,6 +493,8 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
466
493
  });
467
494
  // Human-readable progress for the requesting client's bar.
468
495
  res.write(`Updated ${update.label}.\n`);
496
+ chunkBuffer += `Updated ${update.label}.\n`;
497
+ await flushChunks();
469
498
  };
470
499
 
471
500
  try {
@@ -490,7 +519,10 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
490
519
  // call onFieldUpdate); any narration text is forwarded to the SSE client.
491
520
  for await (const chunk of result.textStream) {
492
521
  res.write(chunk);
522
+ chunkBuffer += chunk;
523
+ await flushChunks();
493
524
  }
525
+ await flushChunks(true);
494
526
 
495
527
  // Clean up Redis flag and broadcast completion
496
528
  if (redis) {
@@ -1653,14 +1653,119 @@ export const seedDatabase = async () => {
1653
1653
  createdAt: hoursFromNow(-3, now),
1654
1654
  },
1655
1655
  });
1656
+ // AI reply with creation proposals in all three states (pending card,
1657
+ // approved row, dismissed row) so the approval flow is demo-able.
1658
+ const labIntroText =
1659
+ "Here's what I put together for the organelle functions unit. I drafted a 10-question quiz worksheet, and I'm proposing a graded quiz assignment plus a new section to keep the unit organized:";
1660
+ const labProposals = [
1661
+ {
1662
+ id: "seed-proposal-worksheet",
1663
+ kind: "worksheet",
1664
+ title: "Organelle Functions Quiz (10 MCQs)",
1665
+ status: "approved",
1666
+ createdId: geneticsWorksheet.id,
1667
+ payload: {
1668
+ title: "Organelle Functions Quiz (10 MCQs)",
1669
+ questions: Array.from({ length: 10 }, (_, i) => ({
1670
+ type: "MULTIPLE_CHOICE",
1671
+ question: `Organelle question ${i + 1}`,
1672
+ answer: "A",
1673
+ options: [],
1674
+ markScheme: [],
1675
+ points: 1,
1676
+ order: i,
1677
+ })),
1678
+ },
1679
+ },
1680
+ {
1681
+ id: "seed-proposal-assignment",
1682
+ kind: "assignment",
1683
+ title: "Organelle Functions Quiz",
1684
+ status: "pending",
1685
+ payload: {
1686
+ title: "Organelle Functions Quiz",
1687
+ instructions:
1688
+ "Complete the 10-question multiple choice quiz on organelle functions. One attempt, 20 minutes.",
1689
+ dueDate: hoursFromNow(7 * 24, now).toISOString(),
1690
+ acceptFiles: false,
1691
+ acceptExtendedResponse: false,
1692
+ acceptWorksheet: true,
1693
+ maxGrade: 10,
1694
+ worksheetIds: [],
1695
+ studentIds: [],
1696
+ type: "QUIZ",
1697
+ attachments: [],
1698
+ },
1699
+ },
1700
+ {
1701
+ id: "seed-proposal-section",
1702
+ kind: "section",
1703
+ title: "Unit 3: Cell Structure",
1704
+ status: "dismissed",
1705
+ payload: { name: "Unit 3: Cell Structure" },
1706
+ },
1707
+ {
1708
+ id: "seed-proposal-assignment-update",
1709
+ kind: "assignmentUpdate",
1710
+ title: geneticsAssignment.title,
1711
+ status: "pending",
1712
+ payload: {
1713
+ id: geneticsAssignment.id,
1714
+ targetTitle: geneticsAssignment.title,
1715
+ dueDate: hoursFromNow(10 * 24, now).toISOString(),
1716
+ maxGrade: 15,
1717
+ },
1718
+ },
1719
+ ];
1656
1720
  await prisma.message.create({
1657
1721
  data: {
1658
- content:
1659
- "Sure! Here are 10 AP-level MCQs on organelle functions, with explanations on each. Want me to also generate a 4-criterion rubric for grading short-answer follow-ups?",
1722
+ content: labIntroText,
1660
1723
  senderId: aiUserId,
1661
1724
  conversationId: labConversation.id,
1662
1725
  createdAt: hoursFromNow(-2.95, now),
1663
1726
  status: JobStatus.COMPLETED,
1727
+ meta: {
1728
+ pendingCreations: labProposals,
1729
+ streamParts: [
1730
+ { type: "text", text: labIntroText },
1731
+ {
1732
+ type: "tool",
1733
+ toolCallId: "seed-tool-worksheet",
1734
+ toolName: "propose_worksheet",
1735
+ title: "Organelle Functions Quiz (10 MCQs)",
1736
+ proposalId: "seed-proposal-worksheet",
1737
+ status: "completed",
1738
+ },
1739
+ {
1740
+ type: "tool",
1741
+ toolCallId: "seed-tool-assignment",
1742
+ toolName: "propose_assignment",
1743
+ title: "Organelle Functions Quiz",
1744
+ proposalId: "seed-proposal-assignment",
1745
+ status: "completed",
1746
+ },
1747
+ {
1748
+ type: "tool",
1749
+ toolCallId: "seed-tool-section",
1750
+ toolName: "propose_section",
1751
+ title: "Unit 3: Cell Structure",
1752
+ proposalId: "seed-proposal-section",
1753
+ status: "completed",
1754
+ },
1755
+ {
1756
+ type: "tool",
1757
+ toolCallId: "seed-tool-assignment-update",
1758
+ toolName: "propose_update_assignment",
1759
+ title: geneticsAssignment.title,
1760
+ proposalId: "seed-proposal-assignment-update",
1761
+ status: "completed",
1762
+ },
1763
+ {
1764
+ type: "text",
1765
+ text: "Approve whichever you'd like me to create — nothing is added to the class until you do.",
1766
+ },
1767
+ ],
1768
+ },
1664
1769
  },
1665
1770
  });
1666
1771
 
@@ -70,15 +70,18 @@ export async function getUserForAdmin(id: string) {
70
70
  },
71
71
  },
72
72
  sessions: {
73
+ where: { type: 'AUTH' },
73
74
  orderBy: { createdAt: 'desc' },
74
75
  take: 10,
75
- select: { id: true, type: true, createdAt: true, expiresAt: true },
76
+ // Session.id is the bearer token — never return it from admin APIs.
77
+ select: { type: true, createdAt: true, expiresAt: true },
76
78
  },
77
79
  _count: {
78
80
  select: {
79
81
  teacherIn: true,
80
82
  studentIn: true,
81
- sessions: true,
83
+ // Keep the count consistent with the AUTH-only session list above.
84
+ sessions: { where: { type: 'AUTH' } },
82
85
  submissions: true,
83
86
  files: true,
84
87
  },
@@ -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
+ }