feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -1,12 +1,14 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
import ChatInput from '$features/ai-assistant/chat-input.svelte';
import { resolve } from '$app/paths';
import { getToolLabel, getToolResultLabel, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import {
getAgentToolInput,
getAgentToolName,
getAgentToolResult,
getAgentToolStatus,
@@ -14,12 +16,16 @@
type AgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import {
isAiAssistantOpen,
chatDraft,
clearChatDraft,
initialChatModel,
initialChatPrompt,
initialChatTemplateId,
clearInitialChatModel,
clearInitialChatPrompt
clearInitialChatPrompt,
clearInitialChatTemplateId
} from '$features/ai-assistant/utils/store';
import { get } from 'svelte/store';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Chat } from '@ai-sdk/svelte';
@@ -28,23 +34,24 @@
import { getMentionableContent } from '$features/course/utils/content';
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { profile } from '$lib/utils/store/user';
import type {
AiAssistantMessage,
AiAssistantMessageMetadata,
AiAssistantTemplateMetadata,
UploadedDocument
} from '$features/ai-assistant/utils/types';
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
import {
AI_CHAT_MODEL_STORAGE_KEY,
AI_COURSE_CHAT_DEFAULT_WIDTH,
AI_COURSE_CHAT_MAX_WIDTH,
AI_COURSE_CHAT_MIN_WIDTH,
AI_COURSE_CHAT_STORAGE_KEY
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,
@@ -53,14 +60,18 @@
type AgentModelId
} from '@cio/utils/agent-models';
/** Every ~30% / 60% / 90% of tool steps completed, refetch course so UI reflects partial mutations */
const AGENT_STEP_PROGRESS_REFRESH_RATIOS = [0.3, 0.6, 0.9] as const;
/** Completed-step thresholds already refreshed for this streaming session */
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
let lastSeenStreamingFlag = false;
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
interface Props {
/** Course id from route / layout `data` (authoritative for this page). */
courseId: string;
}
let { courseId }: Props = $props();
// Read course id from the route. The chat panel is only mounted inside the
// course content layout, so `page.params.id` is always the active course.
const courseId = $derived(page.params?.id as string);
// Extract current lessonId/exerciseId from route params
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
@@ -69,15 +80,13 @@
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);
let statusFetchedForCourseId: string | null = $state(null);
let conversationsLoadedForCourseId: string | null = $state(null);
let activeConversationId: string | null = $state(null);
let chatWidth = $state(AI_COURSE_CHAT_DEFAULT_WIDTH);
let hasLoadedChatWidth = $state(false);
let isChatResizing = $state(false);
let stopChatResize: (() => void) | null = null;
let chatShellElement: HTMLDivElement | null = null;
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
@@ -120,15 +129,6 @@
}
});
function clampChatWidth(width: number) {
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
}
function clearChatResizeListeners() {
stopChatResize?.();
stopChatResize = null;
}
function getStorageKey(courseId: string) {
return `ai-chat-active-${courseId}`;
}
@@ -197,15 +197,25 @@
}
}
// Fetch status and load conversations when the panel opens or route `courseId` changes
$effect(() => {
if (!$isAiAssistantOpen) {
statusFetchedForCourseId = null;
conversationsLoadedForCourseId = null;
async function handleRenameConversation(conversationId: string, title: string) {
const updatedTitle = await aiAssistantApi.renameConversation(conversationId, title);
return;
if (!updatedTitle) {
const rawError = aiAssistantApi.error;
let message = t.get('ai_assistant.rename_chat_failed');
if (rawError && !rawError.startsWith('{')) {
message = rawError;
}
throw new Error(message);
}
}
// Fetch status and load conversations when the chat panel mounts (the
// SidePanelRail only mounts this component while the panel is active) or
// when the route's courseId changes.
$effect(() => {
if (!courseId) return;
if (statusFetchedForCourseId !== courseId) {
@@ -216,14 +226,20 @@
if (conversationsLoadedForCourseId !== courseId) {
conversationsLoadedForCourseId = courseId;
// If the panel was just opened to start a fresh chat (e.g. via quoteInChat),
// skip the auto-load so the draft effect can call startNewChat itself.
const pendingDraft = get(chatDraft);
if (pendingDraft?.mode === 'new') {
return;
}
const listForCourseId = courseId;
const savedId = getActiveConversationId(listForCourseId);
aiAssistantApi.listConversations(listForCourseId).then(() => {
if (courseId !== listForCourseId) return;
// If the initial-prompt flow already created and claimed a conversation while
// listConversations was in-flight, don't clobber it by loading another one.
if (activeConversationId) return;
if (savedId) {
@@ -323,18 +339,53 @@
}
});
const quickActions = [
'Upload a document to create a course',
'Draft a lesson',
'Generate questions from this lesson',
'Summarize this lesson',
'Help me publish this course'
];
function buildTemplateAnswersSummary(
templateId: CourseTemplateId,
answers: Record<string, string>,
fields: TemplateFormField[]
): string {
const template = getCourseTemplate(templateId);
const registryById = new Map((template?.fields ?? []).map((field) => [field.id, field]));
const lines: string[] = ['Here are my answers for the course template:'];
const seen = new Set<string>();
for (const field of fields) {
if (!field?.id || seen.has(field.id)) {
continue;
}
seen.add(field.id);
const trimmed = answers[field.id]?.trim() ?? '';
if (!trimmed) {
continue;
}
const label = registryById.get(field.id)?.label ?? field.label ?? field.id;
lines.push(`- ${label}: ${trimmed}`);
}
// Capture any answers whose field isn't in the rendered list (defensive — shouldn't happen)
for (const [fieldId, value] of Object.entries(answers)) {
if (seen.has(fieldId)) continue;
const trimmed = value.trim();
if (!trimmed) continue;
const label = registryById.get(fieldId)?.label ?? fieldId;
lines.push(`- ${label}: ${trimmed}`);
}
return lines.join('\n');
}
function handleSend() {
if (!inputValue.trim() || chat.status === 'streaming') return;
const text = inputValue;
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
const templateForFirstMessage = userMessageCount === 0 ? pendingInitialTemplateId : null;
const messageAttachment = uploadedDocument
? {
documentId: uploadedDocument.id,
@@ -342,6 +393,18 @@
}
: undefined;
const metadata: AiAssistantMessageMetadata = {};
if (messageAttachment) {
metadata.attachment = messageAttachment;
}
if (templateForFirstMessage) {
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
metadata.template = templateMeta;
pendingInitialTemplateId = null;
}
inputValue = '';
// Create conversation eagerly before sending the first message
@@ -357,7 +420,48 @@
chat.sendMessage({
text,
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
...(Object.keys(metadata).length > 0 ? { metadata } : {})
});
}
async function handleSubmitTemplateAnswers(payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) {
if (!courseId || chat.status === 'streaming') {
return;
}
await ensureActiveConversation(courseId);
chat.sendMessage({
text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields),
metadata: {
template: {
action: 'submit_template_answers',
templateId: payload.templateId,
answers: payload.answers
}
}
});
}
async function handleSkipTemplateForm(payload: { templateId: CourseTemplateId }) {
if (!courseId || chat.status === 'streaming') {
return;
}
await ensureActiveConversation(courseId);
chat.sendMessage({
text: "I'll answer your questions in chat instead of the form.",
metadata: {
template: {
action: 'skip_template_form',
templateId: payload.templateId
}
}
});
}
@@ -428,8 +532,20 @@
});
}
function handleAskPlanChanges() {
inputValue = '';
function handleAskPlanChanges(message: string) {
const trimmed = message.trim();
if (!trimmed || chat.status === 'streaming') return;
if (!activeConversationId && courseId) {
aiAssistantApi.createConversation(courseId).then((created) => {
if (created) {
setActiveConversationId(courseId, created.id);
}
});
}
chat.sendMessage({ text: trimmed });
}
function handleResume() {
@@ -441,9 +557,94 @@
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);
const status = $derived(aiAssistantApi.status);
const isStudent = $derived(status?.role === 'student');
const tutorStatus = $derived(status?.tutor);
const tutorErrorCode = $derived.by(() => {
if (!chat.error) return null;
const message = chat.error.message ?? '';
if (message.includes('AI_TUTOR_DISABLED')) return 'AI_TUTOR_DISABLED' as const;
if (message.includes('LEARNER_CAP_REACHED')) return 'LEARNER_CAP_REACHED' as const;
if (message.includes('POOL_EXHAUSTED')) return 'POOL_EXHAUSTED' as const;
return null;
});
const tutorBlocked = $derived.by(() => {
if (!isStudent) return null;
if (tutorErrorCode) return tutorErrorCode;
if (tutorStatus && tutorStatus.enabled === false) return 'AI_TUTOR_DISABLED' as const;
if (tutorStatus && tutorStatus.enforced && tutorStatus.capRemaining !== null && tutorStatus.capRemaining <= 0) {
return 'LEARNER_CAP_REACHED' as const;
}
return null;
});
const quickActions = $derived(isStudent ? [...STUDENT_QUICK_ACTION_ENTRIES] : [...AI_ASSISTANT_QUICK_ACTION_ENTRIES]);
// Student-facing per-learner monthly cap (100 messages). Hidden when the agent reports
// no tutor data (e.g. provider unconfigured) so we don't render a 0/0 bar.
const studentMessageUsage = $derived.by(() => {
if (!isStudent || !tutorStatus || tutorStatus.cap == null || tutorStatus.capRemaining == null) {
return null;
}
return {
used: Math.max(0, tutorStatus.cap - tutorStatus.capRemaining),
cap: tutorStatus.cap
};
});
const contextUsage = $derived(
calculateContextUsage(chat.messages as AiAssistantMessage[], AGENT_MODELS[selectedModel].contextWindow)
);
const mentionItems = $derived(
getMentionableContent(courseApi.course).map((item) => ({
id: item.id,
@@ -455,9 +656,31 @@
// Show an activity card whenever the agent calls any tool.
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
const planExecutionState = $derived.by(() => {
const lastAssistantMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
const lastMsg = chat.messages[chat.messages.length - 1];
if (!lastAssistantMsg) return null;
if (!lastMsg) return null;
// If the most recent message is from the user, the agent has not produced a
// reply yet — don't show the previous assistant's tool card. While streaming
// is starting up, fall through to the thinking placeholder below.
if (lastMsg.role !== 'assistant') {
if (!isStreaming) return null;
return {
steps: [
{
status: 'in_progress' as const,
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
}
],
currentActionLine: undefined,
isStopped: false,
titleKey: 'ai_assistant.plan_working',
hasMutations: false
};
}
const lastAssistantMsg = lastMsg;
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
const reachedStepLimit = continuation?.reason === 'step_limit';
@@ -466,9 +689,29 @@
) as AgentToolPart[];
// generate_course_plan renders its own PlanView — exclude it from the card
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
const toolParts = allToolParts.filter((part) => {
const toolName = getAgentToolName(part);
return toolName !== 'generate_course_plan' && toolName !== 'ask_template_questions';
});
if (toolParts.length === 0) return null;
if (toolParts.length === 0) {
if (!isStreaming) return null;
// The agent is mid-turn (reasoning / preparing a tool call) but hasn't emitted
// a real tool part yet. Show a placeholder step so the user always sees activity.
return {
steps: [
{
status: 'in_progress' as const,
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
}
],
currentActionLine: undefined,
isStopped: false,
titleKey: 'ai_assistant.plan_working',
hasMutations: false
};
}
const steps: ProgressStep[] = toolParts.flatMap((part) => {
const toolName = getAgentToolName(part);
@@ -479,9 +722,12 @@
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
const status = getAgentToolStatus(part);
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
const line =
status === 'completed'
? getCompletedToolLine(toolName, result)
: getPendingToolLine(toolName, getAgentToolInput(part));
return [{ label, status }];
return [{ line, status }];
});
const allDone = steps.every((s) => s.status === 'completed');
@@ -494,55 +740,60 @@
const toolName = getAgentToolName(part);
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
});
const title = hasMutations ? 'Applying changes' : 'Working';
const currentAction = steps.find((s) => s.status === 'in_progress')?.label;
const titleKey = hasMutations ? 'ai_assistant.plan_applying_changes' : 'ai_assistant.plan_working';
const currentActionLine = steps.find((s) => s.status === 'in_progress')?.line;
return { steps, currentAction, isStopped, title };
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
});
$effect(() => {
const streamingNow = isStreaming;
if (streamingNow && !lastSeenStreamingFlag) {
agentMutationProgressThresholdsTriggered.clear();
}
lastSeenStreamingFlag = streamingNow;
});
$effect(() => {
if (!isStreaming) {
return;
}
const state = planExecutionState;
if (!state?.hasMutations || state.isStopped) {
return;
}
const total = state.steps.length;
if (total === 0) {
return;
}
const completedStepCount = state.steps.filter((s) => s.status === 'completed').length;
const stepThresholds = [
...new Set(
AGENT_STEP_PROGRESS_REFRESH_RATIOS.map((ratio) => Math.min(total, Math.max(1, Math.ceil(total * ratio))))
)
].sort((a, b) => a - b);
for (const threshold of stepThresholds) {
if (completedStepCount >= threshold && !agentMutationProgressThresholdsTriggered.has(threshold)) {
agentMutationProgressThresholdsTriggered.add(threshold);
refreshCourseStateAfterChat();
}
}
});
const conversationTitle = $derived(
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
);
function handleChatResizePointerDown(event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
clearChatResizeListeners();
const startWidth = chatWidth;
stopChatResize = startResizablePanelDrag({
event,
startWidth,
resolveWidth: ({ startWidth, deltaX }) => clampChatWidth(startWidth - deltaX),
onPreview: (width) => {
chatShellElement?.style.setProperty('--ai-chat-width', `${width}px`);
},
onCommit: ({ width }) => {
chatWidth = width;
},
onDragStart: () => {
isChatResizing = true;
},
onDragEnd: () => {
isChatResizing = false;
stopChatResize = null;
}
});
}
onMount(() => {
try {
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
if (Number.isFinite(storedWidth)) {
chatWidth = clampChatWidth(storedWidth);
}
} catch {
// localStorage unavailable
}
try {
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
@@ -552,28 +803,22 @@
} catch {
// localStorage unavailable
}
hasLoadedChatWidth = true;
return () => {
clearChatResizeListeners();
};
});
onDestroy(() => {
clearChatResizeListeners();
});
$effect(() => {
const prompt = $initialChatPrompt;
if (!prompt || !$isAiAssistantOpen || !courseId) return;
if (!prompt || !courseId) return;
const model = $initialChatModel;
const templateFromHome = $initialChatTemplateId;
clearInitialChatPrompt();
clearInitialChatModel();
clearInitialChatTemplateId();
tick().then(() => {
pendingInitialTemplateId = templateFromHome ?? null;
if (model && isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
@@ -589,21 +834,37 @@
});
$effect(() => {
if (!hasLoadedChatWidth) {
const draft = $chatDraft;
if (!draft || !draft.text) {
return;
}
try {
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
} catch {
// localStorage unavailable
clearChatDraft();
if (draft.mode === 'new') {
void startNewChat().then(() =>
tick().then(() => {
inputValue = `${draft.text}\n\n`;
})
);
return;
}
void tick().then(() => {
const existing = inputValue.trimEnd();
inputValue = existing ? `${draft.text}\n\n${existing}` : `${draft.text}\n\n`;
});
});
</script>
{#snippet chatPanelContent()}
<div class="flex min-h-0 flex-1 flex-col">
<ChatHeader
{tokenUsage}
{isStudent}
{studentMessageUsage}
{conversationTitle}
conversations={aiAssistantApi.conversations}
{activeConversationId}
@@ -611,18 +872,21 @@
onNewChat={startNewChat}
onLoadConversation={loadConversation}
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
/>
<ChatMessageList
messages={chat.messages}
status={chat.status}
{isStreaming}
{isStudent}
{courseId}
{planExecutionState}
{quickActions}
onQuickAction={handleQuickAction}
onImplementPlan={handleImplementPlan}
onAskPlanChanges={handleAskPlanChanges}
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
onSkipTemplateForm={handleSkipTemplateForm}
onStop={handleStop}
onResume={handleResume}
onMentionClick={handleMentionClick}
@@ -633,9 +897,15 @@
{isStreaming}
{isExhausted}
{isUploading}
{contextFullBusy}
compactConversationDisabled={!activeConversationId}
onCompactConversation={handleCompactConversation}
{uploadedDocument}
{mentionItems}
{selectedModel}
{contextUsage}
{isStudent}
{tutorBlocked}
lockedModelIds={$isFreePlan ? paidModelIds : []}
error={chat.error}
onSend={handleSend}
@@ -644,37 +914,6 @@
onRemoveDocument={handleRemoveDocument}
onSelectModel={handleSelectModel}
onLockedModelSelect={handleLockedModelSelect}
onStartNewChatWithSummary={handleStartNewChatWithSummary}
/>
{/snippet}
{#if $isAiAssistantOpen}
<div
bind:this={chatShellElement}
data-ai-chat-resizing={isChatResizing}
class="contents"
style={`--ai-chat-width: ${chatWidth}px;`}
>
<div class="hidden shrink-0 md:block md:w-(--ai-chat-width)"></div>
<div
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--ai-chat-width)"
>
<button
type="button"
aria-label="Resize AI assistant panel"
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
onpointerdown={handleChatResizePointerDown}
>
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
</button>
{@render chatPanelContent()}
</div>
</div>
{/if}
<style>
:global([data-ai-chat-resizing='true'] *) {
transition-property: none !important;
}
</style>
</div>
@@ -4,7 +4,9 @@ import type {
AgentConversationCreateData,
AgentConversationSummary,
AgentStatusData,
AiAssistantMessage
AiAssistantMessage,
CompactConversationRequest,
CompactConversationSuccess
} from '../utils/types';
class AiAssistantApi extends BaseApiWithErrors {
@@ -183,6 +185,71 @@ class AiAssistantApi extends BaseApiWithErrors {
return generatedTitle;
}
async renameConversation(conversationId: string, title: string): Promise<string | null> {
let newTitle: string | null = null;
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$patch']>({
requestFn: () =>
classroomio.agent.history[':conversationId'].$patch({
param: { conversationId },
json: { title }
}),
logContext: 'renaming conversation',
onSuccess: (result) => {
newTitle = (result.data as { id: string; title: string }).title;
this.conversations = this.conversations.map((c) => (c.id === conversationId ? { ...c, title: newTitle } : c));
if (this.currentConversation?.id === conversationId) {
this.currentConversation = { ...this.currentConversation, title: newTitle };
}
}
});
return newTitle;
}
async summarizeConversation(messages: AiAssistantMessage[], courseId: string): Promise<string | null> {
let summary: string | null = null;
await this.execute<(typeof classroomio.agent)['summarize']['$post']>({
requestFn: () =>
classroomio.agent.summarize.$post({
json: { messages, courseId }
}),
logContext: 'summarizing conversation',
onSuccess: (result) => {
summary = (result.data as { summary: string }).summary;
}
});
return summary;
}
async compactConversation(conversationId: string): Promise<CompactConversationSuccess['data']['messages'] | null> {
let compacted: CompactConversationSuccess['data']['messages'] | null = null;
await this.execute<CompactConversationRequest>({
requestFn: () =>
classroomio.agent.history[':conversationId'].compact.$post({
param: { conversationId }
}),
logContext: 'compacting conversation',
onSuccess: (result) => {
compacted = result.data.messages;
if (this.currentConversation?.id === conversationId) {
this.currentConversation = {
...this.currentConversation,
messages: compacted as AiAssistantMessage[]
};
}
}
});
return compacted;
}
}
export const aiAssistantApi = new AiAssistantApi();
@@ -1,5 +1,7 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import { Input } from '@cio/ui/base/input';
import { tick } from 'svelte';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import XIcon from '@lucide/svelte/icons/x';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -22,8 +24,15 @@
remaining: number;
}
interface StudentMessageUsage {
used: number;
cap: number;
}
interface Props {
tokenUsage: TokenUsage | null;
isStudent: boolean;
studentMessageUsage: StudentMessageUsage | null;
conversations: Conversation[];
activeConversationId: string | null;
conversationTitle: string | null;
@@ -31,25 +40,132 @@
onNewChat: () => void;
onLoadConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
/** Resolves on success; rejects with Error when rename fails (message is user-presentable). */
onRenameConversation: (id: string, title: string) => Promise<void>;
}
let {
tokenUsage,
isStudent,
studentMessageUsage,
conversations,
activeConversationId,
conversationTitle,
isNewChatDisabled,
onNewChat,
onLoadConversation,
onDeleteConversation
onDeleteConversation,
onRenameConversation
}: Props = $props();
let historyPopoverOpen = $state(false);
let editingConversationTitle = $state(false);
let draftConversationTitle = $state('');
let renameConversationError = $state<string | null>(null);
let titleInputRef: HTMLInputElement | null = $state(null);
let renameCommitInFlight = $state(false);
let snapshotConversationIdForRename = $state<string | null>(null);
const canRenameConversation = $derived(
Boolean(activeConversationId && conversationTitle && conversationTitle !== 'New conversation')
);
function handleLoadConversation(id: string) {
onLoadConversation(id);
historyPopoverOpen = false;
}
function cancelRenameConversation() {
if (renameCommitInFlight) {
return;
}
if (!editingConversationTitle) {
return;
}
editingConversationTitle = false;
renameConversationError = null;
draftConversationTitle = conversationTitle ?? '';
snapshotConversationIdForRename = null;
}
$effect(() => {
if (
editingConversationTitle &&
snapshotConversationIdForRename != null &&
activeConversationId !== snapshotConversationIdForRename
) {
cancelRenameConversation();
}
});
async function startRenameConversation() {
if (!canRenameConversation || !activeConversationId || !conversationTitle) {
return;
}
renameConversationError = null;
draftConversationTitle = conversationTitle;
snapshotConversationIdForRename = activeConversationId;
editingConversationTitle = true;
await tick();
titleInputRef?.focus();
titleInputRef?.select();
}
async function commitRenameConversation() {
if (!activeConversationId) {
return;
}
const trimmedTitle = draftConversationTitle.trim();
if (trimmedTitle.length === 0) {
cancelRenameConversation();
return;
}
if (trimmedTitle === conversationTitle) {
editingConversationTitle = false;
renameConversationError = null;
snapshotConversationIdForRename = null;
return;
}
renameCommitInFlight = true;
try {
await onRenameConversation(activeConversationId, trimmedTitle);
editingConversationTitle = false;
renameConversationError = null;
snapshotConversationIdForRename = null;
} catch (renameError) {
const message = renameError instanceof Error ? renameError.message : t.get('ai_assistant.rename_chat_failed');
renameConversationError = message;
} finally {
renameCommitInFlight = false;
}
}
function handleTitleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
void commitRenameConversation();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelRenameConversation();
}
}
</script>
<div class="border-b px-4 py-3">
@@ -59,7 +175,32 @@
<div class="min-w-0">
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
{#if conversationTitle && conversationTitle !== 'New conversation'}
<p class="ui:text-muted-foreground truncate text-xs">{conversationTitle}</p>
{#if editingConversationTitle}
<div class="min-w-0">
<Input
bind:ref={titleInputRef}
bind:value={draftConversationTitle}
class="ui:h-7 ui:min-h-0 ui:px-1.5 ui:py-0 text-xs"
placeholder={$t('ai_assistant.rename_chat_placeholder')}
aria-label={$t('ai_assistant.rename_chat_input_aria')}
onkeydown={handleTitleKeydown}
onblur={cancelRenameConversation}
/>
{#if renameConversationError}
<p class="ui:text-destructive mt-0.5 text-[10px]">{renameConversationError}</p>
{/if}
</div>
{:else}
<button
type="button"
class="ui:text-muted-foreground ui:max-w-full ui:cursor-pointer ui:rounded-md ui:border ui:border-transparent ui:px-1.5 ui:py-0.5 hover:ui:bg-muted/40 hover:ui:border-border truncate text-left text-xs"
aria-label={$t('ai_assistant.rename_chat_aria')}
disabled={!canRenameConversation}
onclick={startRenameConversation}
>
{conversationTitle}
</button>
{/if}
{/if}
</div>
</div>
@@ -103,7 +244,28 @@
</IconButton>
</div>
</div>
{#if tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
{#if isStudent && studentMessageUsage}
{@const usagePercent = Math.min(100, Math.round((studentMessageUsage.used / studentMessageUsage.cap) * 100))}
<div class="mt-2">
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
<span
>{studentMessageUsage.used.toLocaleString()} / {studentMessageUsage.cap.toLocaleString()}
{$t('ai_assistant.messages_used_label')}</span
>
<span>{usagePercent}%</span>
</div>
<div class="ui:bg-muted mt-0.5 h-1 w-full rounded-full">
<div
class="h-1 rounded-full transition-all {usagePercent > 90
? 'bg-red-500'
: usagePercent > 70
? 'bg-amber-500'
: 'ui:bg-primary'}"
style="width: {usagePercent}%"
></div>
</div>
</div>
{:else if !isStudent && tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
{@const totalBudget = tokenUsage.used + tokenUsage.remaining}
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
<div class="mt-2">
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
@@ -14,6 +17,10 @@
import { openUpgradeModal } from '$lib/utils/functions/org';
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;
@@ -25,17 +32,25 @@
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;
onSend: () => void;
onStop: () => void;
onFileSelect: (file: File) => void;
onRemoveDocument: () => void;
onSelectModel: (id: AgentModelId) => void;
onLockedModelSelect?: (id: AgentModelId) => void;
onStartNewChatWithSummary: () => void;
onCompactConversation: () => void;
}
let {
@@ -43,23 +58,64 @@
isStreaming,
isExhausted,
isUploading,
contextFullBusy,
compactConversationDisabled = false,
error,
mentionItems,
uploadedDocument,
selectedModel,
lockedModelIds = [],
contextUsage,
isStudent = false,
tutorBlocked = null,
onSend,
onStop,
onFileSelect,
onRemoveDocument,
onSelectModel,
onLockedModelSelect
onLockedModelSelect,
onStartNewChatWithSummary,
onCompactConversation
}: Props = $props();
function tutorBlockedMessage(reason: NonNullable<typeof tutorBlocked>): string {
if (reason === 'LEARNER_CAP_REACHED') return t.get('aiTutor.takeABreak.learnerCap');
if (reason === 'POOL_EXHAUSTED') return t.get('aiTutor.takeABreak.poolExhausted');
return t.get('aiTutor.takeABreak.disabled');
}
let fileInputEl: HTMLInputElement | undefined = $state();
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
let wasBusy = $state(false);
function readAgentRunningWarningDismissed() {
if (!browser) {
return false;
}
try {
return localStorage.getItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY) === '1';
} catch {
return false;
}
}
let agentRunningWarningDismissed = $state(readAgentRunningWarningDismissed());
onMount(() => {
agentRunningWarningDismissed = readAgentRunningWarningDismissed();
});
function dismissAgentRunningWarning() {
agentRunningWarningDismissed = true;
try {
localStorage.setItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY, '1');
} catch {
// localStorage unavailable
}
}
$effect(() => {
const isBusy = isStreaming || isUploading;
@@ -102,6 +158,31 @@
return t.get('ai_assistant.mention_lesson');
}
function getUserFriendlyErrorMessage(errorMessage: string): string {
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes('quota exceeded') || lowerMessage.includes('rate limit')) {
const retryMatch = errorMessage.match(/retry in (\d+(?:\.\d+)?)/i);
const waitSeconds = retryMatch ? Math.ceil(parseFloat(retryMatch[1])) : null;
return waitSeconds
? t.get('ai_assistant.error_rate_limit_with_wait', { seconds: waitSeconds })
: t.get('ai_assistant.error_rate_limit');
}
if (lowerMessage.includes('context length') || lowerMessage.includes('too long')) {
return t.get('ai_assistant.error_context_too_long');
}
if (lowerMessage.includes('network') || lowerMessage.includes('connection')) {
return t.get('ai_assistant.error_network');
}
return errorMessage;
}
const displayErrorMessage = $derived(error ? getUserFriendlyErrorMessage(error.message) : null);
</script>
<input
@@ -112,108 +193,144 @@
onchange={handleFileChange}
/>
<div class="border-t px-3 pt-3 pb-1.5">
{#if isExhausted}
<div
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
<span>{$t('ai_assistant.tokens_exhausted')}</span>
{#if $currentOrgPath !== '#'}
<Button
variant="outline"
size="sm"
href={`${resolve(`${$currentOrgPath}/settings/ai-credits`)}#buy-tokens`}
class="w-full shrink-0 sm:w-auto"
>
{$t('ai_assistant.tokens_exhausted_buy_more')}
</Button>
{/if}
{#if tutorBlocked}
<div class="border-t px-3 py-4">
<div class="ui:bg-muted/40 ui:text-muted-foreground rounded-md border px-3 py-3 text-sm">
<p class="ui:text-foreground mb-1 text-sm font-medium">{$t('aiTutor.takeABreak.title')}</p>
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
</div>
{:else}
{#if isStreaming}
</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}
<div
class="mb-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
{$t('ai_assistant.agent_running_warning')}
</div>
{/if}
{#if error}
<div
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
>
{error.message}
</div>
{/if}
{#if isUploading}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<LoaderIcon size={12} class="animate-spin" />
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
</div>
{:else if uploadedDocument}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
<button
onclick={onRemoveDocument}
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
>
<XIcon size={12} />
</button>
</div>
{/if}
<div class="flex items-end gap-2">
<ChatTextarea
bind:ref={chatTextareaRef}
bind:value={inputValue}
{mentionItems}
onSubmit={onSend}
placeholder={$t('ai_assistant.input_placeholder')}
disabled={isStreaming || isUploading}
typeLabel={getTypeLabel}
emptyMessage={t.get('ai_assistant.mention_no_results')}
rows={2}
class="flex-1"
>
{#snippet icon({ item })}
{#if item.type === 'EXERCISE'}
<ExerciseIcon size={14} />
{:else if item.type === 'SECTION'}
<TableOfContentsIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#snippet actions()}
<button
onclick={handlePaperclipClick}
disabled={isUploading}
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
<span>{$t('ai_assistant.tokens_exhausted')}</span>
{#if $currentOrgPath !== '#'}
<Button
variant="outline"
size="sm"
href={`${resolve(`${$currentOrgPath}/settings/ai-credits`)}#buy-tokens`}
class="w-full shrink-0 sm:w-auto"
>
<PaperclipIcon size={16} />
{$t('ai_assistant.tokens_exhausted_buy_more')}
</Button>
{/if}
</div>
{:else}
{#if isStreaming && !agentRunningWarningDismissed && !isStudent}
<div
class="mb-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
<span class="min-w-0 flex-1">{$t('ai_assistant.agent_running_warning')}</span>
<IconButton
variant="outline"
size="icon-xs"
type="button"
tooltip={t.get('ai_assistant.agent_running_warning_dismiss')}
class="border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900"
onclick={dismissAgentRunningWarning}
>
<XIcon size={12} />
</IconButton>
</div>
{/if}
{#if displayErrorMessage}
<div
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
>
{displayErrorMessage}
</div>
{/if}
{#if isUploading}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<LoaderIcon size={12} class="animate-spin" />
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
</div>
{:else if uploadedDocument}
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
<button
onclick={onRemoveDocument}
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
>
<XIcon size={12} />
</button>
</div>
{/if}
<div class="flex-1"></div>
<div class="flex items-end gap-2">
<ChatTextarea
bind:ref={chatTextareaRef}
bind:value={inputValue}
{mentionItems}
onSubmit={onSend}
placeholder={$t('ai_assistant.input_placeholder')}
disabled={isStreaming || isUploading}
typeLabel={getTypeLabel}
emptyMessage={t.get('ai_assistant.mention_no_results')}
rows={2}
class="flex-1"
>
{#snippet icon({ item })}
{#if item.type === 'EXERCISE'}
<ExerciseIcon size={14} />
{:else if item.type === 'SECTION'}
<TableOfContentsIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#if isStreaming}
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
<SquareIcon size={12} />
</Button>
{:else}
<ModelPicker
value={selectedModel}
disabled={isStreaming}
{lockedModelIds}
onChange={onSelectModel}
onLockedSelect={onLockedModelSelect}
/>
{/if}
{/snippet}
</ChatTextarea>
</div>
{/if}
</div>
{#snippet actions()}
{#if !isStudent}
<button
onclick={handlePaperclipClick}
disabled={isUploading}
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
>
<PaperclipIcon size={16} />
</button>
{/if}
<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} />
</Button>
{:else if !isStudent}
<ModelPicker
value={selectedModel}
disabled={isStreaming}
{lockedModelIds}
onChange={onSelectModel}
onLockedSelect={onLockedModelSelect}
/>
{:else}
<Button size="sm" onclick={onSend} disabled={!inputValue.trim()} class="shrink-0">
{$t('ai_assistant.send')}
</Button>
{/if}
{/snippet}
</ChatTextarea>
</div>
{/if}
</div>
{/if}
@@ -5,26 +5,40 @@
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
import { t } from '$lib/utils/functions/translations';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
interface QuickActionOption {
key: string;
prompt: string;
}
interface PlanExecutionState {
title: string;
titleKey: string;
steps: ProgressStep[];
currentAction?: string;
currentActionLine?: ToolLineUi;
isStopped: boolean;
/** True while the assistant is invoking course-mutation tools (see `MUTATION_TOOLS`). */
hasMutations?: boolean;
}
interface Props {
messages: AiAssistantMessage[];
status: string;
isStreaming: boolean;
isStudent: boolean;
courseId: string;
planExecutionState: PlanExecutionState | null;
quickActions: string[];
quickActions: QuickActionOption[];
onQuickAction: (action: string) => void;
onImplementPlan: (editedPlan: unknown) => void;
onAskPlanChanges: () => void;
onAskPlanChanges: (message: string) => void;
onSubmitTemplateAnswers: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) => void;
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
onStop: () => void;
onResume: () => void;
onMentionClick: (route: string) => void;
@@ -32,14 +46,16 @@
let {
messages,
status,
isStreaming,
isStudent,
courseId,
planExecutionState,
quickActions,
onQuickAction,
onImplementPlan,
onAskPlanChanges,
onSubmitTemplateAnswers,
onSkipTemplateForm,
onStop,
onResume,
onMentionClick
@@ -48,13 +64,34 @@
let messagesContainer: HTMLDivElement | undefined = $state();
let lastMessageCount = $state(0);
let lastStepsCount = $state(0);
let lastCurrentAction = $state('');
let lastCurrentActionSig = $state('');
let lastStreamingSig = $state(0);
const isEmpty = $derived(messages.length === 0);
function scrollToBottom() {
// Total text length of the most recent assistant message — grows as tokens stream in.
// Used to keep the view pinned to the latest text while the AI is typing.
const streamingContentSig = $derived.by(() => {
if (messages.length === 0) return 0;
const last = messages[messages.length - 1];
if (last.role !== 'assistant') return 0;
let total = 0;
for (const part of last.parts ?? []) {
if ((part as { type?: string }).type === 'text') {
total += ((part as { text?: string }).text ?? '').length;
}
}
return total;
});
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
requestAnimationFrame(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior });
});
}
@@ -67,6 +104,16 @@
scrollToBottom();
});
$effect(() => {
if (!messagesContainer) return;
if (streamingContentSig === lastStreamingSig) return;
lastStreamingSig = streamingContentSig;
// Use 'auto' during streaming — smooth scrolls queue up per token and lag behind the cursor.
scrollToBottom('auto');
});
$effect(() => {
if (!messagesContainer || !planExecutionState) return;
@@ -77,9 +124,10 @@
return;
}
const action = planExecutionState.currentAction ?? '';
if (action !== lastCurrentAction) {
lastCurrentAction = action;
const actionSig = planExecutionState.currentActionLine ? JSON.stringify(planExecutionState.currentActionLine) : '';
if (actionSig !== lastCurrentActionSig) {
lastCurrentActionSig = actionSig;
scrollToBottom();
}
});
@@ -92,17 +140,17 @@
<div class="flex flex-col items-center gap-2 text-center">
<SparklesIcon size={32} class="ui:text-muted-foreground" />
<p class="ui:text-muted-foreground text-sm">
{$t('ai_assistant.empty_state')}
{$t(isStudent ? 'ai_assistant.student_empty_state' : 'ai_assistant.empty_state')}
</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
{#each quickActions as action (action)}
{#each quickActions as option (option.key)}
<button
onclick={() => onQuickAction(action)}
onclick={() => onQuickAction(option.prompt)}
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
>
{action}
{$t(option.key)}
</button>
{/each}
</div>
@@ -110,11 +158,31 @@
{:else}
<!-- Message list -->
<div class="flex flex-col gap-3">
{#each messages as message (message.id)}
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
{#each messages as message, messageIndex (message.id)}
{@const compaction = (message.metadata as AiAssistantMessageMetadata | undefined)?.compaction}
{@const isLast = messageIndex === messages.length - 1}
{#if compaction}
<p class="ui:text-muted-foreground mx-1 px-2 text-[11px] leading-snug">
{$t('ai_assistant.context_compacted_badge', { count: compaction.originalMessageCount })}
</p>
{/if}
<MessageBubble
{message}
{messages}
{courseId}
{isStreaming}
{isLast}
{onImplementPlan}
{onAskPlanChanges}
{onSubmitTemplateAnswers}
{onSkipTemplateForm}
{onMentionClick}
/>
{/each}
{#if status === 'submitted' && isStreaming}
{#if isStreaming && (isStudent || !planExecutionState)}
<div class="flex items-start">
<div class="ui:bg-muted rounded-lg px-3 py-2">
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
@@ -122,12 +190,14 @@
</div>
{/if}
{#if planExecutionState}
{#if planExecutionState && !isStudent}
<div class="mt-2">
<ProgressCard
title={planExecutionState.title}
titleKey={planExecutionState.titleKey}
steps={planExecutionState.steps}
currentAction={planExecutionState.currentAction}
currentActionLine={planExecutionState.currentActionLine}
{courseId}
onNavigate={onMentionClick}
onStop={isStreaming ? onStop : undefined}
isStopped={planExecutionState.isStopped}
/>
@@ -0,0 +1,66 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
import Minimize2Icon from '@lucide/svelte/icons/minimize-2';
import LoaderIcon from '@lucide/svelte/icons/loader';
import { t } from '$lib/utils/functions/translations';
interface Props {
contextFullBusy: null | 'compact' | 'new_chat';
compactConversationDisabled?: boolean;
onCompactConversation: () => void;
onStartNewChat: () => void;
}
let { contextFullBusy, compactConversationDisabled = false, onCompactConversation, onStartNewChat }: Props = $props();
const compactBusy = $derived(contextFullBusy === 'compact');
const busy = $derived(contextFullBusy !== null);
</script>
<div class="border-t px-3 py-4">
<div
class="flex flex-col items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-5 text-center dark:border-amber-800 dark:bg-amber-950"
>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
{$t('ai_assistant.context_full_title')}
</p>
<p class="ui:text-muted-foreground text-xs">
{$t('ai_assistant.context_full_description')}
</p>
<div class="flex w-full max-w-xs flex-col gap-2 pt-1 sm:max-w-sm">
<Button
variant="default"
size="sm"
onclick={onCompactConversation}
disabled={busy || compactConversationDisabled}
class="w-full gap-2"
>
{#if compactBusy}
<LoaderIcon size={14} class="shrink-0 animate-spin" />
{$t('ai_assistant.context_full_compacting')}
{:else}
<Minimize2Icon size={14} class="shrink-0" />
{$t('ai_assistant.context_full_compact')}
{/if}
</Button>
<Button
variant="outline"
size="sm"
onclick={onStartNewChat}
disabled={busy}
class="w-full gap-2 border-amber-300 bg-amber-50/70 text-amber-900 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-100 dark:hover:bg-amber-900"
>
{#if contextFullBusy === 'new_chat'}
<LoaderIcon size={14} class="shrink-0 animate-spin" />
{$t('ai_assistant.context_full_summarizing')}
{:else}
<MessageSquarePlusIcon size={14} class="shrink-0" />
{$t('ai_assistant.context_full_new_chat')}
{/if}
</Button>
</div>
</div>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import { CircularProgress } from '@cio/ui/custom/circular-progress';
import { Tooltip, Provider, Trigger, Content } from '@cio/ui/base/tooltip';
import { t } from '$lib/utils/functions/translations';
import { formatTokenCount, type ContextUsage } from './utils/context-utils';
interface Props {
contextUsage: ContextUsage;
}
let { contextUsage }: Props = $props();
const progressClass = $derived(
contextUsage.isFull ? 'ui:stroke-destructive' : contextUsage.isNearlyFull ? 'stroke-amber-500' : 'ui:stroke-primary'
);
const tooltipText = $derived(
t.get('ai_assistant.context_usage_tooltip', {
used: formatTokenCount(contextUsage.usedTokens),
max: formatTokenCount(contextUsage.maxTokens)
})
);
</script>
<Provider>
<Tooltip>
<Trigger>
<button
type="button"
class="ui:text-muted-foreground hover:ui:bg-muted flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors"
>
<CircularProgress
value={contextUsage.percentage}
size={16}
strokeWidth={2}
{progressClass}
trackClass="ui:stroke-muted"
/>
<span>{contextUsage.percentage}%</span>
</button>
</Trigger>
<Content side="top">
<p class="text-xs">{tooltipText}</p>
</Content>
</Tooltip>
</Provider>
@@ -1,4 +1,14 @@
export { default as AiCourseChat } from './ai-course-chat.svelte';
export { default as PlanView } from './plan-view.svelte';
export { default as ProgressCard } from './progress-card.svelte';
export { isAiAssistantOpen, openAiAssistant, closeAiAssistant, toggleAiAssistant } from './utils/store';
export {
AI_ASSISTANT_PANEL_ID,
chatDraft,
clearChatDraft,
openAiAssistant,
closeAiAssistant,
quoteInChat,
setChatDraft,
toggleAiAssistant
} from './utils/store';
export { aiAssistantPanelDefinition } from './panel';
@@ -8,26 +8,64 @@
import { renderMarkdown } from '$features/ai-assistant/utils/markdown';
import { renderMentions } from '$features/ai-assistant/utils/mentions';
import PlanView from '$features/ai-assistant/plan-view.svelte';
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
import TemplateFormCard from '$features/ai-assistant/template-form-card.svelte';
import { isTemplateFormResolved } from '$features/ai-assistant/utils/template-form-resolved';
import { mergeTemplateFieldsWithRegistry } from '$features/ai-assistant/utils/template-fields';
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
import {
getCompletedToolLine,
getPendingToolI18nKey,
getPendingToolI18nVars,
getPendingToolLine
} from '$features/ai-assistant/utils/tool-labels';
import type { CoursePlan } from '$features/ai-assistant/utils/course-plan';
import { t } from '$lib/utils/functions/translations';
import {
getAgentToolName,
getAgentToolInput,
getAgentToolResult,
getAgentToolStatus,
isAgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
interface Props {
message: AiAssistantMessage;
messages: AiAssistantMessage[];
courseId: string;
onImplementPlan: (editedPlan: unknown) => void;
onAskPlanChanges: () => void;
onAskPlanChanges: (message: string) => void;
isStreaming: boolean;
isLast?: boolean;
onSubmitTemplateAnswers: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) => void;
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
onMentionClick: (route: string) => void;
}
let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props();
let {
message,
messages,
courseId,
onImplementPlan,
onAskPlanChanges,
onSubmitTemplateAnswers,
onSkipTemplateForm,
onMentionClick,
isStreaming,
isLast = false
}: Props = $props();
function localizePendingTool(toolName: string): string {
const vars = getPendingToolI18nVars(toolName);
const key = getPendingToolI18nKey(toolName);
return t.get(key, vars ?? {});
}
function handleBubbleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
@@ -46,12 +84,15 @@
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
const tokenUsage = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage);
/** Tool parts rendered in a second pass so narrative text always appears above them (stream order often emits tools first). */
function isDeferredPlanPart(part: Record<string, unknown>) {
if (!isAgentToolPart(part)) {
return false;
}
return getAgentToolName(part) === 'generate_course_plan';
const name = getAgentToolName(part);
return name === 'generate_course_plan' || name === 'ask_template_questions';
}
function truncateErrorText(errorText: string): string {
@@ -72,61 +113,104 @@
return (part as { errorText?: string }).errorText;
}
const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record<string, unknown>)));
const inlineParts = $derived(message.parts.filter((part) => (part as { type?: string }).type === 'text'));
const deferredPlanParts = $derived(
message.parts.filter((part) => isDeferredPlanPart(part as Record<string, unknown>))
);
const hasBubbleContent = $derived(inlineParts.length > 0 || deferredPlanParts.length > 0 || !!messageAttachment);
const showStreamingSpinner = $derived(message.role === 'assistant' && !hasBubbleContent && isStreaming && isLast);
</script>
<div data-role={message.role} class="flex flex-col gap-1 {message.role === 'user' ? 'items-end' : 'items-start'}">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
? 'ui:bg-primary ui:text-primary-foreground'
: 'ui:bg-muted'}"
onclick={handleBubbleClick}
>
{#if messageAttachment}
<div
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
? 'bg-white/15 text-white/90'
: 'ui:bg-background/70 ui:text-muted-foreground'}"
>
<FileTextIcon size={12} class="shrink-0" />
<span class="min-w-0 truncate">{messageAttachment.name}</span>
</div>
{/if}
{#each inlineParts as part, partIndex (partIndex)}
{#if part.type === 'text'}
{#if message.role === 'assistant' && !hasBubbleContent && !showStreamingSpinner}
<!-- Assistant message has no renderable parts yet and isn't streaming — skip the empty bubble -->
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
? 'ui:bg-primary ui:text-primary-foreground'
: 'ui:bg-muted'}"
onclick={handleBubbleClick}
>
{#if showStreamingSpinner}
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
{/if}
{#if messageAttachment}
<div
class="prose prose-sm dark:prose-invert max-w-none {message.role === 'user' && 'ui:text-primary-foreground!'}"
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
? 'bg-white/15 text-white/90'
: 'ui:bg-background/70 ui:text-muted-foreground'}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
<FileTextIcon size={12} class="shrink-0" />
<span class="min-w-0 truncate">{messageAttachment.name}</span>
</div>
{:else if isAgentToolPart(part)}
{/if}
{#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' &&
'ui:text-primary-foreground!'}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
</div>
{/if}
{/each}
{#each deferredPlanParts as part, partIndex (partIndex)}
{@const toolName = getAgentToolName(part)}
{@const toolResult = getAgentToolResult(part)}
{@const toolStatus = getAgentToolStatus(part)}
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<PlanView plan={toolResult as CoursePlan} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
<div class="mt-3">
<PlanView
plan={toolResult as CoursePlan}
onImplement={onImplementPlan}
onAskChanges={onAskPlanChanges}
isBusy={isStreaming}
/>
</div>
{:else if toolName === 'ask_template_questions' && (toolStatus === 'completed' || toolStatus === 'in_progress')}
{@const merged = (toolResult ?? getAgentToolInput(part)) as
| { templateId?: CourseTemplateId; fields?: TemplateFormField[] }
| undefined}
{#if merged?.templateId}
{@const canonicalFields = mergeTemplateFieldsWithRegistry(merged.templateId, merged.fields)}
{#if canonicalFields.length > 0}
<div class="mt-3">
<TemplateFormCard
templateId={merged.templateId}
fields={canonicalFields}
allMessages={messages}
submitted={isTemplateFormResolved(messages, merged.templateId)}
disableFormInputs={toolStatus === 'in_progress'}
onSubmit={onSubmitTemplateAnswers}
onSkip={onSkipTemplateForm}
/>
</div>
{/if}
{/if}
{:else}
<div class="ui:bg-background/70 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
<div class="ui:bg-background/70 mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
{#if toolStatus === 'completed' && toolName}
<CheckIcon size={12} class="ui:text-primary shrink-0" />
<span>{getToolResultLabel(toolName, toolResult)}</span>
<ToolLine line={getCompletedToolLine(toolName, toolResult)} {courseId} onNavigate={onMentionClick} />
{:else if toolStatus === 'failed' && toolName}
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
{#if errorText}
{truncateErrorText(errorText)}
{:else}
{$t('ai_assistant.run_failed_after', { action: localizePendingTool(toolName) })}
{/if}
</span>
{#if errorText}
<button
type="button"
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
title="Copy full error"
title={$t('ai_assistant.copy_full_error')}
onclick={() => copyToClipboard(errorText)}
>
<CopyIcon size={11} />
@@ -134,68 +218,39 @@
{/if}
{:else if toolStatus === 'in_progress' && toolName}
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
<span>{getToolLabel(toolName)}</span>
<ToolLine line={getPendingToolLine(toolName)} {courseId} onNavigate={onMentionClick} />
{:else}
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
{#if toolName}
<span class="ui:text-muted-foreground">
<ToolLine line={getPendingToolLine(toolName)} {courseId} onNavigate={onMentionClick} />
</span>
{:else}
<span class="ui:text-muted-foreground">{$t('ai_assistant.generic_working')}</span>
{/if}
{/if}
</div>
{/if}
{/if}
{/each}
{/each}
{#each deferredPlanParts as part, partIndex (partIndex)}
{@const toolName = getAgentToolName(part)}
{@const toolResult = getAgentToolResult(part)}
{@const toolStatus = getAgentToolStatus(part)}
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<div class="mt-3">
<PlanView plan={toolResult as CoursePlan} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
</div>
{:else}
<div class="ui:bg-background/70 mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
{#if toolStatus === 'completed' && toolName}
<CheckIcon size={12} class="ui:text-primary shrink-0" />
<span>{getToolResultLabel(toolName, toolResult)}</span>
{:else if toolStatus === 'failed' && toolName}
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
</span>
{#if errorText}
<button
type="button"
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
title="Copy full error"
onclick={() => copyToClipboard(errorText)}
>
<CopyIcon size={11} />
</button>
{/if}
{:else if toolStatus === 'in_progress' && toolName}
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
<span>{getToolLabel(toolName)}</span>
{:else}
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
{/if}
{#if message.role === 'assistant' && tokenUsage}
<div class="mt-1 flex justify-end">
<span class="ui:text-muted-foreground text-[10px]">
{tokenUsage.totalTokens.toLocaleString()}
{$t('ai_assistant.tokens_label')}
</span>
</div>
{/if}
{/each}
{#if message.role === 'assistant' && tokenUsage}
<div class="mt-1 flex justify-end">
<span class="ui:text-muted-foreground text-[10px]">
{tokenUsage.totalTokens.toLocaleString()}
{$t('ai_assistant.tokens_label')}
</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
/* Reset global `apps/dashboard/src/app.css` `.prose p { mb-4 }` for chat bubbles */
:global(.ai-chat-prose.prose p) {
margin-bottom: 0;
}
:global(.mention-link) {
display: inline;
cursor: pointer;
@@ -207,10 +262,16 @@
transition: opacity 0.15s;
}
:global([data-role='user'] a) {
:global([data-role='user'] a),
:global([data-role='user'] .prose a) {
color: var(--primary-foreground);
}
:global([data-role='user'] .prose code:not(pre code)) {
color: rgb(9 9 11);
background-color: rgb(255 255 255 / 0.95);
}
:global(.mention-link:hover) {
opacity: 0.8;
}
@@ -0,0 +1,22 @@
import type { Component } from 'svelte';
import {
AI_COURSE_CHAT_DEFAULT_WIDTH,
AI_COURSE_CHAT_MAX_WIDTH,
AI_COURSE_CHAT_MIN_WIDTH,
AI_COURSE_CHAT_STORAGE_KEY
} from './utils/constants';
import { AI_ASSISTANT_PANEL_ID } from './utils/store';
import type { SidePanelDefinition } from '$features/side-panel';
import AiCourseChat from './ai-course-chat.svelte';
export const aiAssistantPanelDefinition: SidePanelDefinition = {
id: AI_ASSISTANT_PANEL_ID,
titleKey: 'side_panel.titles.ai_assistant',
scope: 'course',
component: AiCourseChat as Component<Record<string, unknown>>,
defaultWidth: AI_COURSE_CHAT_DEFAULT_WIDTH,
minWidth: AI_COURSE_CHAT_MIN_WIDTH,
maxWidth: AI_COURSE_CHAT_MAX_WIDTH,
widthStorageKey: AI_COURSE_CHAT_STORAGE_KEY
};
@@ -4,8 +4,11 @@
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
import PencilIcon from '@lucide/svelte/icons/pencil';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { SvelteSet } from 'svelte/reactivity';
import { t } from '$lib/utils/functions/translations';
import type { CoursePlan } from './utils/course-plan';
/** Token cost estimation heuristics (tokens per item) */
@@ -18,11 +21,31 @@
interface Props {
plan: CoursePlan;
onImplement: (editedPlan: CoursePlan) => void;
onAskChanges: () => void;
onAskChanges: (message: string) => void;
/** When true, disables the inline change-request composer (agent is already working). */
isBusy?: boolean;
remainingTokens?: number;
}
let { plan, onImplement, onAskChanges, remainingTokens }: Props = $props();
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens }: Props = $props();
let changeRequest = $state('');
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
function submitChangeRequest() {
if (!canSendChange) return;
const message = changeRequest.trim();
changeRequest = '';
onAskChanges(message);
}
function handleChangeKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submitChangeRequest();
}
}
// CoursePlan is plain JSON; structuredClone fails when the AI SDK part carries
// non-cloneable internals, so JSON round-trip is both safer and sufficient.
@@ -237,9 +260,29 @@
</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => onImplement(editablePlan)} class="flex-1">Implement Plan</Button>
<Button size="sm" variant="outline" onclick={onAskChanges} class="flex-1">Ask for Changes</Button>
<div class="flex flex-col gap-2">
<Button size="sm" onclick={() => onImplement(editablePlan)} class="w-full">
{$t('ai_assistant.plan_implement')}
</Button>
<div class="flex items-end gap-2">
<Textarea
bind:value={changeRequest}
placeholder={$t('ai_assistant.plan_ask_changes_placeholder')}
rows={1}
disabled={isBusy}
onkeydown={handleChangeKeydown}
class="ui:min-h-9! ui:resize-none ui:py-2 ui:text-sm"
/>
<Button
size="icon-sm"
variant="default"
disabled={!canSendChange}
onclick={submitChangeRequest}
aria-label={$t('ai_assistant.plan_ask_changes_send_aria')}
>
<ArrowUpIcon class="ui:size-4" />
</Button>
</div>
</div>
</div>
</div>
@@ -2,27 +2,42 @@
import CheckIcon from '@lucide/svelte/icons/check';
import LoaderIcon from '@lucide/svelte/icons/loader';
import CircleIcon from '@lucide/svelte/icons/circle';
import SquareIcon from '@lucide/svelte/icons/square';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import SquareIcon from '@lucide/svelte/icons/square';
import { Button } from '@cio/ui/base/button';
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import { t } from '$lib/utils/functions/translations';
interface ProgressStep {
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
indent?: boolean;
}
interface Props {
title: string;
titleKey: string;
steps: ProgressStep[];
currentAction?: string;
courseId: string;
/** Same handler as `@mention` clicks (SPA navigation into lesson / exercise). */
onNavigate: (route: string) => void;
currentActionLine?: ToolLineUi;
onStop?: () => void;
isStopped?: boolean;
error?: string;
}
let { title, steps, currentAction, onStop, isStopped = false, error }: Props = $props();
let { titleKey, steps, courseId, onNavigate, currentActionLine, onStop, isStopped = false, error }: Props = $props();
function stepRowKey(step: ProgressStep, index: number): string {
if (step.line.shape === 'i18n') {
return `${index}-${step.line.key}-${JSON.stringify(step.line.vars ?? {})}`;
}
if (step.line.shape === 'lesson_written') {
return `${index}-lesson-${step.line.lessonId}-${step.line.charCount}-${step.line.title}`;
}
if (step.line.shape === 'landing_page_updated') {
return `${index}-landing-${step.line.title}`;
}
return `${index}-exo-${step.line.exerciseId}-${step.line.action}-${step.line.count}-${step.line.title}`;
}
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
const totalCount = $derived(steps.length);
@@ -30,14 +45,14 @@
<div class="ui:bg-background rounded-lg border">
<div class="border-b px-3 py-2">
<h4 class="text-sm font-semibold">{title}</h4>
<h4 class="text-sm font-semibold">{$t(titleKey)}</h4>
<p class="ui:text-muted-foreground text-xs">
{completedCount} / {totalCount} steps completed
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
</p>
</div>
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
{#each steps as step, i (`${i}-${step.label}`)}
{#each steps as step, i (stepRowKey(step, i))}
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''}">
{#if step.status === 'completed'}
<CheckIcon size={12} class="ui:text-primary shrink-0" />
@@ -49,7 +64,7 @@
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
{/if}
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
{step.label}
<ToolLine line={step.line} {courseId} {onNavigate} />
</span>
</div>
{/each}
@@ -60,8 +75,10 @@
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
{/if}
{#if currentAction && !isStopped}
<p class="ui:text-muted-foreground text-xs">{currentAction}</p>
{#if currentActionLine && !isStopped}
<div class="ui:text-muted-foreground text-xs leading-snug">
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
</div>
{/if}
{#if isStopped}
@@ -71,7 +88,7 @@
{#if onStop && !isStopped && completedCount < totalCount}
<Button size="sm" variant="outline" onclick={onStop} class="w-full">
<SquareIcon size={12} class="mr-1" />
Stop
{$t('ai_assistant.stop')}
</Button>
{/if}
</div>
@@ -0,0 +1,254 @@
<script lang="ts">
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
import { getCourseTemplate } from '@cio/ai-assistant';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import GlobeIcon from '@lucide/svelte/icons/globe';
import { t } from '$lib/utils/functions/translations';
import { DISPLAY_BY_ID } from '$features/ai-assistant/utils/template-display';
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
interface Props {
templateId: CourseTemplateId;
fields: TemplateFormField[];
allMessages: AiAssistantMessage[];
submitted: boolean;
/** True while the `ask_template_questions` tool is still in progress (not the global chat busy flag). */
disableFormInputs: boolean;
onSubmit: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) => void;
onSkip: (payload: { templateId: CourseTemplateId }) => void;
}
let { templateId, fields, allMessages, submitted, disableFormInputs, onSubmit, onSkip }: Props = $props();
const display = $derived(DISPLAY_BY_ID[templateId]);
let answers = $state<Record<string, string>>({});
$effect.pre(() => {
void templateId;
void fields;
const next: Record<string, string> = {};
for (const field of fields ?? []) {
if (!field?.id) {
continue;
}
if (field.type === 'select') {
next[field.id] = field.options?.[0]?.value ?? '';
} else {
next[field.id] = '';
}
}
answers = next;
});
const resolutionKind = $derived.by((): 'submitted' | 'skipped' | null => {
for (let index = allMessages.length - 1; index >= 0; index -= 1) {
const message = allMessages[index];
if (message.role !== 'user') {
continue;
}
const tmpl = message.metadata?.template;
if (!tmpl || !('action' in tmpl)) {
continue;
}
if (tmpl.templateId !== templateId) {
continue;
}
if (tmpl.action === 'submit_template_answers') {
return 'submitted';
}
if (tmpl.action === 'skip_template_form') {
return 'skipped';
}
}
return null;
});
function resolveFieldLabel(field: TemplateFormField): string {
const key = display?.fieldLabelKeys?.[field.id];
if (key) {
const translated = t.get(key);
if (translated && translated !== key) {
return translated;
}
}
return field.label;
}
function resolveFormTitle(): string {
const key = display?.formTitleKey;
if (!key) {
return getCourseTemplate(templateId)?.formTitle ?? '';
}
const translated = t.get(key);
if (translated && translated !== key) {
return translated;
}
return getCourseTemplate(templateId)?.formTitle ?? '';
}
function isValidHttpUrl(value: string): boolean {
if (!value.trim()) {
return true;
}
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
const canSubmit = $derived.by(() => {
if (submitted || disableFormInputs) {
return false;
}
for (const field of fields ?? []) {
if (!field?.id) {
continue;
}
const raw = answers[field.id] ?? '';
const trimmed = raw.trim();
if (field.type === 'select' && !field.options?.length) {
return false;
}
if (field.required && !trimmed) {
return false;
}
if (field.type === 'url' && trimmed && !isValidHttpUrl(trimmed)) {
return false;
}
}
return true;
});
function handleSubmit() {
if (!canSubmit) {
return;
}
onSubmit({ templateId, answers: { ...answers }, fields });
}
</script>
<div class="ui:bg-card max-w-md rounded-xl border p-4">
<h4 class="ui:text-foreground mb-3 text-sm font-medium">{resolveFormTitle()}</h4>
{#if submitted}
<p class="ui:text-muted-foreground text-sm">
{resolutionKind === 'skipped'
? $t('course.creator.template.skipped_summary')
: $t('course.creator.template.submitted_summary')}
</p>
{:else}
<div class="flex flex-col gap-4">
{#each fields as field (field.id)}
{#if field.type === 'text'}
<InputField
label={resolveFieldLabel(field)}
bind:value={answers[field.id]}
placeholder={field.placeholder ?? ''}
isRequired={field.required === true}
isDisabled={disableFormInputs}
/>
{:else if field.type === 'textarea'}
<TextareaField
label={resolveFieldLabel(field)}
bind:value={answers[field.id]}
placeholder={field.placeholder ?? ''}
isRequired={field.required === true}
disabled={disableFormInputs}
rows={4}
/>
{:else if field.type === 'select'}
<Field.Field>
<Field.Label>
{resolveFieldLabel(field)}
{#if field.required}
<span class="ui:text-red-700">*</span>
{/if}
</Field.Label>
<Select.Root type="single" bind:value={answers[field.id]} disabled={disableFormInputs}>
<Select.Trigger class="ui:w-full">
{(field.options ?? []).find((option) => option.value === answers[field.id])?.label ?? ''}
</Select.Trigger>
<Select.Content style="z-index: 251">
{#each field.options ?? [] as option (option.value)}
<Select.Item value={option.value} label={option.label}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
{:else if field.type === 'url'}
<div class="flex flex-col gap-1">
<div class="flex items-start gap-2">
<GlobeIcon class="ui:text-muted-foreground mt-2.5 size-4 shrink-0" />
<div class="min-w-0 flex-1">
<InputField
label={resolveFieldLabel(field)}
bind:value={answers[field.id]}
placeholder={field.placeholder ?? ''}
type="url"
isRequired={false}
isDisabled={disableFormInputs}
/>
</div>
</div>
<p class="ui:text-muted-foreground pl-7 text-xs">
{$t('course.creator.template.field.documentation_url.hint')}
</p>
</div>
{/if}
{/each}
<div class="flex flex-col gap-2">
<Button size="sm" disabled={!canSubmit} onclick={handleSubmit}>
{$t('course.creator.template.submit')}
</Button>
<button
type="button"
class="ui:text-muted-foreground hover:ui:text-foreground text-xs underline-offset-2 hover:underline"
disabled={disableFormInputs}
onclick={() => onSkip({ templateId })}
>
{$t('course.creator.template.skip')}
</button>
</div>
</div>
{/if}
</div>
@@ -1,27 +1,29 @@
<script lang="ts">
import LoaderIcon from '@lucide/svelte/icons/loader';
import CheckIcon from '@lucide/svelte/icons/check';
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
import { getCompletedToolLine, getPendingToolLine } from '$features/ai-assistant/utils/tool-labels';
interface Props {
toolName: string;
state: string;
result?: unknown;
/** Required for linked lesson/exercise summaries in ToolLine rows */
courseId: string;
onNavigate?: (route: string) => void;
}
let { toolName, state, result }: Props = $props();
let { toolName, state, result, courseId, onNavigate = () => {} }: Props = $props();
</script>
<div class="ui:bg-background/50 flex items-center gap-2 rounded border px-2 py-1 text-xs">
{#if state === 'call' || state === 'partial-call'}
<LoaderIcon size={12} class="animate-spin" />
<span class="ui:text-muted-foreground">
{getToolLabel(toolName)}...
<span class="ui:text-muted-foreground inline-flex flex-wrap gap-x-1">
<ToolLine line={getPendingToolLine(toolName)} {courseId} {onNavigate} />…
</span>
{:else if state === 'result'}
<CheckIcon size={12} class="ui:text-primary" />
<span>
{getToolResultLabel(toolName, result)}
</span>
<span><ToolLine line={getCompletedToolLine(toolName, result)} {courseId} {onNavigate} /></span>
{/if}
</div>
@@ -1,5 +1,52 @@
/** When set (e.g. to `1`), the streaming "don't reload" banner stays hidden across sessions */
export const AI_AGENT_RUNNING_WARNING_DISMISSED_KEY = 'classroomio-ai-agent-running-warning-dismissed';
export const AI_COURSE_CHAT_DEFAULT_WIDTH = 360;
export const AI_COURSE_CHAT_MIN_WIDTH = 280;
export const AI_COURSE_CHAT_MAX_WIDTH = 520;
export const AI_COURSE_CHAT_STORAGE_KEY = 'classroomio-ai-course-chat-width';
export const AI_CHAT_MODEL_STORAGE_KEY = 'classroomio-ai-chat-model';
/** Display via `$t(entry.key)`; `prompt` is sent to the model (English). */
export const AI_ASSISTANT_QUICK_ACTION_ENTRIES = [
{
key: 'ai_assistant.quick_action.upload_document_course',
prompt: 'Upload a document to create a course'
},
{
key: 'ai_assistant.quick_action.draft_lesson',
prompt: 'Draft a lesson'
},
{
key: 'ai_assistant.quick_action.generate_questions_from_lesson',
prompt: 'Generate questions from this lesson'
},
{
key: 'ai_assistant.quick_action.summarize_lesson',
prompt: 'Summarize this lesson'
},
{
key: 'ai_assistant.quick_action.publish_course',
prompt: 'Help me publish this course'
}
] as const;
/** Student-facing quick-action chips. Display via `$t(entry.key)`; `prompt` is sent to the model (English). */
export const STUDENT_QUICK_ACTION_ENTRIES = [
{
key: 'ai_assistant.student_quick_action.explain_lesson',
prompt: 'Explain this lesson'
},
{
key: 'ai_assistant.student_quick_action.quiz_me',
prompt: 'Quiz me on this'
},
{
key: 'ai_assistant.student_quick_action.summarize_section',
prompt: 'Summarize this section'
},
{
key: 'ai_assistant.student_quick_action.what_to_review',
prompt: 'What should I review next?'
}
] as const;
@@ -0,0 +1,115 @@
import type { AiAssistantMessage, AiAssistantMessageMetadata } from './types';
const APPROX_CHARS_PER_TOKEN = 4;
const MAX_TOOL_OUTPUT_CHARS = 2000;
export interface ContextUsage {
/** Tokens used by the latest model request, or an estimate before the first response */
usedTokens: number;
/** Maximum context window size for the model */
maxTokens: number;
/** Percentage of context used (0-100) */
percentage: number;
/** Whether the context is nearly full (>= threshold) */
isNearlyFull: boolean;
/** Whether the context is completely full (>= 100%) */
isFull: boolean;
}
/** Threshold percentage at which we consider context "nearly full" */
export const CONTEXT_NEARLY_FULL_THRESHOLD = 90;
/** Threshold percentage at which we consider context "full" and block input */
export const CONTEXT_FULL_THRESHOLD = 95;
function getSerializedLength(value: unknown): number {
if (value == null) {
return 0;
}
if (typeof value === 'string') {
return value.length;
}
try {
return JSON.stringify(value).length;
} catch {
return 0;
}
}
function estimatePartChars(part: unknown): number {
if (!part || typeof part !== 'object') {
return getSerializedLength(part);
}
const record = part as Record<string, unknown>;
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'step-start') {
return 0;
}
if (type.startsWith('tool-')) {
const outputChars =
record.state === 'output-available' ? Math.min(getSerializedLength(record.output), MAX_TOOL_OUTPUT_CHARS) : 0;
return type.length + getSerializedLength(record.input) + outputChars + getSerializedLength(record.errorText);
}
if (typeof record.text === 'string') {
return record.text.length;
}
return getSerializedLength(record);
}
function estimateContextTokens(messages: AiAssistantMessage[]): number {
const chars = messages.reduce((messageTotal, message) => {
const parts = Array.isArray(message.parts) ? message.parts : [];
const partsChars = parts.reduce((partTotal, part) => partTotal + estimatePartChars(part), 0);
return messageTotal + message.role.length + partsChars;
}, 0);
return Math.ceil(chars / APPROX_CHARS_PER_TOKEN);
}
/**
* Tracks the latest provider-reported request size as the context guard.
* Do not sum historical usage: every assistant turn can include a full prompt
* replay, so summing turns overstates current context. The character estimate
* is only a fallback before the first provider usage report exists.
*/
export function calculateContextUsage(messages: AiAssistantMessage[], contextWindow: number): ContextUsage {
const latestTokenUsage = [...messages]
.reverse()
.map((message) => (message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage)
.find((tokenUsage) => tokenUsage !== undefined);
const usedTokens = latestTokenUsage?.totalTokens ?? estimateContextTokens(messages);
const percentage = contextWindow > 0 ? Math.min(100, Math.round((usedTokens / contextWindow) * 100)) : 0;
return {
usedTokens,
maxTokens: contextWindow,
percentage,
isNearlyFull: percentage >= CONTEXT_NEARLY_FULL_THRESHOLD,
isFull: percentage >= CONTEXT_FULL_THRESHOLD
};
}
/**
* Formats token count for display (e.g., "125K" for 125000)
*/
export function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`;
}
if (tokens >= 1_000) {
return `${Math.round(tokens / 1_000)}K`;
}
return tokens.toString();
}
@@ -1,23 +1,36 @@
type MentionType = 'lesson' | 'exercise' | 'section';
type MentionType = 'lesson' | 'exercise' | 'section' | 'landingpage';
/**
* Mention format: @[Title](type:id)
* Where type is "lesson", "exercise", or "section" and id is the content item ID.
* Landing page: @[Title](landingpage) or @[Title](landingpage:COURSE_ID) — resolves to this course's landing page editor.
*/
const RAW_MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise|section):([a-zA-Z0-9_-]+)\)/gi;
const RAW_LANDINGPAGE_MENTION_REGEX = /@\[([^\]]+)\]\(landingpage(?::[a-zA-Z0-9_-]+)?\)/gi;
const HTML_LINK_MENTION_REGEX = /@?<a\b[^>]*href=(["'])(lesson|exercise|section):([a-zA-Z0-9_-]+)\1[^>]*>(.*?)<\/a>/gi;
const HTML_LINK_LANDINGPAGE_MENTION_REGEX = /@?<a\b[^>]*href=(["'])landingpage(?::[a-zA-Z0-9_-]+)?\1[^>]*>(.*?)<\/a>/gi;
function normalizeMentionType(type: string): MentionType | null {
const normalizedType = type.toLowerCase();
if (normalizedType === 'lesson' || normalizedType === 'exercise' || normalizedType === 'section') {
if (
normalizedType === 'lesson' ||
normalizedType === 'exercise' ||
normalizedType === 'section' ||
normalizedType === 'landingpage'
) {
return normalizedType;
}
return null;
}
function getLandingPageEditorRoute(courseId: string): string {
return `/courses/${courseId}/landingpage`;
}
function getMentionRoute(courseId: string, type: MentionType, id: string) {
if (type === 'landingpage') return getLandingPageEditorRoute(courseId);
if (type === 'exercise') return `/courses/${courseId}/exercises/${id}`;
if (type === 'section') return `/courses/${courseId}/lessons#section-${id}`;
@@ -38,11 +51,19 @@ function buildMentionLink(route: string, label: string, title: string) {
}
/**
* Replace AI lesson/exercise mentions with real in-app links.
* Replace AI lesson, exercise, section, and landing-page mentions with in-app links.
* Handles both raw mention syntax and markdown-generated anchor tags.
*/
export function renderMentions(html: string, courseId: string): string {
const linksWithRoutes = html.replace(
const landingHtmlRoutes = html.replace(
HTML_LINK_LANDINGPAGE_MENTION_REGEX,
(match, _quote: string, label: string) => {
const route = getLandingPageEditorRoute(courseId);
return buildMentionLink(route, label, 'landingpage');
}
);
const linksWithRoutes = landingHtmlRoutes.replace(
HTML_LINK_MENTION_REGEX,
(match, _quote: string, type: string, id: string, label: string) => {
const mentionType = normalizeMentionType(type);
@@ -54,7 +75,12 @@ export function renderMentions(html: string, courseId: string): string {
}
);
return linksWithRoutes.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
const withLandingRaw = linksWithRoutes.replace(RAW_LANDINGPAGE_MENTION_REGEX, (match, title: string) => {
const route = getLandingPageEditorRoute(courseId);
return buildMentionLink(route, escapeHtml(title), `landingpage: ${title}`);
});
return withLandingRaw.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
const mentionType = normalizeMentionType(type);
if (!mentionType) return match;
@@ -1,19 +1,34 @@
import { writable } from 'svelte/store';
import type { AgentModelId } from '@cio/utils/agent-models';
import type { CourseTemplateId } from '@cio/ai-assistant';
import { sidePanel } from '$features/side-panel';
/** Controls whether the AI assistant right sidebar Sheet is open. */
export const isAiAssistantOpen = writable(false);
/**
* AI assistant is one of several side-panel apps. These helpers proxy to the
* generic side-panel store so existing call sites keep working.
*/
export const AI_ASSISTANT_PANEL_ID = 'ai-assistant';
export function openAiAssistant() {
isAiAssistantOpen.set(true);
sidePanel.open(AI_ASSISTANT_PANEL_ID);
}
export function closeAiAssistant() {
isAiAssistantOpen.set(false);
sidePanel.close();
}
export function toggleAiAssistant() {
isAiAssistantOpen.update((open) => !open);
sidePanel.toggle(AI_ASSISTANT_PANEL_ID);
}
export const initialChatTemplateId = writable<CourseTemplateId | null>(null);
export function setInitialChatTemplateId(id: CourseTemplateId) {
initialChatTemplateId.set(id);
}
export function clearInitialChatTemplateId() {
initialChatTemplateId.set(null);
}
/** Carries a prompt from the home page course creator into the AI chat on first open. */
@@ -35,3 +50,48 @@ export function clearInitialChatPrompt() {
export function clearInitialChatModel() {
initialChatModel.set(null);
}
/**
* Pending composer action picked up by the chat component:
* - `append` adds the text to whatever is already in the input (panel was open).
* - `new` starts a fresh conversation and replaces the input (panel was closed).
*/
export type ChatDraft = { text: string; mode: 'append' | 'new' };
export const chatDraft = writable<ChatDraft | null>(null);
export function clearChatDraft() {
chatDraft.set(null);
}
export function setChatDraft(draft: ChatDraft) {
chatDraft.set(draft);
}
function mdQuote(body: string) {
return body
.trim()
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
/**
* Quote `text` into the assistant. If the panel is already open the quote is
* appended to the current draft; otherwise a new conversation is opened.
*/
export function quoteInChat(text: string) {
const quoted = mdQuote(text);
if (!quoted.trim()) {
return;
}
const isOpen = sidePanel.activePanelId === AI_ASSISTANT_PANEL_ID;
chatDraft.set({ text: quoted, mode: isOpen ? 'append' : 'new' });
if (!isOpen) {
openAiAssistant();
}
}
@@ -0,0 +1,50 @@
import type { CourseTemplateId } from '@cio/ai-assistant';
export type CourseTemplateDisplay = {
iconName: 'GraduationCap' | 'Compass' | 'Award';
titleKey: string;
descriptionKey: string;
formTitleKey: string;
fieldLabelKeys: Record<string, string>;
};
export const DISPLAY_BY_ID: Record<CourseTemplateId, CourseTemplateDisplay> = {
product_101: {
iconName: 'GraduationCap',
titleKey: 'course.creator.template.product_101.title',
descriptionKey: 'course.creator.template.product_101.description',
formTitleKey: 'course.creator.template.product_101.form_title',
fieldLabelKeys: {
product_name: 'course.creator.template.field.product_name',
product_summary: 'course.creator.template.field.product_summary',
audience: 'course.creator.template.field.audience',
outcome: 'course.creator.template.field.outcome',
depth: 'course.creator.template.field.depth',
documentation_url: 'course.creator.template.field.documentation_url'
}
},
product_onboarding: {
iconName: 'Compass',
titleKey: 'course.creator.template.product_onboarding.title',
descriptionKey: 'course.creator.template.product_onboarding.description',
formTitleKey: 'course.creator.template.product_onboarding.form_title',
fieldLabelKeys: {
product_name: 'course.creator.template.field.product_name',
audience: 'course.creator.template.field.audience',
outcome: 'course.creator.template.field.outcome',
documentation_url: 'course.creator.template.field.documentation_url'
}
},
expert_on_x: {
iconName: 'Award',
titleKey: 'course.creator.template.expert_on_x.title',
descriptionKey: 'course.creator.template.expert_on_x.description',
formTitleKey: 'course.creator.template.expert_on_x.form_title',
fieldLabelKeys: {
topic: 'course.creator.template.field.topic',
expertise_level: 'course.creator.template.field.expertise_level',
outcome: 'course.creator.template.field.outcome',
documentation_url: 'course.creator.template.field.documentation_url'
}
}
};
@@ -0,0 +1,58 @@
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
/**
* The agent occasionally produces `ask_template_questions` payloads where a select field
* has lost options, fewer options than the registry, or different option labels. The
* registry is the source of truth for what the teacher should see; merge over agent output
* so the form always matches our pedagogy + UI expectations.
*/
export function mergeTemplateFieldsWithRegistry(
templateId: CourseTemplateId,
agentFields: TemplateFormField[] | undefined
): TemplateFormField[] {
const registry = getCourseTemplate(templateId);
const registryFields = registry?.fields ?? [];
if (!agentFields || agentFields.length === 0) {
return registryFields;
}
const registryById = new Map(registryFields.map((field) => [field.id, field]));
// Agent occasionally emits fields with missing/empty id, or duplicates; clean both so {#each} keying stays unique.
const seenIds = new Set<string>();
const validAgentFields = agentFields.filter((field): field is TemplateFormField => {
if (typeof field?.id !== 'string' || field.id.length === 0) return false;
if (seenIds.has(field.id)) return false;
seenIds.add(field.id);
return true;
});
return validAgentFields.map((agentField) => {
const canonical = registryById.get(agentField.id);
if (!canonical) {
return agentField;
}
if (canonical.type === 'select') {
return {
...agentField,
type: 'select',
label: canonical.label,
required: canonical.required,
placeholder: canonical.placeholder,
options: canonical.options
} as TemplateFormField;
}
return {
...agentField,
type: canonical.type,
label: canonical.label,
required: canonical.required,
placeholder: canonical.placeholder
} as TemplateFormField;
});
}
@@ -0,0 +1,22 @@
import type { CourseTemplateId } from '@cio/ai-assistant';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
export function isTemplateFormResolved(messages: AiAssistantMessage[], templateId: CourseTemplateId): boolean {
return messages.some((message) => {
if (message.role !== 'user') {
return false;
}
const meta = message.metadata as AiAssistantMessageMetadata | undefined;
const tmpl = meta?.template;
if (!tmpl || !('action' in tmpl)) {
return false;
}
return (
(tmpl.action === 'submit_template_answers' || tmpl.action === 'skip_template_form') &&
tmpl.templateId === templateId
);
});
}
@@ -1,80 +1,269 @@
const TOOL_LABELS: Record<string, string> = {
get_course_structure: 'Reading course structure',
get_lesson_content: 'Reading lesson content',
get_exercise_details: 'Reading exercise',
create_section: 'Creating section',
update_section: 'Updating section',
create_lesson: 'Creating lesson',
update_lesson: 'Updating lesson',
update_lesson_content: 'Writing lesson content',
create_exercise: 'Creating exercise',
create_exercise_section: 'Creating exercise section',
update_exercise: 'Updating exercise',
update_exercise_section: 'Updating exercise section',
add_questions: 'Adding questions',
update_questions: 'Updating questions',
reorder_content: 'Reordering content',
update_course_landing_page: 'Updating landing page',
check_course_go_live_readiness: 'Checking go-live readiness',
go_live_course: 'Publishing course',
generate_course_plan: 'Generating course plan'
};
export type AgentToolProgressStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export function getToolLabel(toolName: string): string {
return TOOL_LABELS[toolName] || toolName;
/** Render in UI via `ToolLine` / `$t` — no user-facing literals in `.ts`. */
export type ToolLineUi =
| { shape: 'i18n'; key: string; vars?: Record<string, string | number> }
| { shape: 'lesson_written'; lessonId: string; title: string; charCount: number }
| {
shape: 'exercise_questions';
exerciseId: string;
title: string;
count: number;
action: 'added' | 'updated';
}
/** Course landing page editor (same path as sidebar "Landing page"). Title may be empty. */
| { shape: 'landing_page_updated'; title: string };
export interface ProgressStep {
status: AgentToolProgressStatus;
indent?: boolean;
line: ToolLineUi;
}
export function getToolResultLabel(toolName: string, result: unknown): string {
const r = result as Record<string, unknown>;
const TOOLS_WITH_PENDING_COPY = new Set([
'get_course_structure',
'get_lesson_content',
'get_exercise_details',
'create_section',
'update_section',
'create_lesson',
'update_lesson',
'update_lesson_content',
'create_exercise',
'create_exercise_section',
'update_exercise',
'update_exercise_section',
'add_questions',
'update_questions',
'reorder_content',
'update_course_landing_page',
'check_course_go_live_readiness',
'go_live_course',
'generate_course_plan',
'ask_template_questions',
'fetch_documentation_url'
]);
/** i18n key for the running / pending description of `toolName` */
export function getPendingToolI18nKey(toolName: string): string {
if (TOOLS_WITH_PENDING_COPY.has(toolName)) {
return `ai_assistant.tool.pending.${toolName}`;
}
return 'ai_assistant.tool.pending.unknown_tool';
}
export function getPendingToolI18nVars(toolName: string): Record<string, string | number | undefined> | undefined {
if (TOOLS_WITH_PENDING_COPY.has(toolName)) {
return undefined;
}
return { toolName };
}
function readString(record: Record<string, unknown>, key: string): string | undefined {
const v = record[key];
return typeof v === 'string' ? v : undefined;
}
function prettifyUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
const path = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, '');
return `${parsed.host}${path}${parsed.search}`;
} catch {
return rawUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
}
function readPositiveInt(record: Record<string, unknown>, key: string): number {
const v = record[key];
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
}
export function getCompletedToolLine(toolName: string, result: unknown): ToolLineUi {
const r = (result ?? {}) as Record<string, unknown>;
switch (toolName) {
case 'create_section':
return `Created section: ${r.title}`;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.create_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_section':
return `Updated section: ${r.title}`;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'create_lesson':
return `Created lesson: ${r.title}`;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.create_lesson',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_lesson':
return `Updated lesson: ${r.title}`;
case 'update_lesson_content':
return `Wrote ${r.contentLength} chars`;
case 'create_exercise':
return `Created exercise with ${r.questionCount} questions`;
case 'update_exercise':
return `Updated exercise: ${r.title}`;
case 'update_exercise_section':
return `Updated exercise section: ${r.title}`;
case 'add_questions':
return `Added ${r.addedCount} questions`;
case 'update_questions':
return `Updated ${r.updatedCount} questions`;
case 'get_course_structure':
return 'Course structure loaded';
case 'get_lesson_content':
return 'Lesson content loaded';
case 'get_exercise_details':
return 'Exercise loaded';
case 'reorder_content':
return 'Content reordered';
case 'update_course_landing_page':
return `Updated landing page: ${r.title}`;
case 'check_course_go_live_readiness': {
const blockerCount = Array.isArray(r.blockers) ? r.blockers.length : 0;
const warningCount = Array.isArray(r.warnings) ? r.warnings.length : 0;
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_lesson',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_lesson_content': {
const lessonId = readString(r, 'lessonId') ?? '';
const rawTitle = readString(r, 'lessonTitle');
const displayTitle = rawTitle && rawTitle.trim().length > 0 ? rawTitle : '';
const contentLength = readPositiveInt(r, 'contentLength');
return blockerCount === 0
? `Ready to go live${warningCount > 0 ? ` with ${warningCount} warnings` : ''}`
: `${blockerCount} blockers before go-live`;
if (lessonId && displayTitle) {
return { shape: 'lesson_written', lessonId, title: displayTitle, charCount: contentLength };
}
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_lesson_content_fallback',
vars: { count: contentLength }
};
}
case 'create_exercise': {
const count = readPositiveInt(r, 'questionCount');
const title = readString(r, 'title') ?? '';
const exerciseId = readString(r, 'id') ?? '';
if (exerciseId && title) {
return { shape: 'exercise_questions', exerciseId, title, count, action: 'added' };
}
return { shape: 'i18n', key: 'ai_assistant.tool.done.create_exercise', vars: { title, count } };
}
case 'update_exercise':
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_exercise',
vars: { title: readString(r, 'title') ?? '' }
};
case 'update_exercise_section':
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_exercise_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'add_questions': {
const exerciseId = readString(r, 'exerciseId') ?? '';
const rawTitle = readString(r, 'exerciseTitle');
const displayTitle = rawTitle && rawTitle.trim().length > 0 ? rawTitle : '';
const addedCount = readPositiveInt(r, 'addedCount');
if (exerciseId && displayTitle) {
return { shape: 'exercise_questions', exerciseId, title: displayTitle, count: addedCount, action: 'added' };
}
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.add_questions_fallback',
vars: { count: addedCount }
};
}
case 'update_questions': {
const exerciseId = readString(r, 'exerciseId') ?? '';
const rawTitle = readString(r, 'exerciseTitle');
const displayTitle = rawTitle && rawTitle.trim().length > 0 ? rawTitle : '';
const updatedCount = readPositiveInt(r, 'updatedCount');
if (exerciseId && displayTitle) {
return { shape: 'exercise_questions', exerciseId, title: displayTitle, count: updatedCount, action: 'updated' };
}
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.update_questions_fallback',
vars: { count: updatedCount }
};
}
case 'create_exercise_section':
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.create_exercise_section',
vars: { title: readString(r, 'title') ?? '' }
};
case 'get_course_structure':
return { shape: 'i18n', key: 'ai_assistant.tool.done.get_course_structure' };
case 'get_lesson_content':
return { shape: 'i18n', key: 'ai_assistant.tool.done.get_lesson_content' };
case 'get_exercise_details':
return { shape: 'i18n', key: 'ai_assistant.tool.done.get_exercise_details' };
case 'reorder_content':
return { shape: 'i18n', key: 'ai_assistant.tool.done.reorder_content' };
case 'update_course_landing_page':
return { shape: 'landing_page_updated', title: readString(r, 'title')?.trim() ?? '' };
case 'check_course_go_live_readiness': {
const blockers = r.blockers;
const blockerCount = Array.isArray(blockers) ? blockers.length : 0;
const warnings = r.warnings;
const warningCount = Array.isArray(warnings) ? warnings.length : 0;
if (blockerCount === 0) {
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.check_go_live_ready',
vars: { warningCount }
};
}
return { shape: 'i18n', key: 'ai_assistant.tool.done.check_go_live_blocked', vars: { blockerCount } };
}
case 'go_live_course': {
const isPublished = r.isPublished === true;
return isPublished
? {
shape: 'i18n',
key: 'ai_assistant.tool.done.go_live_published',
vars: { title: readString(r, 'title') ?? '' }
}
: { shape: 'i18n', key: 'ai_assistant.tool.done.go_live_not_published' };
}
case 'go_live_course':
return r.isPublished ? `Published course: ${r.title}` : 'Course was not published';
case 'generate_course_plan':
return 'Course plan generated';
return { shape: 'i18n', key: 'ai_assistant.tool.done.generate_course_plan' };
case 'ask_template_questions':
return { shape: 'i18n', key: 'ai_assistant.tool.done.ask_template_questions' };
case 'fetch_documentation_url': {
const rawUrl = readString(r, 'url') ?? '';
return {
shape: 'i18n',
key: 'ai_assistant.tool.done.fetch_documentation_url',
vars: { url: rawUrl ? prettifyUrl(rawUrl) : '' }
};
}
default:
return 'Done';
return { shape: 'i18n', key: 'ai_assistant.tool.done.generic' };
}
}
export function getPendingToolLine(toolName: string, input?: unknown): ToolLineUi {
if (toolName === 'fetch_documentation_url') {
const rawUrl =
typeof input === 'object' && input !== null ? readString(input as Record<string, unknown>, 'url') : undefined;
if (rawUrl) {
return {
shape: 'i18n',
key: 'ai_assistant.tool.pending.fetch_documentation_url_with_url',
vars: { url: prettifyUrl(rawUrl) }
};
}
}
return {
shape: 'i18n',
key: getPendingToolI18nKey(toolName),
vars: getPendingToolI18nVars(toolName) as Record<string, string | number> | undefined
};
}
export const MUTATION_TOOLS = [
'create_section',
'update_section',
@@ -91,9 +280,3 @@ export const MUTATION_TOOLS = [
'update_course_landing_page',
'go_live_course'
];
export interface ProgressStep {
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
indent?: boolean;
}
@@ -0,0 +1,92 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import { t } from '$lib/utils/functions/translations';
interface Props {
line: ToolLineUi;
courseId: string;
onNavigate: (route: string) => void;
}
let { line, courseId, onNavigate }: Props = $props();
function lessonPath(lessonId: string): string {
return resolve(`/courses/${courseId}/lessons/${lessonId}`, {});
}
function exercisePath(exerciseId: string): string {
return resolve(`/courses/${courseId}/exercises/${exerciseId}`, {});
}
function landingPageEditorPath(): string {
return resolve(`/courses/${courseId}/landingpage`, {});
}
</script>
{#if line.shape === 'i18n'}
{$t(line.key, line.vars ?? {})}
{:else if line.shape === 'lesson_written'}
<span class="inline-flex flex-wrap items-baseline gap-x-1 gap-y-0.5">
<button
type="button"
class="mention-link max-w-[14rem] truncate text-left font-medium"
onclick={() => onNavigate(lessonPath(line.lessonId))}
>
{line.title}
</button>
<span class="ui:text-muted-foreground font-normal"
>{$t('ai_assistant.tool.meta.lesson_char_count', { count: line.charCount })}</span
>
</span>
{:else if line.shape === 'exercise_questions'}
<span class="inline-flex flex-wrap items-baseline gap-x-1 gap-y-0.5">
<button
type="button"
class="mention-link max-w-[14rem] truncate text-left font-medium"
onclick={() => onNavigate(exercisePath(line.exerciseId))}
>
{line.title}
</button>
<span class="ui:text-muted-foreground font-normal">
{#if line.action === 'added'}
{$t('ai_assistant.tool.meta.exercise_questions_added', { count: line.count })}
{:else}
{$t('ai_assistant.tool.meta.exercise_questions_updated', { count: line.count })}
{/if}
</span>
</span>
{:else if line.shape === 'landing_page_updated'}
<span class="inline-flex flex-wrap items-baseline gap-x-1 gap-y-0.5">
<button
type="button"
class="mention-link max-w-[14rem] truncate text-left font-medium"
onclick={() => onNavigate(landingPageEditorPath())}
>
{line.title ? line.title : $t('course.navItems.nav_landing_page')}
</button>
<span class="ui:text-muted-foreground font-normal">{$t('ai_assistant.tool.meta.landing_page_preview')}</span>
</span>
{/if}
<style>
:global(.mention-link) {
display: inline;
cursor: pointer;
font-weight: 500;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
border-radius: 0.125rem;
transition: opacity 0.15s;
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
}
:global(.mention-link:hover) {
opacity: 0.85;
}
</style>
@@ -46,6 +46,10 @@ export function getAgentToolResult(part: AgentToolPart): unknown {
return part.output;
}
export function getAgentToolInput(part: AgentToolPart): unknown {
return (part as { input?: unknown }).input;
}
export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] {
if (part.state === 'result' || part.state === 'output-available') {
return 'completed';
@@ -1,5 +1,6 @@
import type { UIMessage } from 'ai';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import type { CourseTemplateId } from '@cio/ai-assistant';
export interface UploadedDocument {
id: string;
@@ -23,10 +24,22 @@ export interface AiAssistantMessageContinuation {
finishReason?: string;
}
export type AiAssistantTemplateMetadata =
| { id: CourseTemplateId }
| { action: 'submit_template_answers'; templateId: CourseTemplateId; answers: Record<string, string> }
| { action: 'skip_template_form'; templateId: CourseTemplateId };
export interface AiAssistantCompactionMetadata {
compactedAt: string;
originalMessageCount: number;
}
export interface AiAssistantMessageMetadata {
attachment?: AiAssistantMessageAttachment;
tokenUsage?: AiAssistantMessageTokenUsage;
continuation?: AiAssistantMessageContinuation;
template?: AiAssistantTemplateMetadata;
compaction?: AiAssistantCompactionMetadata;
}
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
@@ -60,8 +73,13 @@ export type AgentConversationCreateData = AgentConversationCreateSuccess['data']
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
export type AgentHistoryRenameRequest = (typeof classroomio.agent.history)[':conversationId']['$patch'];
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
export type CompactConversationRequest = (typeof classroomio.agent.history)[':conversationId']['compact']['$post'];
export type CompactConversationSuccess = Extract<InferResponseType<CompactConversationRequest>, { success: true }>;
export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post'];
export type GenerateCourseTitleSuccess = Extract<InferResponseType<GenerateCourseTitleRequest>, { success: true }>;
export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data'];