@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.
- package/.env.example +3 -1
- package/.github/workflows/npm-publish.yml +1 -0
- package/AGENTS.md +84 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -4
- package/dist/index.js.map +1 -1
- package/dist/instrument.js +9 -4
- package/dist/instrument.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +15 -0
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +43 -2
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/models/cloudStorage.d.ts +11 -0
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +18 -2
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/conversation.d.ts +1 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +40 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +32 -2
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/message.d.ts +3 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +2 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +60 -16
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +170 -2
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +30 -2
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +30 -5
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/generateVisualization.d.ts +33 -0
- package/dist/pipelines/generateVisualization.d.ts.map +1 -0
- package/dist/pipelines/generateVisualization.js +79 -0
- package/dist/pipelines/generateVisualization.js.map +1 -0
- package/dist/pipelines/labChatPrompt.d.ts +1 -1
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +13 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1118 -70
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +297 -1
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/admin.js +43 -2
- package/dist/routers/admin.js.map +1 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +16 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/conversation.d.ts +1 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +10 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +6 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +37 -4
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +24 -7
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/module.d.ts +1 -1
- package/dist/routers/parent.d.ts +18 -0
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/parent.js +10 -3
- package/dist/routers/parent.js.map +1 -1
- package/dist/routers/studentProgress.d.ts +44 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/visualization.d.ts +124 -0
- package/dist/routers/visualization.d.ts.map +1 -0
- package/dist/routers/visualization.js +41 -0
- package/dist/routers/visualization.js.map +1 -0
- package/dist/routes/aiStream.js +32 -2
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +107 -3
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminClass.d.ts +153 -0
- package/dist/services/adminClass.d.ts.map +1 -0
- package/dist/services/adminClass.js +196 -0
- package/dist/services/adminClass.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/adminInstitution.js +11 -4
- package/dist/services/adminInstitution.js.map +1 -1
- package/dist/services/adminMetrics.d.ts +8 -0
- package/dist/services/adminMetrics.d.ts.map +1 -1
- package/dist/services/adminMetrics.js +30 -3
- package/dist/services/adminMetrics.js.map +1 -1
- package/dist/services/adminSpam.d.ts +76 -0
- package/dist/services/adminSpam.d.ts.map +1 -0
- package/dist/services/adminSpam.js +163 -0
- package/dist/services/adminSpam.js.map +1 -0
- package/dist/services/adminSql.d.ts +36 -0
- package/dist/services/adminSql.d.ts.map +1 -0
- package/dist/services/adminSql.js +172 -0
- package/dist/services/adminSql.js.map +1 -0
- package/dist/services/adminTelemetry.d.ts +56 -0
- package/dist/services/adminTelemetry.d.ts.map +1 -0
- package/dist/services/adminTelemetry.js +147 -0
- package/dist/services/adminTelemetry.js.map +1 -0
- package/dist/services/adminUser.d.ts +45 -1
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +53 -13
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/aiChatStream.d.ts +15 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +144 -74
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiGeneration.d.ts +4 -0
- package/dist/services/aiGeneration.d.ts.map +1 -0
- package/dist/services/aiGeneration.js +88 -0
- package/dist/services/aiGeneration.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +4 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +128 -3
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiProposals.d.ts +70 -0
- package/dist/services/aiProposals.d.ts.map +1 -0
- package/dist/services/aiProposals.js +363 -0
- package/dist/services/aiProposals.js.map +1 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +10 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/conversation.d.ts +1 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +7 -3
- package/dist/services/fees.js.map +1 -1
- package/dist/services/labChat.d.ts +10 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +52 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +24 -8
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +170 -117
- package/dist/services/message.js.map +1 -1
- package/dist/services/module.d.ts.map +1 -1
- package/dist/services/module.js +23 -4
- package/dist/services/module.js.map +1 -1
- package/dist/services/parentPortal.d.ts +14 -0
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +72 -2
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/studentProgress.d.ts +50 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +107 -2
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/visualization.d.ts +72 -0
- package/dist/services/visualization.d.ts.map +1 -0
- package/dist/services/visualization.js +122 -0
- package/dist/services/visualization.js.map +1 -0
- package/dist/utils/inference.d.ts +10 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +77 -2
- package/dist/utils/inference.js.map +1 -1
- package/dist/utils/sentryTrpc.d.ts +3 -0
- package/dist/utils/sentryTrpc.d.ts.map +1 -0
- package/dist/utils/sentryTrpc.js +26 -0
- package/dist/utils/sentryTrpc.js.map +1 -0
- package/dist/workers/processors/createRunJobWorker.js +17 -16
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
- package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
- package/prisma/schema.prisma +32 -0
- package/src/index.ts +30 -4
- package/src/instrument.ts +8 -2
- package/src/lib/fileUpload.ts +49 -0
- package/src/models/cloudStorage.ts +19 -0
- package/src/models/labChat.ts +34 -0
- package/src/pipelines/aiLabChat.ts +79 -18
- package/src/pipelines/aiLabChatContract.ts +35 -0
- package/src/pipelines/aiNewtonChat.ts +34 -2
- package/src/pipelines/generateVisualization.ts +107 -0
- package/src/pipelines/labChatPrompt.ts +11 -12
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +72 -0
- package/src/routers/class.ts +14 -1
- package/src/routers/labChat.ts +5 -0
- package/src/routers/message.ts +34 -5
- package/src/routers/parent.ts +11 -0
- package/src/routers/studentProgress.ts +15 -0
- package/src/routers/visualization.ts +60 -0
- package/src/routes/aiStream.ts +32 -0
- package/src/seedDatabase.ts +107 -2
- package/src/services/adminClass.ts +222 -0
- package/src/services/adminInstitution.ts +9 -2
- package/src/services/adminMetrics.ts +35 -0
- package/src/services/adminSpam.ts +224 -0
- package/src/services/adminSql.ts +214 -0
- package/src/services/adminTelemetry.ts +194 -0
- package/src/services/adminUser.ts +66 -11
- package/src/services/aiChatStream.ts +83 -7
- package/src/services/aiGeneration.ts +92 -0
- package/src/services/aiProposalTools.ts +166 -1
- package/src/services/aiProposals.ts +428 -0
- package/src/services/auth.ts +8 -2
- package/src/services/fees.ts +5 -1
- package/src/services/labChat.ts +65 -0
- package/src/services/message.ts +205 -142
- package/src/services/module.ts +29 -2
- package/src/services/parentPortal.ts +85 -0
- package/src/services/studentProgress.ts +135 -0
- package/src/services/visualization.ts +147 -0
- package/src/utils/inference.ts +85 -0
- package/src/utils/sentryTrpc.ts +25 -0
- package/src/workers/processors/createRunJobWorker.ts +17 -17
- package/tests/unit/lib/fileUploadSession.test.ts +67 -0
- package/tests/unit/services/adminClass.test.ts +102 -0
- package/tests/unit/services/adminSpam.test.ts +108 -0
- package/tests/unit/services/adminSql.test.ts +56 -0
- package/tests/unit/services/adminTelemetry.test.ts +47 -0
- package/tests/unit/services/adminUser.test.ts +77 -0
- package/tests/unit/services/visualization.test.ts +130 -0
- package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
- 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
|
|
23
|
+
const filters: Prisma.UserWhereInput[] = [];
|
|
24
|
+
|
|
12
25
|
const q = input.query?.trim();
|
|
13
26
|
if (q) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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 {
|
|
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.',
|