chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } 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';
@@ -13,7 +13,13 @@
isAgentToolPart,
type AgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import { isAiAssistantOpen } from '$features/ai-assistant/utils/store';
import {
isAiAssistantOpen,
initialChatModel,
initialChatPrompt,
clearInitialChatModel,
clearInitialChatPrompt
} from '$features/ai-assistant/utils/store';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Chat } from '@ai-sdk/svelte';
@@ -23,9 +29,15 @@
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 { 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 type {
AiAssistantMessage,
AiAssistantMessageMetadata,
UploadedDocument
} from '$features/ai-assistant/utils/types';
import {
AI_CHAT_MODEL_STORAGE_KEY,
AI_COURSE_CHAT_DEFAULT_WIDTH,
@@ -33,7 +45,22 @@
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';
import {
AGENT_MODELS,
AGENT_MODEL_IDS,
DEFAULT_PICKER_MODEL_ID,
UI_PICKER_MODEL_IDS,
type AgentModelId
} from '@cio/utils/agent-models';
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();
// Extract current lessonId/exerciseId from route params
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
@@ -43,8 +70,8 @@
let uploadedDocument: UploadedDocument | null = $state(null);
let isUploading = $state(false);
let usageFetched = $state(false);
let conversationsLoaded = $state(false);
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);
@@ -52,12 +79,18 @@
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);
function isAgentModelId(value: unknown): value is AgentModelId {
return typeof value === 'string' && (AGENT_MODEL_IDS as readonly string[]).includes(value);
}
function handleSelectModel(id: AgentModelId) {
if ($isFreePlan && paidModelIds.includes(id)) {
openUpgradeModal();
return;
}
selectedModel = id;
try {
@@ -67,8 +100,26 @@
}
}
function handleLockedModelSelect() {
openUpgradeModal();
}
const tokenUsage = $derived(aiAssistantApi.status?.usage ?? null);
$effect(() => {
if (!$isFreePlan || !paidModelIds.includes(selectedModel)) {
return;
}
selectedModel = DEFAULT_PICKER_MODEL_ID;
try {
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
} catch {
// localStorage unavailable
}
});
function clampChatWidth(width: number) {
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
}
@@ -105,8 +156,6 @@
}
async function loadConversation(conversationId: string) {
const courseId = courseApi.course?.id;
if (!courseId) return;
await aiAssistantApi.loadConversation(conversationId);
@@ -114,14 +163,19 @@
const conversation = aiAssistantApi.currentConversation;
if (conversation) {
chat.messages = (conversation.messages ?? []) as AiAssistantMessage[];
const loadedMessages = (conversation.messages ?? []) as AiAssistantMessage[];
// Only overwrite in-memory messages if the loaded conversation actually has saved
// messages. An empty result means the conversation was just created and the first
// message hasn't been persisted yet — overwriting would wipe the optimistic message
// that chat.sendMessage already placed in the UI.
if (loadedMessages.length > 0) {
chat.messages = loadedMessages;
}
setActiveConversationId(courseId, conversationId);
}
}
async function startNewChat() {
const courseId = courseApi.course?.id;
if (!courseId) return;
const created = await aiAssistantApi.createConversation(courseId);
@@ -133,8 +187,6 @@
}
async function handleDeleteConversation(conversationId: string) {
const courseId = courseApi.course?.id;
if (!courseId) return;
await aiAssistantApi.deleteConversation(conversationId);
@@ -145,21 +197,35 @@
}
}
// Fetch status and load conversations when panel opens
// Fetch status and load conversations when the panel opens or route `courseId` changes
$effect(() => {
const courseId = courseApi.course?.id;
if (!$isAiAssistantOpen) {
statusFetchedForCourseId = null;
conversationsLoadedForCourseId = null;
if ($isAiAssistantOpen && !usageFetched && courseId) {
usageFetched = true;
return;
}
if (!courseId) return;
if (statusFetchedForCourseId !== courseId) {
statusFetchedForCourseId = courseId;
aiAssistantApi.fetchStatus(courseId);
}
if ($isAiAssistantOpen && !conversationsLoaded && courseId) {
conversationsLoaded = true;
if (conversationsLoadedForCourseId !== courseId) {
conversationsLoadedForCourseId = courseId;
const savedId = getActiveConversationId(courseId);
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;
aiAssistantApi.listConversations(courseId).then(() => {
if (savedId) {
loadConversation(savedId);
} else if (aiAssistantApi.conversations.length > 0) {
@@ -167,20 +233,78 @@
}
});
}
if (!$isAiAssistantOpen) {
usageFetched = false;
conversationsLoaded = false;
}
});
function refreshCourseStateAfterChat() {
const profileId = $profile.id;
// Force refetch course data so new sections/lessons/exercises show in the UI
if (courseId && profileId) {
void courseApi.refreshCourse(courseId, profileId);
}
// Refresh current lesson content if viewing a lesson
if (courseId && currentLessonId) {
void lessonApi.get(courseId, currentLessonId);
}
// Refresh current exercise if viewing an exercise
if (courseId && currentExerciseId) {
void refreshExercisePageData(courseId, currentExerciseId);
}
// Refresh usage meter
if (courseId) {
void aiAssistantApi.fetchStatus(courseId);
}
}
async function persistFinishedChat(messages: AiAssistantMessage[], conversationId: string | null) {
if (conversationId) {
await aiAssistantApi.saveMessages(conversationId, messages);
// Generate a smart title after the first exchange (when title is still default)
const activeConv = aiAssistantApi.conversations.find((c) => c.id === conversationId);
const isDefaultTitle = !activeConv?.title || activeConv.title === 'New conversation';
const firstUserMsg = messages.find((message) => message.role === 'user');
const textPart = firstUserMsg?.parts?.find(
(part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text'
);
if (isDefaultTitle && textPart?.text) {
await aiAssistantApi.generateTitle(conversationId, textPart.text.slice(0, 500));
}
return;
}
if (!courseId) return;
// No active conversation yet — create one and save
const created = await aiAssistantApi.createConversation(courseId);
if (!created) return;
setActiveConversationId(courseId, created.id);
await aiAssistantApi.saveMessages(created.id, messages);
const firstUserMsg = messages.find((message) => message.role === 'user');
const textPart = firstUserMsg?.parts?.find(
(part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text'
);
if (textPart?.text) {
await aiAssistantApi.generateTitle(created.id, textPart.text.slice(0, 500));
}
}
const chat = new Chat({
id: 'ai-assistant',
transport: new DefaultChatTransport({
api: `${getRequestBaseUrl()}/agent/chat`,
credentials: 'include',
body: () => ({
courseId: courseApi.course?.id ?? '',
courseId,
model: selectedModel,
context: {
lessonId: page.params?.lessonId,
@@ -190,64 +314,12 @@
}),
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);
}
onFinish: () => {
// 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));
}
}
}
refreshCourseStateAfterChat();
void persistFinishedChat(chat.messages as AiAssistantMessage[], activeConversationId);
}
});
@@ -255,8 +327,8 @@
'Upload a document to create a course',
'Draft a lesson',
'Generate questions from this lesson',
"Improve this lesson's text",
'Summarize this lesson'
'Summarize this lesson',
'Help me publish this course'
];
function handleSend() {
@@ -274,8 +346,6 @@
// Create conversation eagerly before sending the first message
if (!activeConversationId) {
const courseId = courseApi.course?.id;
if (!courseId) return;
aiAssistantApi.createConversation(courseId).then((created) => {
@@ -304,8 +374,6 @@
}
async function handleFileSelect(file: File) {
const courseId = courseApi.course?.id;
if (!courseId) return;
const conversationId = await ensureActiveConversation(courseId);
@@ -339,8 +407,6 @@
inputValue = '';
const courseId = courseApi.course?.id;
if (!activeConversationId) {
if (!courseId) return;
@@ -367,7 +433,7 @@
}
function handleResume() {
inputValue = 'Continue implementing the plan from where you left off.';
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
handleSend();
}
@@ -393,6 +459,8 @@
if (!lastAssistantMsg) return null;
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
const reachedStepLimit = continuation?.reason === 'step_limit';
const allToolParts = lastAssistantMsg.parts.filter((part: Record<string, unknown>) =>
isAgentToolPart(part)
) as AgentToolPart[];
@@ -417,10 +485,10 @@
});
const allDone = steps.every((s) => s.status === 'completed');
const isStopped = !isStreaming && !allDone;
const isStopped = !isStreaming && (!allDone || reachedStepLimit);
// Hide the card once the agent finishes cleanly — the text response takes over
if (allDone && !isStreaming) return null;
if (allDone && !isStreaming && !reachedStepLimit) return null;
const hasMutations = toolParts.some((part) => {
const toolName = getAgentToolName(part);
@@ -496,6 +564,30 @@
clearChatResizeListeners();
});
$effect(() => {
const prompt = $initialChatPrompt;
if (!prompt || !$isAiAssistantOpen || !courseId) return;
const model = $initialChatModel;
clearInitialChatPrompt();
clearInitialChatModel();
tick().then(() => {
if (model && isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
return;
}
handleSelectModel(model);
}
inputValue = prompt;
handleSend();
});
});
$effect(() => {
if (!hasLoadedChatWidth) {
return;
@@ -525,7 +617,7 @@
messages={chat.messages}
status={chat.status}
{isStreaming}
courseId={courseApi.course?.id ?? ''}
{courseId}
{planExecutionState}
{quickActions}
onQuickAction={handleQuickAction}
@@ -544,12 +636,14 @@
{uploadedDocument}
{mentionItems}
{selectedModel}
lockedModelIds={$isFreePlan ? paidModelIds : []}
error={chat.error}
onSend={handleSend}
onStop={handleStop}
onFileSelect={handleFileSelect}
onRemoveDocument={handleRemoveDocument}
onSelectModel={handleSelectModel}
onLockedModelSelect={handleLockedModelSelect}
/>
{/snippet}
@@ -105,6 +105,23 @@ class AiAssistantApi extends BaseApiWithErrors {
});
}
async generateCourseMeta(prompt: string): Promise<{ title: string; description: string } | null> {
let meta: { title: string; description: string } | null = null;
await this.execute<(typeof classroomio.agent)['generate-course-title']['$post']>({
requestFn: () =>
classroomio.agent['generate-course-title'].$post({
json: { prompt }
}),
logContext: 'generating course meta',
onSuccess: (result) => {
meta = (result as { data: { title: string; description: string } }).data;
}
});
return meta;
}
async uploadDocument(
file: File,
courseId: string,
@@ -8,6 +8,7 @@
import { closeAiAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
interface Conversation {
id: string;
@@ -75,11 +76,19 @@
<Popover.Root bind:open={historyPopoverOpen}>
<Popover.Trigger>
<IconButton variant="outline" size="icon-xs" tooltip={$t('ai_assistant.chat_history')}>
<HistoryIcon size={16} />
</IconButton>
{#snippet child({ props })}
<Button
variant="outline"
size="icon-xs"
{...props}
aria-label={$t('ai_assistant.chat_history')}
title={$t('ai_assistant.chat_history')}
>
<HistoryIcon size={16} />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="ui:p-0! w-72" align="end">
<Popover.Content class="ui:p-0! z-200! w-72" align="center">
<ChatHistoryPopover
{conversations}
{activeConversationId}
@@ -94,12 +103,13 @@
</IconButton>
</div>
</div>
{#if tokenUsage && tokenUsage.allowance > 0}
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))}
{#if 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">
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
<span
>{tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()}
>{tokenUsage.used.toLocaleString()} / {totalBudget.toLocaleString()}
{$t('ai_assistant.tokens_label')}</span
>
<span>{usagePercent}%</span>
@@ -9,9 +9,10 @@
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 { resolve } from '$app/paths';
import { currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import ModelPicker from '$features/ai-assistant/model-picker.svelte';
import { ModelPicker } from '@cio/ui/custom/model-picker';
import type { AgentModelId } from '@cio/utils/agent-models';
interface UploadedDocument {
@@ -28,11 +29,13 @@
mentionItems: MentionItem[];
uploadedDocument: UploadedDocument | null;
selectedModel: AgentModelId;
lockedModelIds?: readonly AgentModelId[];
onSend: () => void;
onStop: () => void;
onFileSelect: (file: File) => void;
onRemoveDocument: () => void;
onSelectModel: (id: AgentModelId) => void;
onLockedModelSelect?: (id: AgentModelId) => void;
}
let {
@@ -44,11 +47,13 @@
mentionItems,
uploadedDocument,
selectedModel,
lockedModelIds = [],
onSend,
onStop,
onFileSelect,
onRemoveDocument,
onSelectModel
onSelectModel,
onLockedModelSelect
}: Props = $props();
let fileInputEl: HTMLInputElement | undefined = $state();
@@ -110,11 +115,29 @@
<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"
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.tokens_exhausted')}
<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}
</div>
{:else}
{#if isStreaming}
<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"
>
{$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"
@@ -181,7 +204,13 @@
<SquareIcon size={12} />
</Button>
{:else}
<ModelPicker value={selectedModel} disabled={isStreaming} onChange={onSelectModel} />
<ModelPicker
value={selectedModel}
disabled={isStreaming}
{lockedModelIds}
onChange={onSelectModel}
onLockedSelect={onLockedModelSelect}
/>
{/if}
{/snippet}
</ChatTextarea>
@@ -9,6 +9,8 @@
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 type { CoursePlan } from '$features/ai-assistant/utils/course-plan';
import { t } from '$lib/utils/functions/translations';
import {
getAgentToolName,
getAgentToolResult,
@@ -42,6 +44,7 @@
}
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
const tokenUsage = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage);
function isDeferredPlanPart(part: Record<string, unknown>) {
if (!isAgentToolPart(part)) {
@@ -108,7 +111,7 @@
{@const toolStatus = getAgentToolStatus(part)}
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
<PlanView plan={toolResult as CoursePlan} 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}
@@ -148,7 +151,7 @@
{@const errorText = getPartErrorText(part)}
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
<div class="mt-3">
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
<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">
@@ -180,6 +183,15 @@
</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>
@@ -1,49 +0,0 @@
<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>
@@ -6,6 +6,7 @@
import PencilIcon from '@lucide/svelte/icons/pencil';
import { Button } from '@cio/ui/base/button';
import { SvelteSet } from 'svelte/reactivity';
import type { CoursePlan } from './utils/course-plan';
/** Token cost estimation heuristics (tokens per item) */
const TOKEN_COST_ESTIMATES = {
@@ -14,25 +15,6 @@
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;
@@ -0,0 +1,18 @@
export interface CoursePlanSectionItem {
type: 'lesson' | 'exercise';
title: string;
description: string;
order: number;
hasExercise: boolean;
}
export interface CoursePlanSection {
title: string;
order: number;
items: CoursePlanSectionItem[];
}
export interface CoursePlan {
title: string;
sections: CoursePlanSection[];
}
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
import type { AgentModelId } from '@cio/utils/agent-models';
/** Controls whether the AI assistant right sidebar Sheet is open. */
export const isAiAssistantOpen = writable(false);
@@ -14,3 +15,23 @@ export function closeAiAssistant() {
export function toggleAiAssistant() {
isAiAssistantOpen.update((open) => !open);
}
/** Carries a prompt from the home page course creator into the AI chat on first open. */
export const initialChatPrompt = writable<string | null>(null);
export const initialChatModel = writable<AgentModelId | null>(null);
export function setInitialChatPrompt(prompt: string) {
initialChatPrompt.set(prompt);
}
export function setInitialChatModel(model: AgentModelId) {
initialChatModel.set(model);
}
export function clearInitialChatPrompt() {
initialChatPrompt.set(null);
}
export function clearInitialChatModel() {
initialChatModel.set(null);
}
@@ -8,7 +8,9 @@ const TOOL_LABELS: Record<string, string> = {
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',
@@ -40,6 +42,8 @@ export function getToolResultLabel(toolName: string, result: unknown): string {
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':
@@ -78,7 +82,9 @@ export const MUTATION_TOOLS = [
'update_lesson',
'update_lesson_content',
'create_exercise',
'create_exercise_section',
'update_exercise',
'update_exercise_section',
'add_questions',
'update_questions',
'reorder_content',
@@ -12,12 +12,18 @@ export interface AgentToolPart {
};
}
export function isAgentToolPart(part: Record<string, unknown>): part is AgentToolPart {
if (part.type === 'tool-invocation') {
export function isAgentToolPart(part: unknown): part is AgentToolPart {
if (!part || typeof part !== 'object') {
return false;
}
const record = part as Record<string, unknown>;
if (record.type === 'tool-invocation') {
return true;
}
return typeof part.type === 'string' && part.type.startsWith('tool-');
return typeof record.type === 'string' && record.type.startsWith('tool-');
}
export function getAgentToolName(part: AgentToolPart): string | null {
@@ -11,8 +11,22 @@ export interface AiAssistantMessageAttachment {
name: string;
}
export interface AiAssistantMessageTokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface AiAssistantMessageContinuation {
reason: 'step_limit';
maxSteps: number;
finishReason?: string;
}
export interface AiAssistantMessageMetadata {
attachment?: AiAssistantMessageAttachment;
tokenUsage?: AiAssistantMessageTokenUsage;
continuation?: AiAssistantMessageContinuation;
}
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
@@ -47,3 +61,7 @@ export type AgentConversationCreateData = AgentConversationCreateSuccess['data']
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post'];
export type GenerateCourseTitleSuccess = Extract<InferResponseType<GenerateCourseTitleRequest>, { success: true }>;
export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data'];