feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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'];
|
||||
|
||||
Reference in New Issue
Block a user