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
@@ -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);