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