chore: auto compact chat

This commit is contained in:
Rotimi Best
2026-05-21 19:46:17 +01:00
parent 4e4119d597
commit 30e5f9cd54
15 changed files with 1247 additions and 122 deletions
+20 -5
View File
@@ -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);
+158
View File
@@ -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
};
}
+276
View File
@@ -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);
}
+46
View File
@@ -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<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,
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 | 'compact' | 'new_chat'>(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}
/>
</div>
@@ -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<typeof tutorBlocked>): string {
@@ -200,13 +187,6 @@
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
</div>
</div>
{:else if contextUsage?.isFull && !isStreaming}
<ContextFullState
{contextFullBusy}
{compactConversationDisabled}
{onCompactConversation}
onStartNewChat={onStartNewChatWithSummary}
/>
{:else}
<div class="border-t px-3 pt-3 pb-1.5">
{#if isExhausted}
@@ -307,10 +287,6 @@
<div class="flex-1"></div>
{#if contextUsage && contextUsage.usedTokens > 0}
<ContextIndicator {contextUsage} />
{/if}
{#if isStreaming}
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
<SquareIcon size={12} />
@@ -149,7 +149,7 @@
{#each inlineParts as part, partIndex (partIndex)}
{#if part.type === 'text'}
<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!'}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
@@ -8,7 +8,6 @@ export const getSessionData = async (cookies: Cookies): Promise<App.Locals | nul
try {
const cioCookies = getCioCookieString(cookies);
console.log('cioCookies', cioCookies);
if (!cioCookies) return null;
const locals = await getThroughAuthClient(cioCookies);