chore: auto compact chat
This commit is contained in:
@@ -63,7 +63,6 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { listCourseSections } from '@api/services/course/section';
|
import { listCourseSections } from '@api/services/course/section';
|
||||||
import { getLesson } from '@api/services/lesson/lesson';
|
import { getLesson } from '@api/services/lesson/lesson';
|
||||||
import { getExercise } from '@api/services/exercise/exercise';
|
import { getExercise } from '@api/services/exercise/exercise';
|
||||||
import { trimMessageHistory } from '@api/services/agent/context-window';
|
|
||||||
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
|
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
|
||||||
import {
|
import {
|
||||||
collectDocumentIds,
|
collectDocumentIds,
|
||||||
@@ -74,8 +73,10 @@ import {
|
|||||||
verifyLessonBelongsToCourse
|
verifyLessonBelongsToCourse
|
||||||
} from '@api/services/agent/chat-context';
|
} from '@api/services/agent/chat-context';
|
||||||
import { buildAgentTools } from '@api/services/agent/chat-tools';
|
import { buildAgentTools } from '@api/services/agent/chat-tools';
|
||||||
|
import { buildModelContextMessages } from '@api/services/agent/model-context';
|
||||||
import { summarizeConversation } from '@api/services/agent/summarize';
|
import { summarizeConversation } from '@api/services/agent/summarize';
|
||||||
import { agentHistoryRouter } from './history';
|
import { agentHistoryRouter } from './history';
|
||||||
|
import { agentRunsRouter } from './runs';
|
||||||
|
|
||||||
const agentCoreRouter = new Hono()
|
const agentCoreRouter = new Hono()
|
||||||
.get('/status', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentStatusQuery), async (c) => {
|
.get('/status', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentStatusQuery), async (c) => {
|
||||||
@@ -385,7 +386,7 @@ const agentCoreRouter = new Hono()
|
|||||||
const orgId = c.req.header('cio-org-id')!;
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
|
const { courseId, conversationId, messages, context, model: requestedModel } = c.req.valid('json');
|
||||||
|
|
||||||
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||||
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
||||||
@@ -423,6 +424,14 @@ const agentCoreRouter = new Hono()
|
|||||||
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (conversationId) {
|
||||||
|
const conversation = await getChatConversation(conversationId, user.id);
|
||||||
|
|
||||||
|
if (!conversation || conversation.courseId !== courseId) {
|
||||||
|
throw new AppError('Conversation not found for this course', 'CONVERSATION_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
|
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
|
||||||
const isPaid = await isOrgOnPaidPlan(orgId);
|
const isPaid = await isOrgOnPaidPlan(orgId);
|
||||||
|
|
||||||
@@ -524,8 +533,13 @@ const agentCoreRouter = new Hono()
|
|||||||
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
||||||
: buildAgentTools(orgId, user.id, courseId, messages);
|
: buildAgentTools(orgId, user.id, courseId, messages);
|
||||||
|
|
||||||
const trimmedMessages = trimMessageHistory(messages);
|
const contextManaged = await buildModelContextMessages({
|
||||||
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages));
|
conversationId,
|
||||||
|
courseId,
|
||||||
|
userId: user.id,
|
||||||
|
messages
|
||||||
|
});
|
||||||
|
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(contextManaged.messages as any));
|
||||||
let completedStepCount = 0;
|
let completedStepCount = 0;
|
||||||
let finishReason: string | undefined;
|
let finishReason: string | undefined;
|
||||||
|
|
||||||
@@ -695,4 +709,5 @@ const agentCoreRouter = new Hono()
|
|||||||
export const agentRouter = new Hono()
|
export const agentRouter = new Hono()
|
||||||
.use('*', agentContentTypeRewrite)
|
.use('*', agentContentTypeRewrite)
|
||||||
.route('/', agentCoreRouter)
|
.route('/', agentCoreRouter)
|
||||||
.route('/history', agentHistoryRouter);
|
.route('/history', agentHistoryRouter)
|
||||||
|
.route('/runs', agentRunsRouter);
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Hono } from '@api/utils/hono';
|
||||||
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import {
|
||||||
|
ZAgentRunCourseQuery,
|
||||||
|
ZAgentRunCreateBody,
|
||||||
|
ZAgentRunInstructionBody,
|
||||||
|
ZAgentRunParam
|
||||||
|
} from '@cio/utils/validation/agent';
|
||||||
|
import { handleError } from '@api/utils/errors';
|
||||||
|
import {
|
||||||
|
appendAgentRunInstructionState,
|
||||||
|
cancelAgentRunState,
|
||||||
|
createAgentRunState,
|
||||||
|
getAgentRunState,
|
||||||
|
listAgentRunStates,
|
||||||
|
resumeAgentRunState,
|
||||||
|
retryAgentRunState
|
||||||
|
} from '@api/services/agent/run-state';
|
||||||
|
|
||||||
|
export const agentRunsRouter = new Hono()
|
||||||
|
/**
|
||||||
|
* GET /agent/runs?courseId=...
|
||||||
|
* List durable course-generation runs for the current user and course.
|
||||||
|
*/
|
||||||
|
.get('/', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentRunCourseQuery), async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { courseId } = c.req.valid('query');
|
||||||
|
const runs = await listAgentRunStates(courseId, user.id, orgId);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: runs });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to list agent runs');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /agent/runs
|
||||||
|
* Create a durable run record before worker-backed generation begins.
|
||||||
|
*/
|
||||||
|
.post('/', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentRunCreateBody), async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const run = await createAgentRunState({
|
||||||
|
orgId,
|
||||||
|
userId: user.id,
|
||||||
|
courseId: data.courseId,
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
phase: data.phase,
|
||||||
|
approvedPlan: data.approvedPlan,
|
||||||
|
executionCursor: data.executionCursor,
|
||||||
|
sourceIds: data.sourceIds,
|
||||||
|
modelSummary: data.modelSummary
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: run }, 201);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to create agent run');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /agent/runs/:runId
|
||||||
|
* Fetch the run, checkpoint steps, and progress events.
|
||||||
|
*/
|
||||||
|
.get('/:runId', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { runId } = c.req.valid('param');
|
||||||
|
const run = await getAgentRunState(runId, user.id, orgId);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: run });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to fetch agent run');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /agent/runs/:runId/resume
|
||||||
|
* Requeue a paused or waiting run from its saved cursor.
|
||||||
|
*/
|
||||||
|
.post('/:runId/resume', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { runId } = c.req.valid('param');
|
||||||
|
const run = await resumeAgentRunState(runId, user.id, orgId);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: run });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to resume agent run');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /agent/runs/:runId/retry
|
||||||
|
* Requeue a failed run without duplicating completed checkpointed steps.
|
||||||
|
*/
|
||||||
|
.post('/:runId/retry', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { runId } = c.req.valid('param');
|
||||||
|
const run = await retryAgentRunState(runId, user.id, orgId);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: run });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to retry agent run');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /agent/runs/:runId/cancel
|
||||||
|
* Mark the run canceled so workers stop before the next step.
|
||||||
|
*/
|
||||||
|
.post('/:runId/cancel', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { runId } = c.req.valid('param');
|
||||||
|
const run = await cancelAgentRunState(runId, user.id, orgId);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: run });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to cancel agent run');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /agent/runs/:runId/instructions
|
||||||
|
* Queue teacher instructions for the worker to consume between steps.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:runId/instructions',
|
||||||
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
|
zValidator('param', ZAgentRunParam),
|
||||||
|
zValidator('json', ZAgentRunInstructionBody),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { runId } = c.req.valid('param');
|
||||||
|
const { text } = c.req.valid('json');
|
||||||
|
const run = await appendAgentRunInstructionState(runId, user.id, orgId, text);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: run });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to queue agent run instruction');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { getChatModelContext, upsertChatModelContext } from '@cio/db/queries/agent';
|
||||||
|
import { trimMessageHistory } from '@api/services/agent/context-window';
|
||||||
|
import { summarizeConversationDelta, type SummarizeOptions } from '@api/services/agent/summarize';
|
||||||
|
|
||||||
|
const KEEP_RECENT_MESSAGES = 16;
|
||||||
|
const MIN_MESSAGES_BEFORE_SUMMARY = KEEP_RECENT_MESSAGES + 4;
|
||||||
|
|
||||||
|
type AgentMessage = SummarizeOptions['messages'][number] & {
|
||||||
|
id?: string;
|
||||||
|
metadata?: Record<string, unknown> | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BuildModelContextMessagesOptions {
|
||||||
|
conversationId?: string;
|
||||||
|
courseId: string;
|
||||||
|
userId: string;
|
||||||
|
messages: AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildModelContextMessagesResult {
|
||||||
|
messages: AgentMessage[];
|
||||||
|
summarized: boolean;
|
||||||
|
compactedThroughMessageId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageId(message: AgentMessage | undefined): string | null {
|
||||||
|
return typeof message?.id === 'string' && message.id.length > 0 ? message.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummarySeed(summary: string, compactedThroughMessageId?: string | null): AgentMessage {
|
||||||
|
return {
|
||||||
|
id: `model-context-summary-${randomUUID()}`,
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Conversation summary for continuity. Use this as prior context, then continue from the recent messages that follow.\n\n${summary}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
modelContextSummary: {
|
||||||
|
compactedThroughMessageId: compactedThroughMessageId ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildModelContextMessages(
|
||||||
|
options: BuildModelContextMessagesOptions
|
||||||
|
): Promise<BuildModelContextMessagesResult> {
|
||||||
|
const { conversationId, courseId, userId, messages } = options;
|
||||||
|
|
||||||
|
if (!conversationId || messages.length <= MIN_MESSAGES_BEFORE_SUMMARY) {
|
||||||
|
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingContext = await getChatModelContext(conversationId, userId);
|
||||||
|
const splitIndex = Math.max(0, messages.length - KEEP_RECENT_MESSAGES);
|
||||||
|
const messagesToRepresent = messages.slice(0, splitIndex);
|
||||||
|
const recentMessages = messages.slice(splitIndex);
|
||||||
|
const compactedThroughMessageId = getMessageId(messagesToRepresent[messagesToRepresent.length - 1]);
|
||||||
|
|
||||||
|
if (messagesToRepresent.length === 0) {
|
||||||
|
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelSummary = existingContext?.modelSummary ?? '';
|
||||||
|
let summarized = false;
|
||||||
|
|
||||||
|
if (compactedThroughMessageId && existingContext?.compactedThroughMessageId !== compactedThroughMessageId) {
|
||||||
|
const previousCompactedIndex = existingContext?.compactedThroughMessageId
|
||||||
|
? messagesToRepresent.findIndex((message) => getMessageId(message) === existingContext.compactedThroughMessageId)
|
||||||
|
: -1;
|
||||||
|
const deltaStartIndex = previousCompactedIndex >= 0 ? previousCompactedIndex + 1 : 0;
|
||||||
|
const deltaMessages = messagesToRepresent.slice(deltaStartIndex);
|
||||||
|
|
||||||
|
if (deltaMessages.length > 0) {
|
||||||
|
modelSummary = await summarizeConversationDelta({
|
||||||
|
existingSummary: modelSummary,
|
||||||
|
messages: deltaMessages
|
||||||
|
});
|
||||||
|
summarized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertChatModelContext({
|
||||||
|
conversationId,
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
modelSummary,
|
||||||
|
compactedThroughMessageId,
|
||||||
|
sourceIds: existingContext?.sourceIds ?? []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelSummary.trim()) {
|
||||||
|
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextManagedMessages = [buildSummarySeed(modelSummary, compactedThroughMessageId), ...recentMessages];
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: trimMessageHistory(contextManagedMessages) as AgentMessage[],
|
||||||
|
summarized,
|
||||||
|
compactedThroughMessageId
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendAgentRunInstruction,
|
||||||
|
createAgentRun,
|
||||||
|
createAgentRunEvent,
|
||||||
|
getAgentRun,
|
||||||
|
getChatConversation,
|
||||||
|
listAgentRunEvents,
|
||||||
|
listAgentRuns,
|
||||||
|
listAgentRunSteps,
|
||||||
|
updateAgentRun,
|
||||||
|
type AgentRunInstruction
|
||||||
|
} from '@cio/db/queries/agent';
|
||||||
|
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||||
|
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||||
|
import type { TAiAgentRun, TAiAgentRunEvent, TAiAgentRunStatus, TAiAgentRunStep } from '@cio/db/types';
|
||||||
|
import { AppError } from '@api/utils/errors';
|
||||||
|
|
||||||
|
const ACTIVE_RUN_STATUSES = new Set<TAiAgentRunStatus>(['queued', 'running', 'waiting_for_input', 'paused']);
|
||||||
|
|
||||||
|
export interface CreateAgentRunStateInput {
|
||||||
|
orgId: string;
|
||||||
|
userId: string;
|
||||||
|
courseId: string;
|
||||||
|
conversationId?: string;
|
||||||
|
phase?: string;
|
||||||
|
approvedPlan?: Record<string, unknown>;
|
||||||
|
executionCursor?: Record<string, unknown>;
|
||||||
|
sourceIds?: string[];
|
||||||
|
modelSummary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRunDetail {
|
||||||
|
run: TAiAgentRun;
|
||||||
|
steps: TAiAgentRunStep[];
|
||||||
|
events: TAiAgentRunEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCourseRunAccess(courseId: string, userId: string, orgId: string): Promise<void> {
|
||||||
|
const courseOrgId = await getCourseOrganizationId(courseId);
|
||||||
|
|
||||||
|
if (!courseOrgId) {
|
||||||
|
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (courseOrgId !== orgId) {
|
||||||
|
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCourseAccess = await isCourseTeamMemberOrOrgAdmin(courseId, userId);
|
||||||
|
|
||||||
|
if (!hasCourseAccess) {
|
||||||
|
throw new AppError('You must be a course team member to manage agent runs', 'NOT_COURSE_TEAM_MEMBER', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertConversationBelongsToCourse(
|
||||||
|
conversationId: string | undefined,
|
||||||
|
courseId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
const conversation = await getChatConversation(conversationId, userId);
|
||||||
|
|
||||||
|
if (!conversation || conversation.courseId !== courseId) {
|
||||||
|
throw new AppError('Conversation not found for this course', 'CONVERSATION_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOwnedAgentRun(runId: string, userId: string, orgId: string): Promise<TAiAgentRun> {
|
||||||
|
const run = await getAgentRun(runId, userId);
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.orgId !== orgId) {
|
||||||
|
throw new AppError('Agent run does not belong to this organization', 'AGENT_RUN_ORG_MISMATCH', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAgentRunDetail(run: TAiAgentRun): Promise<AgentRunDetail> {
|
||||||
|
const [steps, events] = await Promise.all([listAgentRunSteps(run.id), listAgentRunEvents(run.id)]);
|
||||||
|
|
||||||
|
return { run, steps, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateOwnedAgentRun(
|
||||||
|
runId: string,
|
||||||
|
userId: string,
|
||||||
|
update: Omit<Parameters<typeof updateAgentRun>[0], 'runId' | 'userId'>
|
||||||
|
): Promise<TAiAgentRun> {
|
||||||
|
const updatedRun = await updateAgentRun({ runId, userId, ...update });
|
||||||
|
|
||||||
|
if (!updatedRun) {
|
||||||
|
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentRunState(input: CreateAgentRunStateInput): Promise<AgentRunDetail> {
|
||||||
|
await assertCourseRunAccess(input.courseId, input.userId, input.orgId);
|
||||||
|
await assertConversationBelongsToCourse(input.conversationId, input.courseId, input.userId);
|
||||||
|
|
||||||
|
const run = await createAgentRun({
|
||||||
|
orgId: input.orgId,
|
||||||
|
courseId: input.courseId,
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
userId: input.userId,
|
||||||
|
phase: input.phase,
|
||||||
|
approvedPlan: input.approvedPlan ?? null,
|
||||||
|
executionCursor: input.executionCursor,
|
||||||
|
sourceIds: input.sourceIds,
|
||||||
|
modelSummary: input.modelSummary
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgentRunEvent({
|
||||||
|
runId: run.id,
|
||||||
|
eventType: 'run.created',
|
||||||
|
message: 'Agent run created',
|
||||||
|
payload: { phase: run.phase, status: run.status }
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildAgentRunDetail(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAgentRunStates(courseId: string, userId: string, orgId: string): Promise<TAiAgentRun[]> {
|
||||||
|
await assertCourseRunAccess(courseId, userId, orgId);
|
||||||
|
|
||||||
|
return listAgentRuns(courseId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||||
|
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||||
|
|
||||||
|
return buildAgentRunDetail(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||||
|
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||||
|
|
||||||
|
if (run.status === 'canceled') {
|
||||||
|
return buildAgentRunDetail(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === 'completed') {
|
||||||
|
throw new AppError('Completed agent runs cannot be canceled', 'AGENT_RUN_ALREADY_COMPLETED', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updatedRun = await updateOwnedAgentRun(runId, userId, {
|
||||||
|
status: 'canceled',
|
||||||
|
phase: 'canceled',
|
||||||
|
cancelRequestedAt: now,
|
||||||
|
finishedAt: now,
|
||||||
|
workerId: null,
|
||||||
|
lockedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgentRunEvent({
|
||||||
|
runId,
|
||||||
|
eventType: 'run.canceled',
|
||||||
|
message: 'Cancel requested',
|
||||||
|
payload: { previousStatus: run.status, previousPhase: run.phase }
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildAgentRunDetail(updatedRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resumeAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||||
|
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||||
|
|
||||||
|
if (run.status === 'queued' || run.status === 'running') {
|
||||||
|
return buildAgentRunDetail(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === 'completed') {
|
||||||
|
throw new AppError('Completed agent runs cannot be resumed', 'AGENT_RUN_ALREADY_COMPLETED', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === 'canceled') {
|
||||||
|
throw new AppError('Canceled agent runs cannot be resumed', 'AGENT_RUN_CANCELED', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === 'failed') {
|
||||||
|
throw new AppError('Failed agent runs must be retried', 'AGENT_RUN_RETRY_REQUIRED', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRun = await updateOwnedAgentRun(runId, userId, {
|
||||||
|
status: 'queued',
|
||||||
|
cancelRequestedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
workerId: null,
|
||||||
|
lockedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgentRunEvent({
|
||||||
|
runId,
|
||||||
|
eventType: 'run.resumed',
|
||||||
|
message: 'Run resumed',
|
||||||
|
payload: { previousStatus: run.status, phase: run.phase }
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildAgentRunDetail(updatedRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||||
|
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||||
|
|
||||||
|
if (run.status !== 'failed') {
|
||||||
|
throw new AppError('Only failed agent runs can be retried', 'AGENT_RUN_NOT_RETRYABLE', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.attempt >= run.maxAttempts) {
|
||||||
|
throw new AppError('Agent run has reached its retry limit', 'AGENT_RUN_RETRY_LIMIT_REACHED', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRun = await updateOwnedAgentRun(runId, userId, {
|
||||||
|
status: 'queued',
|
||||||
|
attempt: run.attempt + 1,
|
||||||
|
lastError: null,
|
||||||
|
cancelRequestedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
workerId: null,
|
||||||
|
lockedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgentRunEvent({
|
||||||
|
runId,
|
||||||
|
eventType: 'run.retried',
|
||||||
|
message: 'Run queued for retry',
|
||||||
|
payload: { previousAttempt: run.attempt, nextAttempt: updatedRun.attempt }
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildAgentRunDetail(updatedRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendAgentRunInstructionState(
|
||||||
|
runId: string,
|
||||||
|
userId: string,
|
||||||
|
orgId: string,
|
||||||
|
text: string
|
||||||
|
): Promise<AgentRunDetail> {
|
||||||
|
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||||
|
|
||||||
|
if (!ACTIVE_RUN_STATUSES.has(run.status) && run.status !== 'failed') {
|
||||||
|
throw new AppError('Agent run is no longer accepting instructions', 'AGENT_RUN_NOT_ACCEPTING_INPUT', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instruction: AgentRunInstruction = {
|
||||||
|
id: randomUUID(),
|
||||||
|
text,
|
||||||
|
createdBy: userId,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedRun = await appendAgentRunInstruction(runId, userId, instruction);
|
||||||
|
|
||||||
|
if (!updatedRun) {
|
||||||
|
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAgentRunEvent({
|
||||||
|
runId,
|
||||||
|
eventType: 'instruction.queued',
|
||||||
|
message: 'Instruction queued',
|
||||||
|
payload: { instructionId: instruction.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildAgentRunDetail(updatedRun);
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ export interface SummarizeOptions {
|
|||||||
messages: InboundMessage[];
|
messages: InboundMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SummarizeDeltaOptions extends SummarizeOptions {
|
||||||
|
existingSummary?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function truncate(input: string, max: number): string {
|
function truncate(input: string, max: number): string {
|
||||||
if (input.length <= max) return input;
|
if (input.length <= max) return input;
|
||||||
|
|
||||||
@@ -138,3 +142,45 @@ export async function summarizeConversation(options: SummarizeOptions): Promise<
|
|||||||
|
|
||||||
return result.text.trim();
|
return result.text.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function summarizeConversationDelta(options: SummarizeDeltaOptions): Promise<string> {
|
||||||
|
if (!options.existingSummary?.trim()) {
|
||||||
|
return summarizeConversation({ messages: options.messages });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerConfig = pickAnyConfiguredProvider();
|
||||||
|
|
||||||
|
if (!providerConfig) {
|
||||||
|
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = createModel(providerConfig);
|
||||||
|
const conversationLines: string[] = [];
|
||||||
|
|
||||||
|
for (const message of options.messages ?? []) {
|
||||||
|
const role = message.role === 'user' ? 'User' : 'Assistant';
|
||||||
|
const body = partsToText(message);
|
||||||
|
|
||||||
|
if (!body.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationLines.push(`${role}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationText = conversationLines.join('\n\n');
|
||||||
|
const truncatedText =
|
||||||
|
conversationText.length > MAX_CONVERSATION_CHARS
|
||||||
|
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
|
||||||
|
: conversationText;
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model,
|
||||||
|
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||||
|
prompt: `Existing structured handoff summary:\n\n${options.existingSummary.trim()}\n\nNew conversation messages to merge into that summary:\n\n${truncatedText}\n\nReturn one updated structured handoff message using the required section headings. Preserve concrete plan status, template answers, fetched documentation URLs/source identifiers, created course items, open decisions, and the next action.`,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||||
|
maxRetries: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.text.trim();
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
AI_ASSISTANT_QUICK_ACTION_ENTRIES,
|
AI_ASSISTANT_QUICK_ACTION_ENTRIES,
|
||||||
STUDENT_QUICK_ACTION_ENTRIES
|
STUDENT_QUICK_ACTION_ENTRIES
|
||||||
} from '$features/ai-assistant/utils/constants';
|
} from '$features/ai-assistant/utils/constants';
|
||||||
import { calculateContextUsage } from '$features/ai-assistant/utils/context-utils';
|
|
||||||
import {
|
import {
|
||||||
AGENT_MODELS,
|
AGENT_MODELS,
|
||||||
AGENT_MODEL_IDS,
|
AGENT_MODEL_IDS,
|
||||||
@@ -80,7 +79,6 @@
|
|||||||
let inputValue = $state('');
|
let inputValue = $state('');
|
||||||
let uploadedDocument: UploadedDocument | null = $state(null);
|
let uploadedDocument: UploadedDocument | null = $state(null);
|
||||||
let isUploading = $state(false);
|
let isUploading = $state(false);
|
||||||
let contextFullBusy = $state<null | 'compact' | 'new_chat'>(null);
|
|
||||||
|
|
||||||
let pendingInitialTemplateId: CourseTemplateId | null = $state(null);
|
let pendingInitialTemplateId: CourseTemplateId | null = $state(null);
|
||||||
|
|
||||||
@@ -321,6 +319,7 @@
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: () => ({
|
body: () => ({
|
||||||
courseId,
|
courseId,
|
||||||
|
conversationId: activeConversationId ?? undefined,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
context: {
|
context: {
|
||||||
lessonId: page.params?.lessonId,
|
lessonId: page.params?.lessonId,
|
||||||
@@ -379,8 +378,9 @@
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSend() {
|
async function handleSend() {
|
||||||
if (!inputValue.trim() || chat.status === 'streaming') return;
|
if (!inputValue.trim() || chat.status === 'streaming') return;
|
||||||
|
if (!courseId) return;
|
||||||
|
|
||||||
const text = inputValue;
|
const text = inputValue;
|
||||||
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
|
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
|
||||||
@@ -402,22 +402,17 @@
|
|||||||
if (templateForFirstMessage) {
|
if (templateForFirstMessage) {
|
||||||
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
|
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
|
||||||
metadata.template = templateMeta;
|
metadata.template = templateMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationId = await ensureActiveConversation(courseId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
if (templateForFirstMessage) {
|
||||||
pendingInitialTemplateId = null;
|
pendingInitialTemplateId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
inputValue = '';
|
inputValue = '';
|
||||||
|
|
||||||
// Create conversation eagerly before sending the first message
|
|
||||||
if (!activeConversationId) {
|
|
||||||
if (!courseId) return;
|
|
||||||
|
|
||||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
|
||||||
if (created) {
|
|
||||||
setActiveConversationId(courseId, created.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
chat.sendMessage({
|
chat.sendMessage({
|
||||||
text,
|
text,
|
||||||
...(Object.keys(metadata).length > 0 ? { metadata } : {})
|
...(Object.keys(metadata).length > 0 ? { metadata } : {})
|
||||||
@@ -433,7 +428,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureActiveConversation(courseId);
|
const conversationId = await ensureActiveConversation(courseId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
chat.sendMessage({
|
chat.sendMessage({
|
||||||
text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields),
|
text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields),
|
||||||
@@ -452,7 +448,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureActiveConversation(courseId);
|
const conversationId = await ensureActiveConversation(courseId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
chat.sendMessage({
|
chat.sendMessage({
|
||||||
text: "I'll answer your questions in chat instead of the form.",
|
text: "I'll answer your questions in chat instead of the form.",
|
||||||
@@ -499,28 +496,22 @@
|
|||||||
|
|
||||||
function handleQuickAction(action: string) {
|
function handleQuickAction(action: string) {
|
||||||
inputValue = action;
|
inputValue = action;
|
||||||
handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
chat.stop();
|
chat.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImplementPlan(editedPlan: unknown) {
|
async function handleImplementPlan(editedPlan: unknown) {
|
||||||
if (chat.status === 'streaming') return;
|
if (chat.status === 'streaming') return;
|
||||||
|
if (!courseId) return;
|
||||||
|
|
||||||
|
const conversationId = await ensureActiveConversation(courseId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
inputValue = '';
|
inputValue = '';
|
||||||
|
|
||||||
if (!activeConversationId) {
|
|
||||||
if (!courseId) return;
|
|
||||||
|
|
||||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
|
||||||
if (created) {
|
|
||||||
setActiveConversationId(courseId, created.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
chat.sendMessage({
|
chat.sendMessage({
|
||||||
text: 'Implement this plan.',
|
text: 'Implement this plan.',
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -532,74 +523,27 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAskPlanChanges(message: string) {
|
async function handleAskPlanChanges(message: string) {
|
||||||
const trimmed = message.trim();
|
const trimmed = message.trim();
|
||||||
|
|
||||||
if (!trimmed || chat.status === 'streaming') return;
|
if (!trimmed || chat.status === 'streaming') return;
|
||||||
|
if (!courseId) return;
|
||||||
|
|
||||||
if (!activeConversationId && courseId) {
|
const conversationId = await ensureActiveConversation(courseId);
|
||||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
if (!conversationId) return;
|
||||||
if (created) {
|
|
||||||
setActiveConversationId(courseId, created.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
chat.sendMessage({ text: trimmed });
|
chat.sendMessage({ text: trimmed });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResume() {
|
function handleResume() {
|
||||||
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
|
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
|
||||||
handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMentionClick(route: string) {
|
function handleMentionClick(route: string) {
|
||||||
goto(resolve(route, {}));
|
goto(resolve(route, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartNewChatWithSummary() {
|
|
||||||
if (!courseId || contextFullBusy) return;
|
|
||||||
|
|
||||||
contextFullBusy = 'new_chat';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const summary = await aiAssistantApi.summarizeConversation(chat.messages as AiAssistantMessage[], courseId);
|
|
||||||
|
|
||||||
chat.messages = [];
|
|
||||||
setActiveConversationId(courseId, null);
|
|
||||||
|
|
||||||
const created = await aiAssistantApi.createConversation(courseId);
|
|
||||||
|
|
||||||
if (created) {
|
|
||||||
setActiveConversationId(courseId, created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary) {
|
|
||||||
inputValue = summary;
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
contextFullBusy = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCompactConversation() {
|
|
||||||
if (!courseId || !activeConversationId || contextFullBusy) return;
|
|
||||||
|
|
||||||
contextFullBusy = 'compact';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const compacted = await aiAssistantApi.compactConversation(activeConversationId);
|
|
||||||
|
|
||||||
if (compacted && compacted.length > 0) {
|
|
||||||
chat.messages = compacted as AiAssistantMessage[];
|
|
||||||
await chat.regenerate();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
contextFullBusy = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
||||||
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
||||||
|
|
||||||
@@ -641,10 +585,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextUsage = $derived(
|
|
||||||
calculateContextUsage(chat.messages as AiAssistantMessage[], AGENT_MODELS[selectedModel].contextWindow)
|
|
||||||
);
|
|
||||||
|
|
||||||
const mentionItems = $derived(
|
const mentionItems = $derived(
|
||||||
getMentionableContent(courseApi.course).map((item) => ({
|
getMentionableContent(courseApi.course).map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -829,7 +769,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
inputValue = prompt;
|
inputValue = prompt;
|
||||||
handleSend();
|
void handleSend();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -897,13 +837,9 @@
|
|||||||
{isStreaming}
|
{isStreaming}
|
||||||
{isExhausted}
|
{isExhausted}
|
||||||
{isUploading}
|
{isUploading}
|
||||||
{contextFullBusy}
|
|
||||||
compactConversationDisabled={!activeConversationId}
|
|
||||||
onCompactConversation={handleCompactConversation}
|
|
||||||
{uploadedDocument}
|
{uploadedDocument}
|
||||||
{mentionItems}
|
{mentionItems}
|
||||||
{selectedModel}
|
{selectedModel}
|
||||||
{contextUsage}
|
|
||||||
{isStudent}
|
{isStudent}
|
||||||
{tutorBlocked}
|
{tutorBlocked}
|
||||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||||
@@ -914,6 +850,5 @@
|
|||||||
onRemoveDocument={handleRemoveDocument}
|
onRemoveDocument={handleRemoveDocument}
|
||||||
onSelectModel={handleSelectModel}
|
onSelectModel={handleSelectModel}
|
||||||
onLockedModelSelect={handleLockedModelSelect}
|
onLockedModelSelect={handleLockedModelSelect}
|
||||||
onStartNewChatWithSummary={handleStartNewChatWithSummary}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
import { ModelPicker } from '@cio/ui/custom/model-picker';
|
import { ModelPicker } from '@cio/ui/custom/model-picker';
|
||||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||||
import { AI_AGENT_RUNNING_WARNING_DISMISSED_KEY } from '$features/ai-assistant/utils/constants';
|
import { AI_AGENT_RUNNING_WARNING_DISMISSED_KEY } from '$features/ai-assistant/utils/constants';
|
||||||
import ContextIndicator from './context-indicator.svelte';
|
|
||||||
import ContextFullState from './context-full-state.svelte';
|
|
||||||
import type { ContextUsage } from './utils/context-utils';
|
|
||||||
|
|
||||||
interface UploadedDocument {
|
interface UploadedDocument {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,14 +29,11 @@
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isExhausted: boolean;
|
isExhausted: boolean;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
contextFullBusy: null | 'compact' | 'new_chat';
|
|
||||||
compactConversationDisabled?: boolean;
|
|
||||||
error: Error | null | undefined;
|
error: Error | null | undefined;
|
||||||
mentionItems: MentionItem[];
|
mentionItems: MentionItem[];
|
||||||
uploadedDocument: UploadedDocument | null;
|
uploadedDocument: UploadedDocument | null;
|
||||||
selectedModel: AgentModelId;
|
selectedModel: AgentModelId;
|
||||||
lockedModelIds?: readonly AgentModelId[];
|
lockedModelIds?: readonly AgentModelId[];
|
||||||
contextUsage: ContextUsage | null;
|
|
||||||
isStudent?: boolean;
|
isStudent?: boolean;
|
||||||
/** Set to 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' to render the take-a-break empty state. */
|
/** Set to 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' to render the take-a-break empty state. */
|
||||||
tutorBlocked?: 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' | null;
|
tutorBlocked?: 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' | null;
|
||||||
@@ -49,8 +43,6 @@
|
|||||||
onRemoveDocument: () => void;
|
onRemoveDocument: () => void;
|
||||||
onSelectModel: (id: AgentModelId) => void;
|
onSelectModel: (id: AgentModelId) => void;
|
||||||
onLockedModelSelect?: (id: AgentModelId) => void;
|
onLockedModelSelect?: (id: AgentModelId) => void;
|
||||||
onStartNewChatWithSummary: () => void;
|
|
||||||
onCompactConversation: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -58,14 +50,11 @@
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
isExhausted,
|
isExhausted,
|
||||||
isUploading,
|
isUploading,
|
||||||
contextFullBusy,
|
|
||||||
compactConversationDisabled = false,
|
|
||||||
error,
|
error,
|
||||||
mentionItems,
|
mentionItems,
|
||||||
uploadedDocument,
|
uploadedDocument,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
lockedModelIds = [],
|
lockedModelIds = [],
|
||||||
contextUsage,
|
|
||||||
isStudent = false,
|
isStudent = false,
|
||||||
tutorBlocked = null,
|
tutorBlocked = null,
|
||||||
onSend,
|
onSend,
|
||||||
@@ -73,9 +62,7 @@
|
|||||||
onFileSelect,
|
onFileSelect,
|
||||||
onRemoveDocument,
|
onRemoveDocument,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
onLockedModelSelect,
|
onLockedModelSelect
|
||||||
onStartNewChatWithSummary,
|
|
||||||
onCompactConversation
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function tutorBlockedMessage(reason: NonNullable<typeof tutorBlocked>): string {
|
function tutorBlockedMessage(reason: NonNullable<typeof tutorBlocked>): string {
|
||||||
@@ -200,13 +187,6 @@
|
|||||||
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
|
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if contextUsage?.isFull && !isStreaming}
|
|
||||||
<ContextFullState
|
|
||||||
{contextFullBusy}
|
|
||||||
{compactConversationDisabled}
|
|
||||||
{onCompactConversation}
|
|
||||||
onStartNewChat={onStartNewChatWithSummary}
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="border-t px-3 pt-3 pb-1.5">
|
<div class="border-t px-3 pt-3 pb-1.5">
|
||||||
{#if isExhausted}
|
{#if isExhausted}
|
||||||
@@ -307,10 +287,6 @@
|
|||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
{#if contextUsage && contextUsage.usedTokens > 0}
|
|
||||||
<ContextIndicator {contextUsage} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if isStreaming}
|
{#if isStreaming}
|
||||||
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
||||||
<SquareIcon size={12} />
|
<SquareIcon size={12} />
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
{#each inlineParts as part, partIndex (partIndex)}
|
{#each inlineParts as part, partIndex (partIndex)}
|
||||||
{#if part.type === 'text'}
|
{#if part.type === 'text'}
|
||||||
<div
|
<div
|
||||||
class="ai-chat-prose prose prose-sm dark:prose-invert max-w-none {message.role === 'user' &&
|
class="ai-chat-prose prose prose-sm dark:prose-invert max-w-none break-all {message.role === 'user' &&
|
||||||
'ui:text-primary-foreground!'}"
|
'ui:text-primary-foreground!'}"
|
||||||
>
|
>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const getSessionData = async (cookies: Cookies): Promise<App.Locals | nul
|
|||||||
try {
|
try {
|
||||||
const cioCookies = getCioCookieString(cookies);
|
const cioCookies = getCioCookieString(cookies);
|
||||||
|
|
||||||
console.log('cioCookies', cioCookies);
|
|
||||||
if (!cioCookies) return null;
|
if (!cioCookies) return null;
|
||||||
|
|
||||||
const locals = await getThroughAuthClient(cioCookies);
|
const locals = await getThroughAuthClient(cioCookies);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import * as schema from '@db/schema';
|
||||||
|
import { db } from '@db/drizzle';
|
||||||
|
|
||||||
|
export interface ChatModelContextInput {
|
||||||
|
conversationId: string;
|
||||||
|
courseId: string;
|
||||||
|
userId: string;
|
||||||
|
modelSummary?: string;
|
||||||
|
compactedThroughMessageId?: string | null;
|
||||||
|
sourceIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatModelContext(conversationId: string, userId: string) {
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.aiChatModelContext)
|
||||||
|
.where(
|
||||||
|
and(eq(schema.aiChatModelContext.conversationId, conversationId), eq(schema.aiChatModelContext.userId, userId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getChatModelContext error:', error);
|
||||||
|
throw new Error('Failed to fetch chat model context');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertChatModelContext(input: ChatModelContextInput) {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(schema.aiChatModelContext)
|
||||||
|
.values({
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
courseId: input.courseId,
|
||||||
|
userId: input.userId,
|
||||||
|
modelSummary: input.modelSummary ?? '',
|
||||||
|
compactedThroughMessageId: input.compactedThroughMessageId ?? null,
|
||||||
|
sourceIds: input.sourceIds ?? [],
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.aiChatModelContext.conversationId,
|
||||||
|
set: {
|
||||||
|
courseId: input.courseId,
|
||||||
|
userId: input.userId,
|
||||||
|
modelSummary: input.modelSummary ?? '',
|
||||||
|
compactedThroughMessageId: input.compactedThroughMessageId ?? null,
|
||||||
|
sourceIds: input.sourceIds ?? [],
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return row;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('upsertChatModelContext error:', error);
|
||||||
|
throw new Error('Failed to save chat model context');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendConversationSourceIds(conversationId: string, userId: string, sourceIds: string[]) {
|
||||||
|
try {
|
||||||
|
const existing = await getChatModelContext(conversationId, userId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const mergedSourceIds = Array.from(new Set([...(existing.sourceIds ?? []), ...sourceIds]));
|
||||||
|
const [row] = await db
|
||||||
|
.update(schema.aiChatModelContext)
|
||||||
|
.set({ sourceIds: mergedSourceIds, updatedAt: new Date().toISOString() })
|
||||||
|
.where(
|
||||||
|
and(eq(schema.aiChatModelContext.conversationId, conversationId), eq(schema.aiChatModelContext.userId, userId))
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('appendConversationSourceIds error:', error);
|
||||||
|
throw new Error('Failed to update chat model context sources');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export * from './chat-history';
|
export * from './chat-history';
|
||||||
export * from './chat-document';
|
export * from './chat-document';
|
||||||
export * from './chat-resource';
|
export * from './chat-resource';
|
||||||
|
export * from './chat-model-context';
|
||||||
|
export * from './run-state';
|
||||||
export * from './credit-purchase';
|
export * from './credit-purchase';
|
||||||
export * from './token-usage';
|
export * from './token-usage';
|
||||||
export * from './usage-aggregations';
|
export * from './usage-aggregations';
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { and, asc, desc, eq } from 'drizzle-orm';
|
||||||
|
import * as schema from '@db/schema';
|
||||||
|
import { db } from '@db/drizzle';
|
||||||
|
import type {
|
||||||
|
TAiAgentRun,
|
||||||
|
TAiAgentRunEvent,
|
||||||
|
TAiAgentRunStatus,
|
||||||
|
TAiAgentRunStep,
|
||||||
|
TNewAiAgentRun,
|
||||||
|
TNewAiAgentRunEvent,
|
||||||
|
TNewAiAgentRunStep
|
||||||
|
} from '@db/types';
|
||||||
|
|
||||||
|
export interface AgentRunInstruction {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAgentRunInput {
|
||||||
|
orgId: string;
|
||||||
|
courseId: string;
|
||||||
|
conversationId?: string | null;
|
||||||
|
userId: string;
|
||||||
|
phase?: string;
|
||||||
|
approvedPlan?: Record<string, unknown> | null;
|
||||||
|
executionCursor?: Record<string, unknown>;
|
||||||
|
sourceIds?: string[];
|
||||||
|
modelSummary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAgentRunInput {
|
||||||
|
runId: string;
|
||||||
|
userId: string;
|
||||||
|
status?: TAiAgentRunStatus;
|
||||||
|
phase?: string;
|
||||||
|
progressPercent?: number;
|
||||||
|
currentStepKey?: string | null;
|
||||||
|
approvedPlan?: Record<string, unknown> | null;
|
||||||
|
executionCursor?: Record<string, unknown>;
|
||||||
|
sourceIds?: string[];
|
||||||
|
modelSummary?: string;
|
||||||
|
queuedInstructions?: AgentRunInstruction[];
|
||||||
|
lastError?: { code: string; message: string; details?: unknown } | null;
|
||||||
|
attempt?: number;
|
||||||
|
workerId?: string | null;
|
||||||
|
lockedAt?: string | null;
|
||||||
|
cancelRequestedAt?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertAgentRunStepInput {
|
||||||
|
runId: string;
|
||||||
|
stepKey: string;
|
||||||
|
stepType: string;
|
||||||
|
status?: TAiAgentRunStatus;
|
||||||
|
inputHash?: string | null;
|
||||||
|
input?: Record<string, unknown> | null;
|
||||||
|
output?: Record<string, unknown> | null;
|
||||||
|
outputIds?: string[];
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
error?: { code: string; message: string; details?: unknown } | null;
|
||||||
|
attempt?: number;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAgentRunEventInput {
|
||||||
|
runId: string;
|
||||||
|
eventType: string;
|
||||||
|
message?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInputKey<T extends object>(input: T, key: keyof T): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(input, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentRun(input: CreateAgentRunInput): Promise<TAiAgentRun> {
|
||||||
|
try {
|
||||||
|
const values: TNewAiAgentRun = {
|
||||||
|
orgId: input.orgId,
|
||||||
|
courseId: input.courseId,
|
||||||
|
conversationId: input.conversationId ?? null,
|
||||||
|
userId: input.userId,
|
||||||
|
phase: input.phase ?? 'planning',
|
||||||
|
approvedPlan: input.approvedPlan ?? null,
|
||||||
|
executionCursor: input.executionCursor ?? {},
|
||||||
|
sourceIds: input.sourceIds ?? [],
|
||||||
|
modelSummary: input.modelSummary ?? '',
|
||||||
|
queuedInstructions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const [row] = await db.insert(schema.aiAgentRun).values(values).returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Insert returned no row');
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('createAgentRun error:', error);
|
||||||
|
throw new Error('Failed to create agent run');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAgentRuns(courseId: string, userId: string): Promise<TAiAgentRun[]> {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(schema.aiAgentRun)
|
||||||
|
.where(and(eq(schema.aiAgentRun.courseId, courseId), eq(schema.aiAgentRun.userId, userId)))
|
||||||
|
.orderBy(desc(schema.aiAgentRun.updatedAt));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('listAgentRuns error:', error);
|
||||||
|
throw new Error('Failed to list agent runs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAgentRun(runId: string, userId: string): Promise<TAiAgentRun | null> {
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.aiAgentRun)
|
||||||
|
.where(and(eq(schema.aiAgentRun.id, runId), eq(schema.aiAgentRun.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getAgentRun error:', error);
|
||||||
|
throw new Error('Failed to get agent run');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAgentRunById(runId: string): Promise<TAiAgentRun | null> {
|
||||||
|
try {
|
||||||
|
const [row] = await db.select().from(schema.aiAgentRun).where(eq(schema.aiAgentRun.id, runId)).limit(1);
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getAgentRunById error:', error);
|
||||||
|
throw new Error('Failed to get agent run by id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAgentRun(input: UpdateAgentRunInput): Promise<TAiAgentRun | null> {
|
||||||
|
try {
|
||||||
|
const updateData: Partial<TNewAiAgentRun> = {
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasInputKey(input, 'status')) updateData.status = input.status;
|
||||||
|
if (hasInputKey(input, 'phase')) updateData.phase = input.phase;
|
||||||
|
if (hasInputKey(input, 'progressPercent')) updateData.progressPercent = input.progressPercent;
|
||||||
|
if (hasInputKey(input, 'currentStepKey')) updateData.currentStepKey = input.currentStepKey;
|
||||||
|
if (hasInputKey(input, 'approvedPlan')) updateData.approvedPlan = input.approvedPlan;
|
||||||
|
if (hasInputKey(input, 'executionCursor')) updateData.executionCursor = input.executionCursor;
|
||||||
|
if (hasInputKey(input, 'sourceIds')) updateData.sourceIds = input.sourceIds;
|
||||||
|
if (hasInputKey(input, 'modelSummary')) updateData.modelSummary = input.modelSummary;
|
||||||
|
if (hasInputKey(input, 'queuedInstructions')) updateData.queuedInstructions = input.queuedInstructions;
|
||||||
|
if (hasInputKey(input, 'lastError')) updateData.lastError = input.lastError;
|
||||||
|
if (hasInputKey(input, 'attempt')) updateData.attempt = input.attempt;
|
||||||
|
if (hasInputKey(input, 'workerId')) updateData.workerId = input.workerId;
|
||||||
|
if (hasInputKey(input, 'lockedAt')) updateData.lockedAt = input.lockedAt;
|
||||||
|
if (hasInputKey(input, 'cancelRequestedAt')) updateData.cancelRequestedAt = input.cancelRequestedAt;
|
||||||
|
if (hasInputKey(input, 'startedAt')) updateData.startedAt = input.startedAt;
|
||||||
|
if (hasInputKey(input, 'finishedAt')) updateData.finishedAt = input.finishedAt;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.update(schema.aiAgentRun)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(eq(schema.aiAgentRun.id, input.runId), eq(schema.aiAgentRun.userId, input.userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateAgentRun error:', error);
|
||||||
|
throw new Error('Failed to update agent run');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendAgentRunInstruction(
|
||||||
|
runId: string,
|
||||||
|
userId: string,
|
||||||
|
instruction: AgentRunInstruction
|
||||||
|
): Promise<TAiAgentRun | null> {
|
||||||
|
try {
|
||||||
|
const existingRun = await getAgentRun(runId, userId);
|
||||||
|
if (!existingRun) return null;
|
||||||
|
|
||||||
|
return await updateAgentRun({
|
||||||
|
runId,
|
||||||
|
userId,
|
||||||
|
queuedInstructions: [...(existingRun.queuedInstructions ?? []), instruction]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('appendAgentRunInstruction error:', error);
|
||||||
|
throw new Error('Failed to append agent run instruction');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertAgentRunStep(input: UpsertAgentRunStepInput): Promise<TAiAgentRunStep> {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const values: TNewAiAgentRunStep = {
|
||||||
|
runId: input.runId,
|
||||||
|
stepKey: input.stepKey,
|
||||||
|
stepType: input.stepType,
|
||||||
|
status: input.status ?? 'queued',
|
||||||
|
inputHash: input.inputHash ?? null,
|
||||||
|
input: input.input ?? null,
|
||||||
|
output: input.output ?? null,
|
||||||
|
outputIds: input.outputIds ?? [],
|
||||||
|
idempotencyKey: input.idempotencyKey ?? null,
|
||||||
|
error: input.error ?? null,
|
||||||
|
attempt: input.attempt ?? 0,
|
||||||
|
startedAt: input.startedAt ?? null,
|
||||||
|
finishedAt: input.finishedAt ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(schema.aiAgentRunStep)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.aiAgentRunStep.runId, schema.aiAgentRunStep.stepKey],
|
||||||
|
set: {
|
||||||
|
stepType: input.stepType,
|
||||||
|
status: input.status ?? 'queued',
|
||||||
|
inputHash: input.inputHash ?? null,
|
||||||
|
input: input.input ?? null,
|
||||||
|
output: input.output ?? null,
|
||||||
|
outputIds: input.outputIds ?? [],
|
||||||
|
idempotencyKey: input.idempotencyKey ?? null,
|
||||||
|
error: input.error ?? null,
|
||||||
|
attempt: input.attempt ?? 0,
|
||||||
|
startedAt: input.startedAt ?? null,
|
||||||
|
finishedAt: input.finishedAt ?? null,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Upsert returned no row');
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('upsertAgentRunStep error:', error);
|
||||||
|
throw new Error('Failed to upsert agent run step');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAgentRunSteps(runId: string): Promise<TAiAgentRunStep[]> {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(schema.aiAgentRunStep)
|
||||||
|
.where(eq(schema.aiAgentRunStep.runId, runId))
|
||||||
|
.orderBy(asc(schema.aiAgentRunStep.createdAt));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('listAgentRunSteps error:', error);
|
||||||
|
throw new Error('Failed to list agent run steps');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentRunEvent(input: CreateAgentRunEventInput): Promise<TAiAgentRunEvent> {
|
||||||
|
try {
|
||||||
|
const values: TNewAiAgentRunEvent = {
|
||||||
|
runId: input.runId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
message: input.message ?? null,
|
||||||
|
payload: input.payload ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const [row] = await db.insert(schema.aiAgentRunEvent).values(values).returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Insert returned no row');
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('createAgentRunEvent error:', error);
|
||||||
|
throw new Error('Failed to create agent run event');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAgentRunEvents(runId: string, limit = 100): Promise<TAiAgentRunEvent[]> {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(schema.aiAgentRunEvent)
|
||||||
|
.where(eq(schema.aiAgentRunEvent.runId, runId))
|
||||||
|
.orderBy(asc(schema.aiAgentRunEvent.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('listAgentRunEvents error:', error);
|
||||||
|
throw new Error('Failed to list agent run events');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,15 @@ export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prom
|
|||||||
export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
|
export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
|
||||||
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
|
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
|
||||||
export const automationUsageCategory = pgEnum('AUTOMATION_USAGE_CATEGORY', ['read', 'write', 'publish']);
|
export const automationUsageCategory = pgEnum('AUTOMATION_USAGE_CATEGORY', ['read', 'write', 'publish']);
|
||||||
|
export const aiAgentRunStatus = pgEnum('AI_AGENT_RUN_STATUS', [
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'waiting_for_input',
|
||||||
|
'paused',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'canceled'
|
||||||
|
]);
|
||||||
export const widgetStatus = pgEnum('WIDGET_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
|
export const widgetStatus = pgEnum('WIDGET_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
|
||||||
export const widgetLayoutType = pgEnum('WIDGET_LAYOUT_TYPE', [
|
export const widgetLayoutType = pgEnum('WIDGET_LAYOUT_TYPE', [
|
||||||
'card_grid',
|
'card_grid',
|
||||||
@@ -3084,6 +3093,38 @@ export const aiChatConversation = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const aiChatModelContext = pgTable(
|
||||||
|
'ai_chat_model_context',
|
||||||
|
{
|
||||||
|
conversationId: uuid('conversation_id').primaryKey(),
|
||||||
|
courseId: uuid('course_id').notNull(),
|
||||||
|
userId: uuid('user_id').notNull(),
|
||||||
|
modelSummary: text('model_summary').notNull().default(''),
|
||||||
|
compactedThroughMessageId: text('compacted_through_message_id'),
|
||||||
|
sourceIds: jsonb('source_ids').default([]).notNull().$type<string[]>(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.conversationId],
|
||||||
|
foreignColumns: [aiChatConversation.id],
|
||||||
|
name: 'ai_chat_model_context_conversation_id_fkey'
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.courseId],
|
||||||
|
foreignColumns: [course.id],
|
||||||
|
name: 'ai_chat_model_context_course_id_fkey'
|
||||||
|
}),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [profile.id],
|
||||||
|
name: 'ai_chat_model_context_user_id_fkey'
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
index('idx_ai_chat_model_context_course_user').on(table.courseId, table.userId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const aiChatDocument = pgTable(
|
export const aiChatDocument = pgTable(
|
||||||
'ai_chat_document',
|
'ai_chat_document',
|
||||||
{
|
{
|
||||||
@@ -3109,6 +3150,128 @@ export const aiChatDocument = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const aiAgentRun = pgTable(
|
||||||
|
'ai_agent_run',
|
||||||
|
{
|
||||||
|
id: uuid()
|
||||||
|
.default(sql`gen_random_uuid()`)
|
||||||
|
.primaryKey()
|
||||||
|
.notNull(),
|
||||||
|
orgId: uuid('org_id').notNull(),
|
||||||
|
courseId: uuid('course_id').notNull(),
|
||||||
|
conversationId: uuid('conversation_id'),
|
||||||
|
userId: uuid('user_id').notNull(),
|
||||||
|
status: aiAgentRunStatus('status').default('queued').notNull(),
|
||||||
|
phase: varchar({ length: 64 }).default('planning').notNull(),
|
||||||
|
progressPercent: integer('progress_percent').default(0).notNull(),
|
||||||
|
currentStepKey: varchar('current_step_key', { length: 128 }),
|
||||||
|
approvedPlan: jsonb('approved_plan').$type<Record<string, unknown> | null>(),
|
||||||
|
executionCursor: jsonb('execution_cursor').default({}).notNull().$type<Record<string, unknown>>(),
|
||||||
|
sourceIds: jsonb('source_ids').default([]).notNull().$type<string[]>(),
|
||||||
|
modelSummary: text('model_summary').notNull().default(''),
|
||||||
|
queuedInstructions: jsonb('queued_instructions')
|
||||||
|
.default([])
|
||||||
|
.notNull()
|
||||||
|
.$type<Array<{ id: string; text: string; createdBy: string; createdAt: string }>>(),
|
||||||
|
lastError: jsonb('last_error').$type<{ code: string; message: string; details?: unknown } | null>(),
|
||||||
|
attempt: integer().default(0).notNull(),
|
||||||
|
maxAttempts: integer('max_attempts').default(3).notNull(),
|
||||||
|
workerId: text('worker_id'),
|
||||||
|
lockedAt: timestamp('locked_at', { withTimezone: true, mode: 'string' }),
|
||||||
|
cancelRequestedAt: timestamp('cancel_requested_at', { withTimezone: true, mode: 'string' }),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'string' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.orgId],
|
||||||
|
foreignColumns: [organization.id],
|
||||||
|
name: 'ai_agent_run_org_id_fkey'
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.courseId],
|
||||||
|
foreignColumns: [course.id],
|
||||||
|
name: 'ai_agent_run_course_id_fkey'
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.conversationId],
|
||||||
|
foreignColumns: [aiChatConversation.id],
|
||||||
|
name: 'ai_agent_run_conversation_id_fkey'
|
||||||
|
}).onDelete('set null'),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [profile.id],
|
||||||
|
name: 'ai_agent_run_user_id_fkey'
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
index('idx_ai_agent_run_course_user').on(table.courseId, table.userId),
|
||||||
|
index('idx_ai_agent_run_org_status').on(table.orgId, table.status),
|
||||||
|
index('idx_ai_agent_run_conversation').on(table.conversationId),
|
||||||
|
index('idx_ai_agent_run_updated_at').on(table.updatedAt)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiAgentRunStep = pgTable(
|
||||||
|
'ai_agent_run_step',
|
||||||
|
{
|
||||||
|
id: uuid()
|
||||||
|
.default(sql`gen_random_uuid()`)
|
||||||
|
.primaryKey()
|
||||||
|
.notNull(),
|
||||||
|
runId: uuid('run_id').notNull(),
|
||||||
|
stepKey: varchar('step_key', { length: 128 }).notNull(),
|
||||||
|
stepType: varchar('step_type', { length: 64 }).notNull(),
|
||||||
|
status: aiAgentRunStatus('status').default('queued').notNull(),
|
||||||
|
inputHash: text('input_hash'),
|
||||||
|
input: jsonb().$type<Record<string, unknown> | null>(),
|
||||||
|
output: jsonb().$type<Record<string, unknown> | null>(),
|
||||||
|
outputIds: jsonb('output_ids').default([]).notNull().$type<string[]>(),
|
||||||
|
idempotencyKey: text('idempotency_key'),
|
||||||
|
error: jsonb().$type<{ code: string; message: string; details?: unknown } | null>(),
|
||||||
|
attempt: integer().default(0).notNull(),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'string' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.runId],
|
||||||
|
foreignColumns: [aiAgentRun.id],
|
||||||
|
name: 'ai_agent_run_step_run_id_fkey'
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
unique('ai_agent_run_step_run_step_unique').on(table.runId, table.stepKey),
|
||||||
|
index('idx_ai_agent_run_step_run_status').on(table.runId, table.status)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiAgentRunEvent = pgTable(
|
||||||
|
'ai_agent_run_event',
|
||||||
|
{
|
||||||
|
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
|
||||||
|
name: 'ai_agent_run_event_id_seq',
|
||||||
|
startWith: 1,
|
||||||
|
increment: 1,
|
||||||
|
minValue: 1,
|
||||||
|
cache: 1
|
||||||
|
}),
|
||||||
|
runId: uuid('run_id').notNull(),
|
||||||
|
eventType: varchar('event_type', { length: 64 }).notNull(),
|
||||||
|
message: text(),
|
||||||
|
payload: jsonb().$type<Record<string, unknown> | null>(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.runId],
|
||||||
|
foreignColumns: [aiAgentRun.id],
|
||||||
|
name: 'ai_agent_run_event_run_id_fkey'
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
index('idx_ai_agent_run_event_run_created').on(table.runId, table.createdAt)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── BullMQ Job State (Postgres source of truth) ─────────────────────────────
|
// ─── BullMQ Job State (Postgres source of truth) ─────────────────────────────
|
||||||
// User-visible status for queued/long-running domain runs. BullMQ holds the
|
// User-visible status for queued/long-running domain runs. BullMQ holds the
|
||||||
// operational job; these tables hold the entity the dashboard polls and
|
// operational job; these tables hold the entity the dashboard polls and
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export type TSsoProvider = (typeof schema.organizationSsoProviderType.enumValues
|
|||||||
|
|
||||||
export type TOrganizationApiKeyType = (typeof schema.organizationApiKeyType.enumValues)[number];
|
export type TOrganizationApiKeyType = (typeof schema.organizationApiKeyType.enumValues)[number];
|
||||||
export type TAutomationUsageCategory = (typeof schema.automationUsageCategory.enumValues)[number];
|
export type TAutomationUsageCategory = (typeof schema.automationUsageCategory.enumValues)[number];
|
||||||
|
export type TAiAgentRunStatus = (typeof schema.aiAgentRunStatus.enumValues)[number];
|
||||||
|
|
||||||
export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number];
|
export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number];
|
||||||
|
|
||||||
@@ -214,6 +215,17 @@ export type TWidgetStatus = (typeof schema.widgetStatus.enumValues)[number];
|
|||||||
export type TWidgetLayoutType = (typeof schema.widgetLayoutType.enumValues)[number];
|
export type TWidgetLayoutType = (typeof schema.widgetLayoutType.enumValues)[number];
|
||||||
export type TWidgetSelectionMode = (typeof schema.widgetSelectionMode.enumValues)[number];
|
export type TWidgetSelectionMode = (typeof schema.widgetSelectionMode.enumValues)[number];
|
||||||
|
|
||||||
|
// ─── AI agent durable runs ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TAiAgentRun = typeof schema.aiAgentRun.$inferSelect;
|
||||||
|
export type TNewAiAgentRun = typeof schema.aiAgentRun.$inferInsert;
|
||||||
|
|
||||||
|
export type TAiAgentRunStep = typeof schema.aiAgentRunStep.$inferSelect;
|
||||||
|
export type TNewAiAgentRunStep = typeof schema.aiAgentRunStep.$inferInsert;
|
||||||
|
|
||||||
|
export type TAiAgentRunEvent = typeof schema.aiAgentRunEvent.$inferSelect;
|
||||||
|
export type TNewAiAgentRunEvent = typeof schema.aiAgentRunEvent.$inferInsert;
|
||||||
|
|
||||||
// ─── Job state ───────────────────────────────────────────────────────────────
|
// ─── Job state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type TJobStatus = (typeof schema.jobStatus.enumValues)[number];
|
export type TJobStatus = (typeof schema.jobStatus.enumValues)[number];
|
||||||
|
|||||||
@@ -2,11 +2,26 @@ import { z } from 'zod';
|
|||||||
import { AGENT_MODEL_IDS } from '../../agent-models';
|
import { AGENT_MODEL_IDS } from '../../agent-models';
|
||||||
|
|
||||||
const ZAgentCourseId = z.string().min(1);
|
const ZAgentCourseId = z.string().min(1);
|
||||||
|
const ZAgentJsonRecord = z.record(z.string(), z.unknown());
|
||||||
|
|
||||||
|
export const AGENT_RUN_STATUSES = [
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'waiting_for_input',
|
||||||
|
'paused',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'canceled'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ZAgentRunStatus = z.enum(AGENT_RUN_STATUSES);
|
||||||
|
export type TAgentRunStatus = z.infer<typeof ZAgentRunStatus>;
|
||||||
|
|
||||||
// ─── POST /agent/chat ────────────────────────────────────────────────────────
|
// ─── POST /agent/chat ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ZAgentChatBody = z.object({
|
export const ZAgentChatBody = z.object({
|
||||||
courseId: ZAgentCourseId,
|
courseId: ZAgentCourseId,
|
||||||
|
conversationId: z.string().uuid().optional(),
|
||||||
messages: z.array(z.any()), // UIMessage[] from Vercel AI SDK — validated by the SDK itself
|
messages: z.array(z.any()), // UIMessage[] from Vercel AI SDK — validated by the SDK itself
|
||||||
model: z.enum(AGENT_MODEL_IDS).optional(),
|
model: z.enum(AGENT_MODEL_IDS).optional(),
|
||||||
context: z
|
context: z
|
||||||
@@ -159,3 +174,35 @@ export const ZAgentGenerateTextBody = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type TAgentGenerateTextBody = z.infer<typeof ZAgentGenerateTextBody>;
|
export type TAgentGenerateTextBody = z.infer<typeof ZAgentGenerateTextBody>;
|
||||||
|
|
||||||
|
// ─── Agent durable runs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ZAgentRunCourseQuery = z.object({
|
||||||
|
courseId: ZAgentCourseId
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAgentRunCourseQuery = z.infer<typeof ZAgentRunCourseQuery>;
|
||||||
|
|
||||||
|
export const ZAgentRunParam = z.object({
|
||||||
|
runId: z.string().uuid()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAgentRunParam = z.infer<typeof ZAgentRunParam>;
|
||||||
|
|
||||||
|
export const ZAgentRunCreateBody = z.object({
|
||||||
|
courseId: ZAgentCourseId,
|
||||||
|
conversationId: z.string().uuid().optional(),
|
||||||
|
phase: z.string().min(1).max(64).optional(),
|
||||||
|
approvedPlan: ZAgentJsonRecord.optional(),
|
||||||
|
executionCursor: ZAgentJsonRecord.optional(),
|
||||||
|
sourceIds: z.array(z.string().min(1)).optional(),
|
||||||
|
modelSummary: z.string().max(100_000).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAgentRunCreateBody = z.infer<typeof ZAgentRunCreateBody>;
|
||||||
|
|
||||||
|
export const ZAgentRunInstructionBody = z.object({
|
||||||
|
text: z.string().min(1).max(4_000)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAgentRunInstructionBody = z.infer<typeof ZAgentRunInstructionBody>;
|
||||||
|
|||||||
Reference in New Issue
Block a user