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,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'];