diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts index ccb2a8ed..853d4126 100644 --- a/apps/api/src/routes/agent/agent.ts +++ b/apps/api/src/routes/agent/agent.ts @@ -63,7 +63,6 @@ import { eq } from 'drizzle-orm'; import { listCourseSections } from '@api/services/course/section'; import { getLesson } from '@api/services/lesson/lesson'; 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 { collectDocumentIds, @@ -74,8 +73,10 @@ import { verifyLessonBelongsToCourse } from '@api/services/agent/chat-context'; import { buildAgentTools } from '@api/services/agent/chat-tools'; +import { buildModelContextMessages } from '@api/services/agent/model-context'; import { summarizeConversation } from '@api/services/agent/summarize'; import { agentHistoryRouter } from './history'; +import { agentRunsRouter } from './runs'; const agentCoreRouter = new Hono() .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')!; 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 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); } + 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) { const isPaid = await isOrgOnPaidPlan(orgId); @@ -524,8 +533,13 @@ const agentCoreRouter = new Hono() ? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings) : buildAgentTools(orgId, user.id, courseId, messages); - const trimmedMessages = trimMessageHistory(messages); - const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages)); + const contextManaged = await buildModelContextMessages({ + conversationId, + courseId, + userId: user.id, + messages + }); + const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(contextManaged.messages as any)); let completedStepCount = 0; let finishReason: string | undefined; @@ -695,4 +709,5 @@ const agentCoreRouter = new Hono() export const agentRouter = new Hono() .use('*', agentContentTypeRewrite) .route('/', agentCoreRouter) - .route('/history', agentHistoryRouter); + .route('/history', agentHistoryRouter) + .route('/runs', agentRunsRouter); diff --git a/apps/api/src/routes/agent/runs.ts b/apps/api/src/routes/agent/runs.ts new file mode 100644 index 00000000..2feeb422 --- /dev/null +++ b/apps/api/src/routes/agent/runs.ts @@ -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'); + } + } + ); diff --git a/apps/api/src/services/agent/model-context.ts b/apps/api/src/services/agent/model-context.ts new file mode 100644 index 00000000..9d943869 --- /dev/null +++ b/apps/api/src/services/agent/model-context.ts @@ -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 | 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 { + 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 + }; +} diff --git a/apps/api/src/services/agent/run-state.ts b/apps/api/src/services/agent/run-state.ts new file mode 100644 index 00000000..80e38618 --- /dev/null +++ b/apps/api/src/services/agent/run-state.ts @@ -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(['queued', 'running', 'waiting_for_input', 'paused']); + +export interface CreateAgentRunStateInput { + orgId: string; + userId: string; + courseId: string; + conversationId?: string; + phase?: string; + approvedPlan?: Record; + executionCursor?: Record; + sourceIds?: string[]; + modelSummary?: string; +} + +export interface AgentRunDetail { + run: TAiAgentRun; + steps: TAiAgentRunStep[]; + events: TAiAgentRunEvent[]; +} + +async function assertCourseRunAccess(courseId: string, userId: string, orgId: string): Promise { + 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 { + 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 { + 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 { + 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[0], 'runId' | 'userId'> +): Promise { + 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 { + 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 { + await assertCourseRunAccess(courseId, userId, orgId); + + return listAgentRuns(courseId, userId); +} + +export async function getAgentRunState(runId: string, userId: string, orgId: string): Promise { + const run = await getOwnedAgentRun(runId, userId, orgId); + + return buildAgentRunDetail(run); +} + +export async function cancelAgentRunState(runId: string, userId: string, orgId: string): Promise { + 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 { + 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 { + 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 { + 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); +} diff --git a/apps/api/src/services/agent/summarize.ts b/apps/api/src/services/agent/summarize.ts index 7c352575..c6b6a655 100644 --- a/apps/api/src/services/agent/summarize.ts +++ b/apps/api/src/services/agent/summarize.ts @@ -38,6 +38,10 @@ export interface SummarizeOptions { messages: InboundMessage[]; } +export interface SummarizeDeltaOptions extends SummarizeOptions { + existingSummary?: string | null; +} + function truncate(input: string, max: number): string { if (input.length <= max) return input; @@ -138,3 +142,45 @@ export async function summarizeConversation(options: SummarizeOptions): Promise< return result.text.trim(); } + +export async function summarizeConversationDelta(options: SummarizeDeltaOptions): Promise { + 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(); +} diff --git a/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte index 3c35ce6b..23140b2e 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte @@ -51,7 +51,6 @@ AI_ASSISTANT_QUICK_ACTION_ENTRIES, STUDENT_QUICK_ACTION_ENTRIES } from '$features/ai-assistant/utils/constants'; - import { calculateContextUsage } from '$features/ai-assistant/utils/context-utils'; import { AGENT_MODELS, AGENT_MODEL_IDS, @@ -80,7 +79,6 @@ let inputValue = $state(''); let uploadedDocument: UploadedDocument | null = $state(null); let isUploading = $state(false); - let contextFullBusy = $state(null); let pendingInitialTemplateId: CourseTemplateId | null = $state(null); @@ -321,6 +319,7 @@ credentials: 'include', body: () => ({ courseId, + conversationId: activeConversationId ?? undefined, model: selectedModel, context: { lessonId: page.params?.lessonId, @@ -379,8 +378,9 @@ return lines.join('\n'); } - function handleSend() { + async function handleSend() { if (!inputValue.trim() || chat.status === 'streaming') return; + if (!courseId) return; const text = inputValue; const userMessageCount = chat.messages.filter((message) => message.role === 'user').length; @@ -402,22 +402,17 @@ if (templateForFirstMessage) { const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage }; metadata.template = templateMeta; + } + + const conversationId = await ensureActiveConversation(courseId); + if (!conversationId) return; + + if (templateForFirstMessage) { pendingInitialTemplateId = null; } 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({ text, ...(Object.keys(metadata).length > 0 ? { metadata } : {}) @@ -433,7 +428,8 @@ return; } - await ensureActiveConversation(courseId); + const conversationId = await ensureActiveConversation(courseId); + if (!conversationId) return; chat.sendMessage({ text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields), @@ -452,7 +448,8 @@ return; } - await ensureActiveConversation(courseId); + const conversationId = await ensureActiveConversation(courseId); + if (!conversationId) return; chat.sendMessage({ text: "I'll answer your questions in chat instead of the form.", @@ -499,28 +496,22 @@ function handleQuickAction(action: string) { inputValue = action; - handleSend(); + void handleSend(); } function handleStop() { chat.stop(); } - function handleImplementPlan(editedPlan: unknown) { + async function handleImplementPlan(editedPlan: unknown) { if (chat.status === 'streaming') return; + if (!courseId) return; + + const conversationId = await ensureActiveConversation(courseId); + if (!conversationId) return; inputValue = ''; - if (!activeConversationId) { - if (!courseId) return; - - aiAssistantApi.createConversation(courseId).then((created) => { - if (created) { - setActiveConversationId(courseId, created.id); - } - }); - } - chat.sendMessage({ text: 'Implement this plan.', metadata: { @@ -532,74 +523,27 @@ }); } - function handleAskPlanChanges(message: string) { + async function handleAskPlanChanges(message: string) { const trimmed = message.trim(); if (!trimmed || chat.status === 'streaming') return; + if (!courseId) return; - if (!activeConversationId && courseId) { - aiAssistantApi.createConversation(courseId).then((created) => { - if (created) { - setActiveConversationId(courseId, created.id); - } - }); - } + const conversationId = await ensureActiveConversation(courseId); + if (!conversationId) return; chat.sendMessage({ text: trimmed }); } function handleResume() { inputValue = CONTINUE_IMPLEMENTATION_PROMPT; - handleSend(); + void handleSend(); } function handleMentionClick(route: string) { 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 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( getMentionableContent(courseApi.course).map((item) => ({ id: item.id, @@ -829,7 +769,7 @@ } inputValue = prompt; - handleSend(); + void handleSend(); }); }); @@ -897,13 +837,9 @@ {isStreaming} {isExhausted} {isUploading} - {contextFullBusy} - compactConversationDisabled={!activeConversationId} - onCompactConversation={handleCompactConversation} {uploadedDocument} {mentionItems} {selectedModel} - {contextUsage} {isStudent} {tutorBlocked} lockedModelIds={$isFreePlan ? paidModelIds : []} @@ -914,6 +850,5 @@ onRemoveDocument={handleRemoveDocument} onSelectModel={handleSelectModel} onLockedModelSelect={handleLockedModelSelect} - onStartNewChatWithSummary={handleStartNewChatWithSummary} /> diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte index d8405148..532e391e 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte @@ -18,9 +18,6 @@ import { ModelPicker } from '@cio/ui/custom/model-picker'; import type { AgentModelId } from '@cio/utils/agent-models'; 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 { id: string; @@ -32,14 +29,11 @@ isStreaming: boolean; isExhausted: boolean; isUploading: boolean; - contextFullBusy: null | 'compact' | 'new_chat'; - compactConversationDisabled?: boolean; error: Error | null | undefined; mentionItems: MentionItem[]; uploadedDocument: UploadedDocument | null; selectedModel: AgentModelId; lockedModelIds?: readonly AgentModelId[]; - contextUsage: ContextUsage | null; isStudent?: boolean; /** 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; @@ -49,8 +43,6 @@ onRemoveDocument: () => void; onSelectModel: (id: AgentModelId) => void; onLockedModelSelect?: (id: AgentModelId) => void; - onStartNewChatWithSummary: () => void; - onCompactConversation: () => void; } let { @@ -58,14 +50,11 @@ isStreaming, isExhausted, isUploading, - contextFullBusy, - compactConversationDisabled = false, error, mentionItems, uploadedDocument, selectedModel, lockedModelIds = [], - contextUsage, isStudent = false, tutorBlocked = null, onSend, @@ -73,9 +62,7 @@ onFileSelect, onRemoveDocument, onSelectModel, - onLockedModelSelect, - onStartNewChatWithSummary, - onCompactConversation + onLockedModelSelect }: Props = $props(); function tutorBlockedMessage(reason: NonNullable): string { @@ -200,13 +187,6 @@

{tutorBlockedMessage(tutorBlocked)}

-{:else if contextUsage?.isFull && !isStreaming} - {:else}
{#if isExhausted} @@ -307,10 +287,6 @@
- {#if contextUsage && contextUsage.usedTokens > 0} - - {/if} - {#if isStreaming}