feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -0,0 +1,586 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
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 type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import {
getAgentToolName,
getAgentToolResult,
getAgentToolStatus,
isAgentToolPart,
type AgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import { isAiAssistantOpen } from '$features/ai-assistant/utils/store';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Chat } from '@ai-sdk/svelte';
import { DefaultChatTransport } from 'ai';
import { courseApi, lessonApi } from '$features/course/api';
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 { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { profile } from '$lib/utils/store/user';
import type { AiAssistantMessage, UploadedDocument } from '$features/ai-assistant/utils/types';
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
} from '$features/ai-assistant/utils/constants';
import { AGENT_MODEL_IDS, DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
// Extract current lessonId/exerciseId from route params
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
const currentExerciseId = $derived(page.params?.exerciseId as string | undefined);
let inputValue = $state('');
let uploadedDocument: UploadedDocument | null = $state(null);
let isUploading = $state(false);
let usageFetched = $state(false);
let conversationsLoaded = $state(false);
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);
function isAgentModelId(value: unknown): value is AgentModelId {
return typeof value === 'string' && (AGENT_MODEL_IDS as readonly string[]).includes(value);
}
function handleSelectModel(id: AgentModelId) {
selectedModel = id;
try {
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, id);
} catch {
// localStorage unavailable
}
}
const tokenUsage = $derived(aiAssistantApi.status?.usage ?? null);
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}`;
}
function getActiveConversationId(courseId: string): string | null {
try {
return localStorage.getItem(getStorageKey(courseId));
} catch {
return null;
}
}
function setActiveConversationId(courseId: string, conversationId: string | null) {
activeConversationId = conversationId;
try {
if (conversationId) {
localStorage.setItem(getStorageKey(courseId), conversationId);
} else {
localStorage.removeItem(getStorageKey(courseId));
}
} catch {
// localStorage unavailable
}
}
async function loadConversation(conversationId: string) {
const courseId = courseApi.course?.id;
if (!courseId) return;
await aiAssistantApi.loadConversation(conversationId);
const conversation = aiAssistantApi.currentConversation;
if (conversation) {
chat.messages = (conversation.messages ?? []) as AiAssistantMessage[];
setActiveConversationId(courseId, conversationId);
}
}
async function startNewChat() {
const courseId = courseApi.course?.id;
if (!courseId) return;
const created = await aiAssistantApi.createConversation(courseId);
if (created) {
chat.messages = [];
setActiveConversationId(courseId, created.id);
}
}
async function handleDeleteConversation(conversationId: string) {
const courseId = courseApi.course?.id;
if (!courseId) return;
await aiAssistantApi.deleteConversation(conversationId);
if (activeConversationId === conversationId) {
chat.messages = [];
setActiveConversationId(courseId, null);
}
}
// Fetch status and load conversations when panel opens
$effect(() => {
const courseId = courseApi.course?.id;
if ($isAiAssistantOpen && !usageFetched && courseId) {
usageFetched = true;
aiAssistantApi.fetchStatus(courseId);
}
if ($isAiAssistantOpen && !conversationsLoaded && courseId) {
conversationsLoaded = true;
const savedId = getActiveConversationId(courseId);
aiAssistantApi.listConversations(courseId).then(() => {
if (savedId) {
loadConversation(savedId);
} else if (aiAssistantApi.conversations.length > 0) {
loadConversation(aiAssistantApi.conversations[0].id);
}
});
}
if (!$isAiAssistantOpen) {
usageFetched = false;
conversationsLoaded = false;
}
});
const chat = new Chat({
id: 'ai-assistant',
transport: new DefaultChatTransport({
api: `${getRequestBaseUrl()}/agent/chat`,
credentials: 'include',
body: () => ({
courseId: courseApi.course?.id ?? '',
model: selectedModel,
context: {
lessonId: page.params?.lessonId,
exerciseId: page.params?.exerciseId,
documentId: uploadedDocument?.id
}
}),
fetch: (input, init) => apiClient.request(input, init)
}),
onFinish: async () => {
const courseId = courseApi.course?.id;
const profileId = $profile.id;
// Force refetch course data so new sections/lessons/exercises show in the UI
if (courseId && profileId) {
courseApi.refreshCourse(courseId, profileId);
}
// Refresh current lesson content if viewing a lesson
if (courseId && currentLessonId) {
lessonApi.get(courseId, currentLessonId);
}
// Refresh current exercise if viewing an exercise
if (courseId && currentExerciseId) {
await refreshExercisePageData(courseId, currentExerciseId);
}
// Refresh usage meter
if (courseId) {
aiAssistantApi.fetchStatus(courseId);
}
// Clear document attachment after message is processed
uploadedDocument = null;
// Persist messages to the active conversation
if (activeConversationId) {
await aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
// Generate a smart title after the first exchange (when title is still default)
const activeConv = aiAssistantApi.conversations.find((c) => c.id === activeConversationId);
const isDefaultTitle = !activeConv?.title || activeConv.title === 'New conversation';
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
const textPart = firstUserMsg?.parts?.find((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text');
if (isDefaultTitle && textPart?.text) {
await aiAssistantApi.generateTitle(activeConversationId, textPart.text.slice(0, 500));
}
} else if (courseId) {
// No active conversation yet — create one and save
const created = await aiAssistantApi.createConversation(courseId);
if (created) {
setActiveConversationId(courseId, created.id);
await aiAssistantApi.saveMessages(created.id, chat.messages as AiAssistantMessage[]);
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
const textPart = firstUserMsg?.parts?.find(
(p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text'
);
if (textPart?.text) {
await aiAssistantApi.generateTitle(created.id, textPart.text.slice(0, 500));
}
}
}
}
});
const quickActions = [
'Upload a document to create a course',
'Draft a lesson',
'Generate questions from this lesson',
"Improve this lesson's text",
'Summarize this lesson'
];
function handleSend() {
if (!inputValue.trim() || chat.status === 'streaming') return;
const text = inputValue;
const messageAttachment = uploadedDocument
? {
documentId: uploadedDocument.id,
name: uploadedDocument.name
}
: undefined;
inputValue = '';
// Create conversation eagerly before sending the first message
if (!activeConversationId) {
const courseId = courseApi.course?.id;
if (!courseId) return;
aiAssistantApi.createConversation(courseId).then((created) => {
if (created) {
setActiveConversationId(courseId, created.id);
}
});
}
chat.sendMessage({
text,
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
});
}
async function ensureActiveConversation(courseId: string): Promise<string | null> {
if (activeConversationId) return activeConversationId;
const created = await aiAssistantApi.createConversation(courseId);
if (!created) return null;
setActiveConversationId(courseId, created.id);
return created.id;
}
async function handleFileSelect(file: File) {
const courseId = courseApi.course?.id;
if (!courseId) return;
const conversationId = await ensureActiveConversation(courseId);
if (!conversationId) return;
isUploading = true;
const result = await aiAssistantApi.uploadDocument(file, courseId, conversationId);
isUploading = false;
if (result) {
uploadedDocument = { id: result.documentId, name: result.fileName };
}
}
function handleRemoveDocument() {
uploadedDocument = null;
}
function handleQuickAction(action: string) {
inputValue = action;
handleSend();
}
function handleStop() {
chat.stop();
}
function handleImplementPlan(editedPlan: unknown) {
if (chat.status === 'streaming') return;
inputValue = '';
const courseId = courseApi.course?.id;
if (!activeConversationId) {
if (!courseId) return;
aiAssistantApi.createConversation(courseId).then((created) => {
if (created) {
setActiveConversationId(courseId, created.id);
}
});
}
chat.sendMessage({
text: 'Implement this plan.',
metadata: {
plan: {
action: 'implement_course_plan',
payload: editedPlan
}
}
});
}
function handleAskPlanChanges() {
inputValue = '';
}
function handleResume() {
inputValue = 'Continue implementing the plan from where you left off.';
handleSend();
}
function handleMentionClick(route: string) {
goto(resolve(route, {}));
}
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
const mentionItems = $derived(
getMentionableContent(courseApi.course).map((item) => ({
id: item.id,
label: item.title,
type: item.type
}))
);
// 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');
if (!lastAssistantMsg) return null;
const allToolParts = lastAssistantMsg.parts.filter((part: Record<string, unknown>) =>
isAgentToolPart(part)
) as AgentToolPart[];
// generate_course_plan renders its own PlanView — exclude it from the card
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
if (toolParts.length === 0) return null;
const steps: ProgressStep[] = toolParts.flatMap((part) => {
const toolName = getAgentToolName(part);
if (!toolName) {
return [];
}
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
const status = getAgentToolStatus(part);
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
return [{ label, status }];
});
const allDone = steps.every((s) => s.status === 'completed');
const isStopped = !isStreaming && !allDone;
// Hide the card once the agent finishes cleanly — the text response takes over
if (allDone && !isStreaming) return null;
const hasMutations = toolParts.some((part) => {
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;
return { steps, currentAction, isStopped, title };
});
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);
if (isAgentModelId(storedModel)) {
selectedModel = storedModel;
}
} catch {
// localStorage unavailable
}
hasLoadedChatWidth = true;
return () => {
clearChatResizeListeners();
};
});
onDestroy(() => {
clearChatResizeListeners();
});
$effect(() => {
if (!hasLoadedChatWidth) {
return;
}
try {
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
} catch {
// localStorage unavailable
}
});
</script>
{#snippet chatPanelContent()}
<ChatHeader
{tokenUsage}
{conversationTitle}
conversations={aiAssistantApi.conversations}
{activeConversationId}
isNewChatDisabled={chat.messages.length === 0}
onNewChat={startNewChat}
onLoadConversation={loadConversation}
onDeleteConversation={handleDeleteConversation}
/>
<ChatMessageList
messages={chat.messages}
status={chat.status}
{isStreaming}
courseId={courseApi.course?.id ?? ''}
{planExecutionState}
{quickActions}
onQuickAction={handleQuickAction}
onImplementPlan={handleImplementPlan}
onAskPlanChanges={handleAskPlanChanges}
onStop={handleStop}
onResume={handleResume}
onMentionClick={handleMentionClick}
/>
<ChatInput
bind:inputValue
{isStreaming}
{isExhausted}
{isUploading}
{uploadedDocument}
{mentionItems}
{selectedModel}
error={chat.error}
onSend={handleSend}
onStop={handleStop}
onFileSelect={handleFileSelect}
onRemoveDocument={handleRemoveDocument}
onSelectModel={handleSelectModel}
/>
{/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>
@@ -0,0 +1,171 @@
import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api';
import type {
AgentConversation,
AgentConversationCreateData,
AgentConversationSummary,
AgentStatusData,
AiAssistantMessage
} from '../utils/types';
class AiAssistantApi extends BaseApiWithErrors {
status: AgentStatusData | null = $state(null);
conversations: AgentConversationSummary[] = $state([]);
currentConversation: AgentConversation | null = $state(null);
async fetchStatus(courseId: string) {
await this.execute<typeof classroomio.agent.status.$get>({
requestFn: () =>
classroomio.agent.status.$get({
query: { courseId }
}),
logContext: 'fetching agent status',
onSuccess: (result) => {
this.status = result.data;
}
});
}
async listConversations(courseId: string) {
await this.execute<typeof classroomio.agent.history.$get>({
requestFn: () =>
classroomio.agent.history.$get({
query: { courseId }
}),
logContext: 'listing conversations',
onSuccess: (result) => {
this.conversations = result.data as AgentConversationSummary[];
}
});
}
async loadConversation(conversationId: string) {
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$get']>({
requestFn: () =>
classroomio.agent.history[':conversationId'].$get({
param: { conversationId }
}),
logContext: 'loading conversation',
onSuccess: (result) => {
this.currentConversation = {
...(result.data as AgentConversation),
messages: ((result.data as AgentConversation).messages ?? []) as AiAssistantMessage[]
};
}
});
}
async createConversation(courseId: string, title?: string): Promise<{ id: string } | null> {
let created: { id: string } | null = null;
await this.execute<typeof classroomio.agent.history.$post>({
requestFn: () =>
classroomio.agent.history.$post({
json: { courseId, title }
}),
logContext: 'creating conversation',
onSuccess: (result) => {
const newConversation = result.data as AgentConversationCreateData;
created = newConversation;
this.conversations = [
{ ...newConversation, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
...this.conversations
];
}
});
return created;
}
async saveMessages(conversationId: string, messages: AiAssistantMessage[], title?: string) {
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$put']>({
requestFn: () =>
classroomio.agent.history[':conversationId'].$put({
param: { conversationId },
json: { messages, title }
}),
logContext: 'saving messages'
});
}
async deleteConversation(conversationId: string) {
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$delete']>({
requestFn: () =>
classroomio.agent.history[':conversationId'].$delete({
param: { conversationId }
}),
logContext: 'deleting conversation',
onSuccess: () => {
this.conversations = this.conversations.filter((c) => c.id !== conversationId);
if (this.currentConversation?.id === conversationId) {
this.currentConversation = null;
}
}
});
}
async uploadDocument(
file: File,
courseId: string,
conversationId: string
): Promise<{ documentId: string; fileName: string; wordCount: number; truncated: boolean } | null> {
const formData = new FormData();
formData.append('file', file);
try {
const url =
`${getRequestBaseUrl()}/agent/upload` +
`?courseId=${encodeURIComponent(courseId)}` +
`&conversationId=${encodeURIComponent(conversationId)}`;
const response = await apiClient.request(url, {
method: 'POST',
body: formData,
credentials: 'include'
});
const result = (await response.json()) as {
success: boolean;
data?: { documentId: string; fileName: string; wordCount: number; truncated: boolean };
};
if (result.success && result.data) {
return result.data;
}
} catch (error) {
console.error('Error uploading document:', error);
this.error = 'Failed to upload document';
}
return null;
}
async generateTitle(conversationId: string, firstMessageText: string): Promise<string | null> {
let generatedTitle: string | null = null;
await this.execute<(typeof classroomio.agent.history)[':conversationId']['generate-title']['$post']>({
requestFn: () =>
classroomio.agent.history[':conversationId']['generate-title'].$post({
param: { conversationId },
json: { firstMessageText }
}),
logContext: 'generating title',
onSuccess: (result) => {
generatedTitle = (result.data as { title: string }).title;
// Update the title in the local conversations list
this.conversations = this.conversations.map((c) =>
c.id === conversationId ? { ...c, title: generatedTitle } : c
);
if (this.currentConversation?.id === conversationId) {
this.currentConversation = { ...this.currentConversation, title: generatedTitle };
}
}
});
return generatedTitle;
}
}
export const aiAssistantApi = new AiAssistantApi();
@@ -0,0 +1,119 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import XIcon from '@lucide/svelte/icons/x';
import PlusIcon from '@lucide/svelte/icons/plus';
import HistoryIcon from '@lucide/svelte/icons/history';
import ChatHistoryPopover from '$features/ai-assistant/chat-history-popover.svelte';
import { closeAiAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
interface Conversation {
id: string;
title: string | null;
updatedAt: string;
}
interface TokenUsage {
used: number;
allowance: number;
remaining: number;
}
interface Props {
tokenUsage: TokenUsage | null;
conversations: Conversation[];
activeConversationId: string | null;
conversationTitle: string | null;
isNewChatDisabled: boolean;
onNewChat: () => void;
onLoadConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
}
let {
tokenUsage,
conversations,
activeConversationId,
conversationTitle,
isNewChatDisabled,
onNewChat,
onLoadConversation,
onDeleteConversation
}: Props = $props();
let historyPopoverOpen = $state(false);
function handleLoadConversation(id: string) {
onLoadConversation(id);
historyPopoverOpen = false;
}
</script>
<div class="border-b px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex min-w-0 items-center gap-2">
<SparklesIcon size={18} class="ui:text-primary shrink-0" />
<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}
</div>
</div>
<div class="flex items-center gap-2">
<IconButton
onclick={onNewChat}
disabled={isNewChatDisabled}
variant="outline"
size="icon-xs"
tooltip={$t('ai_assistant.new_chat')}
>
<PlusIcon size={16} />
</IconButton>
<Popover.Root bind:open={historyPopoverOpen}>
<Popover.Trigger>
<IconButton variant="outline" size="icon-xs" tooltip={$t('ai_assistant.chat_history')}>
<HistoryIcon size={16} />
</IconButton>
</Popover.Trigger>
<Popover.Content class="ui:p-0! w-72" align="end">
<ChatHistoryPopover
{conversations}
{activeConversationId}
onLoad={handleLoadConversation}
onDelete={onDeleteConversation}
/>
</Popover.Content>
</Popover.Root>
<IconButton onclick={closeAiAssistant} variant="outline" size="icon-xs">
<XIcon size={16} />
</IconButton>
</div>
</div>
{#if tokenUsage && tokenUsage.allowance > 0}
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))}
<div class="mt-2">
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
<span
>{tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()}
{$t('ai_assistant.tokens_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>
{/if}
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import MessageSquareIcon from '@lucide/svelte/icons/message-square';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import { t } from '$lib/utils/functions/translations';
interface Conversation {
id: string;
title: string | null;
updatedAt: string;
}
interface Props {
conversations: Conversation[];
activeConversationId: string | null;
onLoad: (id: string) => void;
onDelete: (id: string) => void;
}
let { conversations, activeConversationId, onLoad, onDelete }: Props = $props();
</script>
<div class="border-b px-3 py-2">
<p class="text-xs font-medium">{$t('ai_assistant.chat_history')}</p>
</div>
<div class="max-h-64 overflow-y-auto">
{#if conversations.length === 0}
<p class="ui:text-muted-foreground px-3 py-4 text-center text-xs">{$t('ai_assistant.no_conversations')}</p>
{:else}
{#each conversations as conversation (conversation.id)}
<div
class="group flex items-center gap-2 border-b px-3 py-2 last:border-b-0 {conversation.id ===
activeConversationId
? 'ui:bg-muted'
: 'hover:ui:bg-muted/50'} transition-colors"
>
<button onclick={() => onLoad(conversation.id)} class="flex min-w-0 flex-1 items-center gap-2 text-left">
<MessageSquareIcon size={14} class="ui:text-muted-foreground flex-shrink-0" />
<div class="min-w-0">
<p class="truncate text-xs font-medium">{conversation.title || $t('ai_assistant.untitled')}</p>
<p class="ui:text-muted-foreground text-[10px]">
{new Date(conversation.updatedAt).toLocaleDateString()}
</p>
</div>
</button>
<button
onclick={() => onDelete(conversation.id)}
class="ui:text-muted-foreground flex-shrink-0 rounded p-1 opacity-0 transition-all group-hover:opacity-100 hover:text-red-500"
>
<TrashIcon size={12} />
</button>
</div>
{/each}
{/if}
</div>
@@ -0,0 +1,190 @@
<script lang="ts">
import { Button } from '@cio/ui/base/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';
import SquareIcon from '@lucide/svelte/icons/square';
import XIcon from '@lucide/svelte/icons/x';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import LoaderIcon from '@lucide/svelte/icons/loader';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import ModelPicker from '$features/ai-assistant/model-picker.svelte';
import type { AgentModelId } from '@cio/utils/agent-models';
interface UploadedDocument {
id: string;
name: string;
}
interface Props {
inputValue: string;
isStreaming: boolean;
isExhausted: boolean;
isUploading: boolean;
error: Error | null | undefined;
mentionItems: MentionItem[];
uploadedDocument: UploadedDocument | null;
selectedModel: AgentModelId;
onSend: () => void;
onStop: () => void;
onFileSelect: (file: File) => void;
onRemoveDocument: () => void;
onSelectModel: (id: AgentModelId) => void;
}
let {
inputValue = $bindable(),
isStreaming,
isExhausted,
isUploading,
error,
mentionItems,
uploadedDocument,
selectedModel,
onSend,
onStop,
onFileSelect,
onRemoveDocument,
onSelectModel
}: Props = $props();
let fileInputEl: HTMLInputElement | undefined = $state();
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
let wasBusy = $state(false);
$effect(() => {
const isBusy = isStreaming || isUploading;
if (isBusy) {
wasBusy = true;
return;
}
if (!wasBusy || isExhausted || !chatTextareaRef) {
return;
}
wasBusy = false;
requestAnimationFrame(() => {
chatTextareaRef?.focus();
});
});
function handlePaperclipClick() {
if ($isFreePlan) {
openUpgradeModal();
return;
}
fileInputEl?.click();
}
function handleFileChange(e: Event) {
const target = e.currentTarget as HTMLInputElement;
const file = target.files?.[0];
if (file) {
onFileSelect(file);
target.value = '';
}
}
function getTypeLabel(item: MentionItem) {
if (item.type === 'EXERCISE') return t.get('ai_assistant.mention_exercise');
if (item.type === 'SECTION') return t.get('ai_assistant.mention_section');
return t.get('ai_assistant.mention_lesson');
}
</script>
<input
bind:this={fileInputEl}
type="file"
accept=".pdf,.docx,.pptx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation"
class="hidden"
onchange={handleFileChange}
/>
<div class="border-t px-3 pt-3 pb-1.5">
{#if isExhausted}
<div
class="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"
>
{$t('ai_assistant.tokens_exhausted')}
</div>
{:else}
{#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')}
>
<PaperclipIcon size={16} />
</button>
<div class="flex-1"></div>
{#if isStreaming}
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
<SquareIcon size={12} />
</Button>
{:else}
<ModelPicker value={selectedModel} disabled={isStreaming} onChange={onSelectModel} />
{/if}
{/snippet}
</ChatTextarea>
</div>
{/if}
</div>
@@ -0,0 +1,143 @@
<script lang="ts">
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import LoaderIcon from '@lucide/svelte/icons/loader';
import { Button } from '@cio/ui/base/button';
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';
interface PlanExecutionState {
title: string;
steps: ProgressStep[];
currentAction?: string;
isStopped: boolean;
}
interface Props {
messages: AiAssistantMessage[];
status: string;
isStreaming: boolean;
courseId: string;
planExecutionState: PlanExecutionState | null;
quickActions: string[];
onQuickAction: (action: string) => void;
onImplementPlan: (editedPlan: unknown) => void;
onAskPlanChanges: () => void;
onStop: () => void;
onResume: () => void;
onMentionClick: (route: string) => void;
}
let {
messages,
status,
isStreaming,
courseId,
planExecutionState,
quickActions,
onQuickAction,
onImplementPlan,
onAskPlanChanges,
onStop,
onResume,
onMentionClick
}: Props = $props();
let messagesContainer: HTMLDivElement | undefined = $state();
let lastMessageCount = $state(0);
let lastStepsCount = $state(0);
let lastCurrentAction = $state('');
const isEmpty = $derived(messages.length === 0);
function scrollToBottom() {
requestAnimationFrame(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
});
}
$effect(() => {
if (!messagesContainer || messages.length === 0) return;
if (messages.length === lastMessageCount) return;
lastMessageCount = messages.length;
scrollToBottom();
});
$effect(() => {
if (!messagesContainer || !planExecutionState) return;
const stepsCount = planExecutionState.steps.length;
if (stepsCount !== lastStepsCount) {
lastStepsCount = stepsCount;
scrollToBottom();
return;
}
const action = planExecutionState.currentAction ?? '';
if (action !== lastCurrentAction) {
lastCurrentAction = action;
scrollToBottom();
}
});
</script>
<div class="flex-1 overflow-y-auto p-4" bind:this={messagesContainer}>
{#if isEmpty}
<!-- Empty state with quick actions -->
<div class="flex h-full flex-col items-center justify-center gap-4">
<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')}
</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
{#each quickActions as action (action)}
<button
onclick={() => onQuickAction(action)}
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
>
{action}
</button>
{/each}
</div>
</div>
{:else}
<!-- Message list -->
<div class="flex flex-col gap-3">
{#each messages as message (message.id)}
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
{/each}
{#if status === 'submitted' && isStreaming}
<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" />
</div>
</div>
{/if}
{#if planExecutionState}
<div class="mt-2">
<ProgressCard
title={planExecutionState.title}
steps={planExecutionState.steps}
currentAction={planExecutionState.currentAction}
onStop={isStreaming ? onStop : undefined}
isStopped={planExecutionState.isStopped}
/>
{#if planExecutionState.isStopped}
<Button size="sm" variant="outline" onclick={onResume} class="mt-2 w-full"
>{$t('ai_assistant.resume')}</Button
>
{/if}
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,4 @@
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';
@@ -0,0 +1,53 @@
<script lang="ts">
import { MentionPopover, type MentionItem } from '@cio/ui/custom/mention-popover';
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import { t } from '$lib/utils/functions/translations';
interface ContentMentionItem {
id: string;
title: string;
type: string;
}
interface Props {
items: ContentMentionItem[];
query: string;
selectedIndex: number;
onSelect: (item: ContentMentionItem) => void;
}
let { items, query, selectedIndex, onSelect }: Props = $props();
const mentionItems: MentionItem[] = $derived(
items.map((item) => ({ id: item.id, label: item.title, type: item.type }))
);
function handleSelect(mentionItem: MentionItem) {
const original = items.find((item) => item.id === mentionItem.id);
if (original) {
onSelect(original);
}
}
function getTypeLabel(item: MentionItem) {
return item.type === 'EXERCISE' ? t.get('ai_assistant.mention_exercise') : t.get('ai_assistant.mention_lesson');
}
</script>
<MentionPopover
items={mentionItems}
{query}
{selectedIndex}
onSelect={handleSelect}
typeLabel={getTypeLabel}
emptyMessage={t.get('ai_assistant.mention_no_results')}
>
{#snippet icon({ item })}
{#if item.type === 'EXERCISE'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
</MentionPopover>
@@ -0,0 +1,205 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import CircleIcon from '@lucide/svelte/icons/circle';
import LoaderIcon from '@lucide/svelte/icons/loader';
import CopyIcon from '@lucide/svelte/icons/copy';
import FileTextIcon from '@lucide/svelte/icons/file-text';
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 {
getAgentToolName,
getAgentToolResult,
getAgentToolStatus,
isAgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
interface Props {
message: AiAssistantMessage;
courseId: string;
onImplementPlan: (editedPlan: unknown) => void;
onAskPlanChanges: () => void;
onMentionClick: (route: string) => void;
}
let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props();
function handleBubbleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const mentionLink = target.closest('[data-mention-route]') as HTMLElement | null;
if (mentionLink) {
event.preventDefault();
const route = mentionLink.dataset.mentionRoute;
if (route) {
onMentionClick(route);
}
}
}
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
function isDeferredPlanPart(part: Record<string, unknown>) {
if (!isAgentToolPart(part)) {
return false;
}
return getAgentToolName(part) === 'generate_course_plan';
}
function truncateErrorText(errorText: string): string {
// Show just the summary before the raw Value JSON dump
const valueIndex = errorText.indexOf(' Value: ');
if (valueIndex !== -1) {
return errorText.slice(0, valueIndex);
}
return errorText.length > 120 ? errorText.slice(0, 120) + '…' : errorText;
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function getPartErrorText(part: unknown): string | undefined {
return (part as { errorText?: string }).errorText;
}
const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record<string, unknown>)));
const deferredPlanParts = $derived(
message.parts.filter((part) => isDeferredPlanPart(part as Record<string, unknown>))
);
</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'}
<div
class="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>
{:else if isAgentToolPart(part)}
{@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} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
{:else}
<div class="ui:bg-background/70 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}
</div>
{/if}
{/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'}
<div class="mt-3">
<PlanView plan={toolResult} 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}
</div>
{/if}
{/each}
</div>
</div>
<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;
}
:global([data-role='user'] a) {
color: var(--primary-foreground);
}
:global(.mention-link:hover) {
opacity: 0.8;
}
</style>
@@ -0,0 +1,49 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import CheckIcon from '@lucide/svelte/icons/check';
import { AGENT_MODELS, UI_PICKER_MODEL_IDS, type AgentModelId } from '@cio/utils/agent-models';
interface Props {
value: AgentModelId;
disabled?: boolean;
onChange: (id: AgentModelId) => void;
}
let { value, disabled = false, onChange }: Props = $props();
let open = $state(false);
function handleSelect(id: AgentModelId) {
onChange(id);
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
{disabled}
class="ui:text-muted-foreground hover:ui:bg-muted ui:hover:text-foreground flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<span class="truncate">{AGENT_MODELS[value].label}</span>
<ChevronDownIcon size={12} />
</Popover.Trigger>
<Popover.Content class="ui:p-0! ui:z-[110] w-48" align="end" side="top">
<div class="py-1">
{#each UI_PICKER_MODEL_IDS as id (id)}
{@const isSelected = id === value}
<button
type="button"
onclick={() => handleSelect(id)}
class="hover:ui:bg-muted flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors"
>
<span class="min-w-0 truncate">{AGENT_MODELS[id].label}</span>
{#if isSelected}
<CheckIcon size={14} class="ui:text-primary shrink-0" />
{/if}
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,263 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { Button } from '@cio/ui/base/button';
import { SvelteSet } from 'svelte/reactivity';
/** Token cost estimation heuristics (tokens per item) */
const TOKEN_COST_ESTIMATES = {
SECTION_CREATE: 500,
LESSON_CREATE_AND_CONTENT: 3000,
EXERCISE_CREATE: 1500
} as const;
interface PlanItem {
type: 'lesson' | 'exercise';
title: string;
description: string;
order: number;
hasExercise: boolean;
}
interface PlanSection {
title: string;
order: number;
items: PlanItem[];
}
interface CoursePlan {
title: string;
sections: PlanSection[];
}
interface Props {
plan: CoursePlan;
onImplement: (editedPlan: CoursePlan) => void;
onAskChanges: () => void;
remainingTokens?: number;
}
let { plan, onImplement, onAskChanges, remainingTokens }: Props = $props();
// CoursePlan is plain JSON; structuredClone fails when the AI SDK part carries
// non-cloneable internals, so JSON round-trip is both safer and sufficient.
function clonePlan(input: CoursePlan): CoursePlan {
return JSON.parse(JSON.stringify(input)) as CoursePlan;
}
// Local editable copy of the plan
let editablePlan: CoursePlan = $state(clonePlan(plan));
// Reset editable plan when the prop changes (e.g., agent revises the plan)
$effect(() => {
editablePlan = clonePlan(plan);
});
let expandedSections = new SvelteSet<number>();
$effect(() => {
expandedSections.clear();
for (let i = 0; i < editablePlan.sections.length; i++) {
expandedSections.add(i);
}
});
// Track which field is being edited: "section-0-title", "lesson-1-2-title", "lesson-1-2-desc"
let editingField: string | null = $state(null);
function toggleSection(index: number) {
if (expandedSections.has(index)) {
expandedSections.delete(index);
} else {
expandedSections.add(index);
}
}
function startEditing(fieldKey: string) {
editingField = fieldKey;
}
function stopEditing() {
editingField = null;
}
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
stopEditing();
}
if (event.key === 'Escape') {
stopEditing();
}
}
const totalLessons = $derived(
editablePlan.sections.reduce((sum, s) => sum + s.items.filter((i) => i.type === 'lesson').length, 0)
);
const totalExercises = $derived(
editablePlan.sections.reduce(
(sum, s) => sum + s.items.filter((i) => i.type === 'exercise' || i.hasExercise).length,
0
)
);
const estimatedTokens = $derived(
editablePlan.sections.length * TOKEN_COST_ESTIMATES.SECTION_CREATE +
totalLessons * TOKEN_COST_ESTIMATES.LESSON_CREATE_AND_CONTENT +
totalExercises * TOKEN_COST_ESTIMATES.EXERCISE_CREATE
);
const costPercentage = $derived(remainingTokens ? Math.round((estimatedTokens / remainingTokens) * 100) : null);
const isHighCost = $derived(costPercentage !== null && costPercentage > 50);
</script>
<div class="ui:bg-background rounded-lg border">
<!-- Plan header -->
<div class="border-b px-3 py-2">
{#if editingField === 'plan-title'}
<input
type="text"
bind:value={editablePlan.title}
onblur={stopEditing}
onkeydown={handleEditKeydown}
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-sm font-semibold focus:ring-1 focus:outline-none"
autofocus
/>
{:else}
<button
onclick={() => startEditing('plan-title')}
class="group hover:ui:text-primary flex items-center gap-1 text-left text-sm font-semibold"
>
{editablePlan.title}
<PencilIcon size={10} class="opacity-0 group-hover:opacity-50" />
</button>
{/if}
<p class="ui:text-muted-foreground text-xs">
{editablePlan.sections.length} sections, {totalLessons} lessons{totalExercises > 0
? `, ${totalExercises} exercises`
: ''}
</p>
</div>
<!-- Sections tree -->
<div class="max-h-[300px] overflow-y-auto p-2">
{#each editablePlan.sections as section, sectionIndex (sectionIndex)}
<div class="mb-1">
<div class="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium">
<button onclick={() => toggleSection(sectionIndex)} class="flex-shrink-0">
{#if expandedSections.has(sectionIndex)}
<ChevronDownIcon size={12} />
{:else}
<ChevronRightIcon size={12} />
{/if}
</button>
{#if editingField === `section-${sectionIndex}-title`}
<input
type="text"
bind:value={editablePlan.sections[sectionIndex].title}
onblur={stopEditing}
onkeydown={handleEditKeydown}
class="ui:bg-muted/50 focus:ui:ring-primary flex-1 rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
autofocus
/>
{:else}
<button
onclick={() => startEditing(`section-${sectionIndex}-title`)}
class="group hover:ui:text-primary flex items-center gap-1 text-left"
>
Section {section.order}: {section.title}
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
</button>
{/if}
</div>
{#if expandedSections.has(sectionIndex)}
<div class="ml-5 space-y-1">
{#each section.items as item, itemIndex (item.order)}
<div class="flex items-start gap-1.5 rounded px-2 py-1 text-xs">
{#if item.type === 'exercise'}
<FileQuestionIcon size={11} class="ui:text-primary mt-0.5 flex-shrink-0" />
{:else}
<BookOpenIcon size={11} class="ui:text-muted-foreground mt-0.5 flex-shrink-0" />
{/if}
<div class="min-w-0 flex-1">
{#if editingField === `item-${sectionIndex}-${itemIndex}-title`}
<input
type="text"
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].title}
onblur={stopEditing}
onkeydown={handleEditKeydown}
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-xs font-medium focus:ring-1 focus:outline-none"
autofocus
/>
{:else}
<button
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-title`)}
class="group hover:ui:text-primary flex items-center gap-1 text-left font-medium"
>
{#if item.type === 'exercise'}
<span class="ui:text-primary">{item.title}</span>
{:else}
{item.title}
{/if}
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
</button>
{/if}
{#if editingField === `item-${sectionIndex}-${itemIndex}-desc`}
<textarea
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].description}
onblur={stopEditing}
onkeydown={handleEditKeydown}
rows={2}
class="ui:bg-muted/50 ui:text-muted-foreground focus:ui:ring-primary mt-0.5 w-full rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
autofocus
></textarea>
{:else}
<button
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-desc`)}
class="group ui:text-muted-foreground hover:ui:text-foreground mt-0.5 flex items-start gap-1 text-left"
>
<span class="line-clamp-2">{item.description}</span>
<PencilIcon size={9} class="mt-0.5 flex-shrink-0 opacity-0 group-hover:opacity-50" />
</button>
{/if}
{#if item.type === 'lesson' && item.hasExercise}
<div class="ui:text-muted-foreground mt-0.5 flex items-center gap-1">
<FileQuestionIcon size={10} />
<span>Exercise included</span>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Cost estimate + actions -->
<div class="space-y-2 border-t px-3 py-2">
{#if estimatedTokens > 0}
<p class="text-xs {isHighCost ? 'text-amber-600 dark:text-amber-400' : 'ui:text-muted-foreground'}">
Estimated cost: ~{estimatedTokens.toLocaleString()} tokens
{#if costPercentage !== null}
({costPercentage}% of remaining)
{/if}
</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>
</div>
</div>
@@ -0,0 +1,78 @@
<script lang="ts">
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 { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface ProgressStep {
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
indent?: boolean;
}
interface Props {
title: string;
steps: ProgressStep[];
currentAction?: string;
onStop?: () => void;
isStopped?: boolean;
error?: string;
}
let { title, steps, currentAction, onStop, isStopped = false, error }: Props = $props();
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
const totalCount = $derived(steps.length);
</script>
<div class="ui:bg-background rounded-lg border">
<div class="border-b px-3 py-2">
<h4 class="text-sm font-semibold">{title}</h4>
<p class="ui:text-muted-foreground text-xs">
{completedCount} / {totalCount} steps completed
</p>
</div>
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
{#each steps as step, i (`${i}-${step.label}`)}
<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" />
{:else if step.status === 'in_progress'}
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
{:else if step.status === 'failed'}
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
{:else}
<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}
</span>
</div>
{/each}
</div>
<div class="space-y-1 border-t px-3 py-2">
{#if error}
<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}
{#if isStopped}
<p class="ui:text-muted-foreground text-xs">{$t('ai_assistant.stopped_content_kept')}</p>
{/if}
{#if onStop && !isStopped && completedCount < totalCount}
<Button size="sm" variant="outline" onclick={onStop} class="w-full">
<SquareIcon size={12} class="mr-1" />
Stop
</Button>
{/if}
</div>
</div>
@@ -0,0 +1,27 @@
<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';
interface Props {
toolName: string;
state: string;
result?: unknown;
}
let { toolName, state, result }: 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>
{:else if state === 'result'}
<CheckIcon size={12} class="ui:text-primary" />
<span>
{getToolResultLabel(toolName, result)}
</span>
{/if}
</div>
@@ -0,0 +1,5 @@
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';
@@ -0,0 +1,17 @@
import { marked } from 'marked';
// Configure marked for safe, minimal output
marked.setOptions({
breaks: true,
gfm: true
});
/**
* Convert markdown text to sanitized HTML for rendering in the chat.
* Agent responses come as markdown — this makes them render properly.
*/
export function renderMarkdown(text: string): string {
if (!text) return '';
return marked.parse(text, { async: false }) as string;
}
@@ -0,0 +1,65 @@
type MentionType = 'lesson' | 'exercise' | 'section';
/**
* Mention format: @[Title](type:id)
* Where type is "lesson", "exercise", or "section" and id is the content item ID.
*/
const RAW_MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise|section):([a-zA-Z0-9_-]+)\)/gi;
const HTML_LINK_MENTION_REGEX = /@?<a\b[^>]*href=(["'])(lesson|exercise|section):([a-zA-Z0-9_-]+)\1[^>]*>(.*?)<\/a>/gi;
function normalizeMentionType(type: string): MentionType | null {
const normalizedType = type.toLowerCase();
if (normalizedType === 'lesson' || normalizedType === 'exercise' || normalizedType === 'section') {
return normalizedType;
}
return null;
}
function getMentionRoute(courseId: string, type: MentionType, id: string) {
if (type === 'exercise') return `/courses/${courseId}/exercises/${id}`;
if (type === 'section') return `/courses/${courseId}/lessons#section-${id}`;
return `/courses/${courseId}/lessons/${id}`;
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function buildMentionLink(route: string, label: string, title: string) {
return `<a href="${route}" data-mention-route="${route}" class="mention-link" title="${escapeHtml(title)}">${label}</a>`;
}
/**
* Replace AI lesson/exercise mentions with real 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(
HTML_LINK_MENTION_REGEX,
(match, _quote: string, type: string, id: string, label: string) => {
const mentionType = normalizeMentionType(type);
if (!mentionType) return match;
const route = getMentionRoute(courseId, mentionType, id);
return buildMentionLink(route, label, `${mentionType}: ${id}`);
}
);
return linksWithRoutes.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
const mentionType = normalizeMentionType(type);
if (!mentionType) return match;
const route = getMentionRoute(courseId, mentionType, id);
return buildMentionLink(route, escapeHtml(title), `${mentionType}: ${title}`);
});
}
@@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
/** Controls whether the AI assistant right sidebar Sheet is open. */
export const isAiAssistantOpen = writable(false);
export function openAiAssistant() {
isAiAssistantOpen.set(true);
}
export function closeAiAssistant() {
isAiAssistantOpen.set(false);
}
export function toggleAiAssistant() {
isAiAssistantOpen.update((open) => !open);
}
@@ -0,0 +1,93 @@
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',
update_exercise: 'Updating exercise',
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 function getToolLabel(toolName: string): string {
return TOOL_LABELS[toolName] || toolName;
}
export function getToolResultLabel(toolName: string, result: unknown): string {
const r = result as Record<string, unknown>;
switch (toolName) {
case 'create_section':
return `Created section: ${r.title}`;
case 'update_section':
return `Updated section: ${r.title}`;
case 'create_lesson':
return `Created lesson: ${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 '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 blockerCount === 0
? `Ready to go live${warningCount > 0 ? ` with ${warningCount} warnings` : ''}`
: `${blockerCount} blockers before go-live`;
}
case 'go_live_course':
return r.isPublished ? `Published course: ${r.title}` : 'Course was not published';
case 'generate_course_plan':
return 'Course plan generated';
default:
return 'Done';
}
}
export const MUTATION_TOOLS = [
'create_section',
'update_section',
'create_lesson',
'update_lesson',
'update_lesson_content',
'create_exercise',
'update_exercise',
'add_questions',
'update_questions',
'reorder_content',
'update_course_landing_page',
'go_live_course'
];
export interface ProgressStep {
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
indent?: boolean;
}
@@ -0,0 +1,62 @@
import type { ProgressStep } from './tool-labels';
export interface AgentToolPart {
type: string;
state?: string;
input?: unknown;
output?: unknown;
errorText?: string;
toolInvocation?: {
toolName?: string;
result?: unknown;
};
}
export function isAgentToolPart(part: Record<string, unknown>): part is AgentToolPart {
if (part.type === 'tool-invocation') {
return true;
}
return typeof part.type === 'string' && part.type.startsWith('tool-');
}
export function getAgentToolName(part: AgentToolPart): string | null {
if (part.type === 'tool-invocation') {
return part.toolInvocation?.toolName ?? null;
}
if (!part.type.startsWith('tool-')) {
return null;
}
return part.type.slice(5);
}
export function getAgentToolResult(part: AgentToolPart): unknown {
if (part.type === 'tool-invocation') {
return part.toolInvocation?.result;
}
return part.output;
}
export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] {
if (part.state === 'result' || part.state === 'output-available') {
return 'completed';
}
if (part.state === 'output-error') {
return 'failed';
}
if (
part.state === 'call' ||
part.state === 'partial-call' ||
part.state === 'input-streaming' ||
part.state === 'input-available'
) {
return 'in_progress';
}
return 'pending';
}
@@ -0,0 +1,49 @@
import type { UIMessage } from 'ai';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export interface UploadedDocument {
id: string;
name: string;
}
export interface AiAssistantMessageAttachment {
documentId: string;
name: string;
}
export interface AiAssistantMessageMetadata {
attachment?: AiAssistantMessageAttachment;
}
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
export type AgentStatusRequest = typeof classroomio.agent.status.$get;
export type AgentStatusSuccess = Extract<InferResponseType<AgentStatusRequest>, { success: true }>;
export type AgentStatusData = AgentStatusSuccess['data'];
export type AgentUsageRequest = typeof classroomio.agent.usage.$get;
export type AgentUsageSuccess = Extract<InferResponseType<AgentUsageRequest>, { success: true }>;
export type AgentUsageData = AgentUsageSuccess['data'];
export type AgentHistoryGetRequest = typeof classroomio.agent.history.$get;
export type AgentHistoryGetSuccess = Extract<InferResponseType<AgentHistoryGetRequest>, { success: true }>;
export type AgentHistoryData = AgentHistoryGetSuccess['data'];
export type AgentConversationSummary = AgentHistoryData[number];
export type AgentConversationRequest = (typeof classroomio.agent.history)[':conversationId']['$get'];
export type AgentConversationSuccess = Extract<InferResponseType<AgentConversationRequest>, { success: true }>;
export type AgentConversation = Omit<AgentConversationSuccess['data'], 'messages'> & {
messages: AiAssistantMessage[];
};
export type AgentConversationCreateRequest = typeof classroomio.agent.history.$post;
export type AgentConversationCreateSuccess = Extract<
InferResponseType<AgentConversationCreateRequest>,
{ success: true }
>;
export type AgentConversationCreateData = AgentConversationCreateSuccess['data'];
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];