feat: widgets and ai chat
This commit is contained in:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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'];
|
||||
Reference in New Issue
Block a user