feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
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';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getToolLabel, getToolResultLabel, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
||||
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolInput,
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
@@ -14,12 +16,16 @@
|
||||
type AgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import {
|
||||
isAiAssistantOpen,
|
||||
chatDraft,
|
||||
clearChatDraft,
|
||||
initialChatModel,
|
||||
initialChatPrompt,
|
||||
initialChatTemplateId,
|
||||
clearInitialChatModel,
|
||||
clearInitialChatPrompt
|
||||
clearInitialChatPrompt,
|
||||
clearInitialChatTemplateId
|
||||
} from '$features/ai-assistant/utils/store';
|
||||
import { get } from 'svelte/store';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Chat } from '@ai-sdk/svelte';
|
||||
@@ -28,23 +34,24 @@
|
||||
import { getMentionableContent } from '$features/course/utils/content';
|
||||
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 { t } from '$lib/utils/functions/translations';
|
||||
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type {
|
||||
AiAssistantMessage,
|
||||
AiAssistantMessageMetadata,
|
||||
AiAssistantTemplateMetadata,
|
||||
UploadedDocument
|
||||
} from '$features/ai-assistant/utils/types';
|
||||
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
|
||||
import {
|
||||
AI_CHAT_MODEL_STORAGE_KEY,
|
||||
AI_COURSE_CHAT_DEFAULT_WIDTH,
|
||||
AI_COURSE_CHAT_MAX_WIDTH,
|
||||
AI_COURSE_CHAT_MIN_WIDTH,
|
||||
AI_COURSE_CHAT_STORAGE_KEY
|
||||
AI_ASSISTANT_QUICK_ACTION_ENTRIES,
|
||||
STUDENT_QUICK_ACTION_ENTRIES
|
||||
} from '$features/ai-assistant/utils/constants';
|
||||
import { calculateContextUsage } from '$features/ai-assistant/utils/context-utils';
|
||||
import {
|
||||
AGENT_MODELS,
|
||||
AGENT_MODEL_IDS,
|
||||
@@ -53,14 +60,18 @@
|
||||
type AgentModelId
|
||||
} from '@cio/utils/agent-models';
|
||||
|
||||
/** Every ~30% / 60% / 90% of tool steps completed, refetch course so UI reflects partial mutations */
|
||||
const AGENT_STEP_PROGRESS_REFRESH_RATIOS = [0.3, 0.6, 0.9] as const;
|
||||
|
||||
/** Completed-step thresholds already refreshed for this streaming session */
|
||||
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
|
||||
let lastSeenStreamingFlag = false;
|
||||
|
||||
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();
|
||||
// Read course id from the route. The chat panel is only mounted inside the
|
||||
// course content layout, so `page.params.id` is always the active course.
|
||||
const courseId = $derived(page.params?.id as string);
|
||||
|
||||
// Extract current lessonId/exerciseId from route params
|
||||
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
|
||||
@@ -69,15 +80,13 @@
|
||||
let inputValue = $state('');
|
||||
let uploadedDocument: UploadedDocument | null = $state(null);
|
||||
let isUploading = $state(false);
|
||||
let contextFullBusy = $state<null | 'compact' | 'new_chat'>(null);
|
||||
|
||||
let pendingInitialTemplateId: CourseTemplateId | null = $state(null);
|
||||
|
||||
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);
|
||||
let isChatResizing = $state(false);
|
||||
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);
|
||||
|
||||
@@ -120,15 +129,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
function clampChatWidth(width: number) {
|
||||
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function clearChatResizeListeners() {
|
||||
stopChatResize?.();
|
||||
stopChatResize = null;
|
||||
}
|
||||
|
||||
function getStorageKey(courseId: string) {
|
||||
return `ai-chat-active-${courseId}`;
|
||||
}
|
||||
@@ -197,15 +197,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status and load conversations when the panel opens or route `courseId` changes
|
||||
$effect(() => {
|
||||
if (!$isAiAssistantOpen) {
|
||||
statusFetchedForCourseId = null;
|
||||
conversationsLoadedForCourseId = null;
|
||||
async function handleRenameConversation(conversationId: string, title: string) {
|
||||
const updatedTitle = await aiAssistantApi.renameConversation(conversationId, title);
|
||||
|
||||
return;
|
||||
if (!updatedTitle) {
|
||||
const rawError = aiAssistantApi.error;
|
||||
let message = t.get('ai_assistant.rename_chat_failed');
|
||||
|
||||
if (rawError && !rawError.startsWith('{')) {
|
||||
message = rawError;
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status and load conversations when the chat panel mounts (the
|
||||
// SidePanelRail only mounts this component while the panel is active) or
|
||||
// when the route's courseId changes.
|
||||
$effect(() => {
|
||||
if (!courseId) return;
|
||||
|
||||
if (statusFetchedForCourseId !== courseId) {
|
||||
@@ -216,14 +226,20 @@
|
||||
if (conversationsLoadedForCourseId !== courseId) {
|
||||
conversationsLoadedForCourseId = courseId;
|
||||
|
||||
// If the panel was just opened to start a fresh chat (e.g. via quoteInChat),
|
||||
// skip the auto-load so the draft effect can call startNewChat itself.
|
||||
const pendingDraft = get(chatDraft);
|
||||
|
||||
if (pendingDraft?.mode === 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (savedId) {
|
||||
@@ -323,18 +339,53 @@
|
||||
}
|
||||
});
|
||||
|
||||
const quickActions = [
|
||||
'Upload a document to create a course',
|
||||
'Draft a lesson',
|
||||
'Generate questions from this lesson',
|
||||
'Summarize this lesson',
|
||||
'Help me publish this course'
|
||||
];
|
||||
function buildTemplateAnswersSummary(
|
||||
templateId: CourseTemplateId,
|
||||
answers: Record<string, string>,
|
||||
fields: TemplateFormField[]
|
||||
): string {
|
||||
const template = getCourseTemplate(templateId);
|
||||
const registryById = new Map((template?.fields ?? []).map((field) => [field.id, field]));
|
||||
const lines: string[] = ['Here are my answers for the course template:'];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
if (!field?.id || seen.has(field.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(field.id);
|
||||
const trimmed = answers[field.id]?.trim() ?? '';
|
||||
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = registryById.get(field.id)?.label ?? field.label ?? field.id;
|
||||
lines.push(`- ${label}: ${trimmed}`);
|
||||
}
|
||||
|
||||
// Capture any answers whose field isn't in the rendered list (defensive — shouldn't happen)
|
||||
for (const [fieldId, value] of Object.entries(answers)) {
|
||||
if (seen.has(fieldId)) continue;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const label = registryById.get(fieldId)?.label ?? fieldId;
|
||||
lines.push(`- ${label}: ${trimmed}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!inputValue.trim() || chat.status === 'streaming') return;
|
||||
|
||||
const text = inputValue;
|
||||
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
|
||||
const templateForFirstMessage = userMessageCount === 0 ? pendingInitialTemplateId : null;
|
||||
|
||||
const messageAttachment = uploadedDocument
|
||||
? {
|
||||
documentId: uploadedDocument.id,
|
||||
@@ -342,6 +393,18 @@
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const metadata: AiAssistantMessageMetadata = {};
|
||||
|
||||
if (messageAttachment) {
|
||||
metadata.attachment = messageAttachment;
|
||||
}
|
||||
|
||||
if (templateForFirstMessage) {
|
||||
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
|
||||
metadata.template = templateMeta;
|
||||
pendingInitialTemplateId = null;
|
||||
}
|
||||
|
||||
inputValue = '';
|
||||
|
||||
// Create conversation eagerly before sending the first message
|
||||
@@ -357,7 +420,48 @@
|
||||
|
||||
chat.sendMessage({
|
||||
text,
|
||||
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
|
||||
...(Object.keys(metadata).length > 0 ? { metadata } : {})
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmitTemplateAnswers(payload: {
|
||||
templateId: CourseTemplateId;
|
||||
answers: Record<string, string>;
|
||||
fields: TemplateFormField[];
|
||||
}) {
|
||||
if (!courseId || chat.status === 'streaming') {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureActiveConversation(courseId);
|
||||
|
||||
chat.sendMessage({
|
||||
text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields),
|
||||
metadata: {
|
||||
template: {
|
||||
action: 'submit_template_answers',
|
||||
templateId: payload.templateId,
|
||||
answers: payload.answers
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSkipTemplateForm(payload: { templateId: CourseTemplateId }) {
|
||||
if (!courseId || chat.status === 'streaming') {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureActiveConversation(courseId);
|
||||
|
||||
chat.sendMessage({
|
||||
text: "I'll answer your questions in chat instead of the form.",
|
||||
metadata: {
|
||||
template: {
|
||||
action: 'skip_template_form',
|
||||
templateId: payload.templateId
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,8 +532,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
function handleAskPlanChanges() {
|
||||
inputValue = '';
|
||||
function handleAskPlanChanges(message: string) {
|
||||
const trimmed = message.trim();
|
||||
|
||||
if (!trimmed || chat.status === 'streaming') return;
|
||||
|
||||
if (!activeConversationId && courseId) {
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chat.sendMessage({ text: trimmed });
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
@@ -441,9 +557,94 @@
|
||||
goto(resolve(route, {}));
|
||||
}
|
||||
|
||||
async function handleStartNewChatWithSummary() {
|
||||
if (!courseId || contextFullBusy) return;
|
||||
|
||||
contextFullBusy = 'new_chat';
|
||||
|
||||
try {
|
||||
const summary = await aiAssistantApi.summarizeConversation(chat.messages as AiAssistantMessage[], courseId);
|
||||
|
||||
chat.messages = [];
|
||||
setActiveConversationId(courseId, null);
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
inputValue = summary;
|
||||
handleSend();
|
||||
}
|
||||
} finally {
|
||||
contextFullBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompactConversation() {
|
||||
if (!courseId || !activeConversationId || contextFullBusy) return;
|
||||
|
||||
contextFullBusy = 'compact';
|
||||
|
||||
try {
|
||||
const compacted = await aiAssistantApi.compactConversation(activeConversationId);
|
||||
|
||||
if (compacted && compacted.length > 0) {
|
||||
chat.messages = compacted as AiAssistantMessage[];
|
||||
await chat.regenerate();
|
||||
}
|
||||
} finally {
|
||||
contextFullBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
||||
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
||||
|
||||
const status = $derived(aiAssistantApi.status);
|
||||
const isStudent = $derived(status?.role === 'student');
|
||||
const tutorStatus = $derived(status?.tutor);
|
||||
|
||||
const tutorErrorCode = $derived.by(() => {
|
||||
if (!chat.error) return null;
|
||||
const message = chat.error.message ?? '';
|
||||
if (message.includes('AI_TUTOR_DISABLED')) return 'AI_TUTOR_DISABLED' as const;
|
||||
if (message.includes('LEARNER_CAP_REACHED')) return 'LEARNER_CAP_REACHED' as const;
|
||||
if (message.includes('POOL_EXHAUSTED')) return 'POOL_EXHAUSTED' as const;
|
||||
return null;
|
||||
});
|
||||
|
||||
const tutorBlocked = $derived.by(() => {
|
||||
if (!isStudent) return null;
|
||||
if (tutorErrorCode) return tutorErrorCode;
|
||||
if (tutorStatus && tutorStatus.enabled === false) return 'AI_TUTOR_DISABLED' as const;
|
||||
if (tutorStatus && tutorStatus.enforced && tutorStatus.capRemaining !== null && tutorStatus.capRemaining <= 0) {
|
||||
return 'LEARNER_CAP_REACHED' as const;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const quickActions = $derived(isStudent ? [...STUDENT_QUICK_ACTION_ENTRIES] : [...AI_ASSISTANT_QUICK_ACTION_ENTRIES]);
|
||||
|
||||
// Student-facing per-learner monthly cap (100 messages). Hidden when the agent reports
|
||||
// no tutor data (e.g. provider unconfigured) so we don't render a 0/0 bar.
|
||||
const studentMessageUsage = $derived.by(() => {
|
||||
if (!isStudent || !tutorStatus || tutorStatus.cap == null || tutorStatus.capRemaining == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
used: Math.max(0, tutorStatus.cap - tutorStatus.capRemaining),
|
||||
cap: tutorStatus.cap
|
||||
};
|
||||
});
|
||||
|
||||
const contextUsage = $derived(
|
||||
calculateContextUsage(chat.messages as AiAssistantMessage[], AGENT_MODELS[selectedModel].contextWindow)
|
||||
);
|
||||
|
||||
const mentionItems = $derived(
|
||||
getMentionableContent(courseApi.course).map((item) => ({
|
||||
id: item.id,
|
||||
@@ -455,9 +656,31 @@
|
||||
// Show an activity card whenever the agent calls any tool.
|
||||
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
|
||||
const planExecutionState = $derived.by(() => {
|
||||
const lastAssistantMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
const lastMsg = chat.messages[chat.messages.length - 1];
|
||||
|
||||
if (!lastAssistantMsg) return null;
|
||||
if (!lastMsg) return null;
|
||||
|
||||
// If the most recent message is from the user, the agent has not produced a
|
||||
// reply yet — don't show the previous assistant's tool card. While streaming
|
||||
// is starting up, fall through to the thinking placeholder below.
|
||||
if (lastMsg.role !== 'assistant') {
|
||||
if (!isStreaming) return null;
|
||||
|
||||
return {
|
||||
steps: [
|
||||
{
|
||||
status: 'in_progress' as const,
|
||||
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
|
||||
}
|
||||
],
|
||||
currentActionLine: undefined,
|
||||
isStopped: false,
|
||||
titleKey: 'ai_assistant.plan_working',
|
||||
hasMutations: false
|
||||
};
|
||||
}
|
||||
|
||||
const lastAssistantMsg = lastMsg;
|
||||
|
||||
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
|
||||
const reachedStepLimit = continuation?.reason === 'step_limit';
|
||||
@@ -466,9 +689,29 @@
|
||||
) as AgentToolPart[];
|
||||
|
||||
// generate_course_plan renders its own PlanView — exclude it from the card
|
||||
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
|
||||
const toolParts = allToolParts.filter((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
return toolName !== 'generate_course_plan' && toolName !== 'ask_template_questions';
|
||||
});
|
||||
|
||||
if (toolParts.length === 0) return null;
|
||||
if (toolParts.length === 0) {
|
||||
if (!isStreaming) return null;
|
||||
|
||||
// The agent is mid-turn (reasoning / preparing a tool call) but hasn't emitted
|
||||
// a real tool part yet. Show a placeholder step so the user always sees activity.
|
||||
return {
|
||||
steps: [
|
||||
{
|
||||
status: 'in_progress' as const,
|
||||
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
|
||||
}
|
||||
],
|
||||
currentActionLine: undefined,
|
||||
isStopped: false,
|
||||
titleKey: 'ai_assistant.plan_working',
|
||||
hasMutations: false
|
||||
};
|
||||
}
|
||||
|
||||
const steps: ProgressStep[] = toolParts.flatMap((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
@@ -479,9 +722,12 @@
|
||||
|
||||
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
|
||||
const status = getAgentToolStatus(part);
|
||||
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
|
||||
const line =
|
||||
status === 'completed'
|
||||
? getCompletedToolLine(toolName, result)
|
||||
: getPendingToolLine(toolName, getAgentToolInput(part));
|
||||
|
||||
return [{ label, status }];
|
||||
return [{ line, status }];
|
||||
});
|
||||
|
||||
const allDone = steps.every((s) => s.status === 'completed');
|
||||
@@ -494,55 +740,60 @@
|
||||
const toolName = getAgentToolName(part);
|
||||
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
|
||||
});
|
||||
const title = hasMutations ? 'Applying changes' : 'Working';
|
||||
const currentAction = steps.find((s) => s.status === 'in_progress')?.label;
|
||||
const titleKey = hasMutations ? 'ai_assistant.plan_applying_changes' : 'ai_assistant.plan_working';
|
||||
const currentActionLine = steps.find((s) => s.status === 'in_progress')?.line;
|
||||
|
||||
return { steps, currentAction, isStopped, title };
|
||||
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const streamingNow = isStreaming;
|
||||
|
||||
if (streamingNow && !lastSeenStreamingFlag) {
|
||||
agentMutationProgressThresholdsTriggered.clear();
|
||||
}
|
||||
|
||||
lastSeenStreamingFlag = streamingNow;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = planExecutionState;
|
||||
|
||||
if (!state?.hasMutations || state.isStopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = state.steps.length;
|
||||
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completedStepCount = state.steps.filter((s) => s.status === 'completed').length;
|
||||
const stepThresholds = [
|
||||
...new Set(
|
||||
AGENT_STEP_PROGRESS_REFRESH_RATIOS.map((ratio) => Math.min(total, Math.max(1, Math.ceil(total * ratio))))
|
||||
)
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
for (const threshold of stepThresholds) {
|
||||
if (completedStepCount >= threshold && !agentMutationProgressThresholdsTriggered.has(threshold)) {
|
||||
agentMutationProgressThresholdsTriggered.add(threshold);
|
||||
|
||||
refreshCourseStateAfterChat();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const conversationTitle = $derived(
|
||||
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
|
||||
);
|
||||
|
||||
function handleChatResizePointerDown(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
clearChatResizeListeners();
|
||||
|
||||
const startWidth = chatWidth;
|
||||
|
||||
stopChatResize = startResizablePanelDrag({
|
||||
event,
|
||||
startWidth,
|
||||
resolveWidth: ({ startWidth, deltaX }) => clampChatWidth(startWidth - deltaX),
|
||||
onPreview: (width) => {
|
||||
chatShellElement?.style.setProperty('--ai-chat-width', `${width}px`);
|
||||
},
|
||||
onCommit: ({ width }) => {
|
||||
chatWidth = width;
|
||||
},
|
||||
onDragStart: () => {
|
||||
isChatResizing = true;
|
||||
},
|
||||
onDragEnd: () => {
|
||||
isChatResizing = false;
|
||||
stopChatResize = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
|
||||
|
||||
if (Number.isFinite(storedWidth)) {
|
||||
chatWidth = clampChatWidth(storedWidth);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
try {
|
||||
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
|
||||
|
||||
@@ -552,28 +803,22 @@
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
hasLoadedChatWidth = true;
|
||||
|
||||
return () => {
|
||||
clearChatResizeListeners();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearChatResizeListeners();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const prompt = $initialChatPrompt;
|
||||
|
||||
if (!prompt || !$isAiAssistantOpen || !courseId) return;
|
||||
if (!prompt || !courseId) return;
|
||||
|
||||
const model = $initialChatModel;
|
||||
const templateFromHome = $initialChatTemplateId;
|
||||
clearInitialChatPrompt();
|
||||
clearInitialChatModel();
|
||||
clearInitialChatTemplateId();
|
||||
|
||||
tick().then(() => {
|
||||
pendingInitialTemplateId = templateFromHome ?? null;
|
||||
|
||||
if (model && isAgentModelId(model)) {
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
openUpgradeModal();
|
||||
@@ -589,21 +834,37 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!hasLoadedChatWidth) {
|
||||
const draft = $chatDraft;
|
||||
|
||||
if (!draft || !draft.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
clearChatDraft();
|
||||
|
||||
if (draft.mode === 'new') {
|
||||
void startNewChat().then(() =>
|
||||
tick().then(() => {
|
||||
inputValue = `${draft.text}\n\n`;
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void tick().then(() => {
|
||||
const existing = inputValue.trimEnd();
|
||||
|
||||
inputValue = existing ? `${draft.text}\n\n${existing}` : `${draft.text}\n\n`;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet chatPanelContent()}
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<ChatHeader
|
||||
{tokenUsage}
|
||||
{isStudent}
|
||||
{studentMessageUsage}
|
||||
{conversationTitle}
|
||||
conversations={aiAssistantApi.conversations}
|
||||
{activeConversationId}
|
||||
@@ -611,18 +872,21 @@
|
||||
onNewChat={startNewChat}
|
||||
onLoadConversation={loadConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onRenameConversation={handleRenameConversation}
|
||||
/>
|
||||
|
||||
<ChatMessageList
|
||||
messages={chat.messages}
|
||||
status={chat.status}
|
||||
{isStreaming}
|
||||
{isStudent}
|
||||
{courseId}
|
||||
{planExecutionState}
|
||||
{quickActions}
|
||||
onQuickAction={handleQuickAction}
|
||||
onImplementPlan={handleImplementPlan}
|
||||
onAskPlanChanges={handleAskPlanChanges}
|
||||
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
|
||||
onSkipTemplateForm={handleSkipTemplateForm}
|
||||
onStop={handleStop}
|
||||
onResume={handleResume}
|
||||
onMentionClick={handleMentionClick}
|
||||
@@ -633,9 +897,15 @@
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{contextFullBusy}
|
||||
compactConversationDisabled={!activeConversationId}
|
||||
onCompactConversation={handleCompactConversation}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
{contextUsage}
|
||||
{isStudent}
|
||||
{tutorBlocked}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
@@ -644,37 +914,6 @@
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
onStartNewChatWithSummary={handleStartNewChatWithSummary}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if $isAiAssistantOpen}
|
||||
<div
|
||||
bind:this={chatShellElement}
|
||||
data-ai-chat-resizing={isChatResizing}
|
||||
class="contents"
|
||||
style={`--ai-chat-width: ${chatWidth}px;`}
|
||||
>
|
||||
<div class="hidden shrink-0 md:block md:w-(--ai-chat-width)"></div>
|
||||
|
||||
<div
|
||||
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--ai-chat-width)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize AI assistant panel"
|
||||
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
|
||||
onpointerdown={handleChatResizePointerDown}
|
||||
>
|
||||
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
|
||||
</button>
|
||||
|
||||
{@render chatPanelContent()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global([data-ai-chat-resizing='true'] *) {
|
||||
transition-property: none !important;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,9 @@ import type {
|
||||
AgentConversationCreateData,
|
||||
AgentConversationSummary,
|
||||
AgentStatusData,
|
||||
AiAssistantMessage
|
||||
AiAssistantMessage,
|
||||
CompactConversationRequest,
|
||||
CompactConversationSuccess
|
||||
} from '../utils/types';
|
||||
|
||||
class AiAssistantApi extends BaseApiWithErrors {
|
||||
@@ -183,6 +185,71 @@ class AiAssistantApi extends BaseApiWithErrors {
|
||||
|
||||
return generatedTitle;
|
||||
}
|
||||
|
||||
async renameConversation(conversationId: string, title: string): Promise<string | null> {
|
||||
let newTitle: string | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$patch']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$patch({
|
||||
param: { conversationId },
|
||||
json: { title }
|
||||
}),
|
||||
logContext: 'renaming conversation',
|
||||
onSuccess: (result) => {
|
||||
newTitle = (result.data as { id: string; title: string }).title;
|
||||
|
||||
this.conversations = this.conversations.map((c) => (c.id === conversationId ? { ...c, title: newTitle } : c));
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = { ...this.currentConversation, title: newTitle };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newTitle;
|
||||
}
|
||||
|
||||
async summarizeConversation(messages: AiAssistantMessage[], courseId: string): Promise<string | null> {
|
||||
let summary: string | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent)['summarize']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.summarize.$post({
|
||||
json: { messages, courseId }
|
||||
}),
|
||||
logContext: 'summarizing conversation',
|
||||
onSuccess: (result) => {
|
||||
summary = (result.data as { summary: string }).summary;
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async compactConversation(conversationId: string): Promise<CompactConversationSuccess['data']['messages'] | null> {
|
||||
let compacted: CompactConversationSuccess['data']['messages'] | null = null;
|
||||
|
||||
await this.execute<CompactConversationRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].compact.$post({
|
||||
param: { conversationId }
|
||||
}),
|
||||
logContext: 'compacting conversation',
|
||||
onSuccess: (result) => {
|
||||
compacted = result.data.messages;
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = {
|
||||
...this.currentConversation,
|
||||
messages: compacted as AiAssistantMessage[]
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return compacted;
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAssistantApi = new AiAssistantApi();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { tick } from 'svelte';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
@@ -22,8 +24,15 @@
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface StudentMessageUsage {
|
||||
used: number;
|
||||
cap: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tokenUsage: TokenUsage | null;
|
||||
isStudent: boolean;
|
||||
studentMessageUsage: StudentMessageUsage | null;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
conversationTitle: string | null;
|
||||
@@ -31,25 +40,132 @@
|
||||
onNewChat: () => void;
|
||||
onLoadConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
/** Resolves on success; rejects with Error when rename fails (message is user-presentable). */
|
||||
onRenameConversation: (id: string, title: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
tokenUsage,
|
||||
isStudent,
|
||||
studentMessageUsage,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
conversationTitle,
|
||||
isNewChatDisabled,
|
||||
onNewChat,
|
||||
onLoadConversation,
|
||||
onDeleteConversation
|
||||
onDeleteConversation,
|
||||
onRenameConversation
|
||||
}: Props = $props();
|
||||
|
||||
let historyPopoverOpen = $state(false);
|
||||
|
||||
let editingConversationTitle = $state(false);
|
||||
let draftConversationTitle = $state('');
|
||||
let renameConversationError = $state<string | null>(null);
|
||||
let titleInputRef: HTMLInputElement | null = $state(null);
|
||||
|
||||
let renameCommitInFlight = $state(false);
|
||||
let snapshotConversationIdForRename = $state<string | null>(null);
|
||||
|
||||
const canRenameConversation = $derived(
|
||||
Boolean(activeConversationId && conversationTitle && conversationTitle !== 'New conversation')
|
||||
);
|
||||
|
||||
function handleLoadConversation(id: string) {
|
||||
onLoadConversation(id);
|
||||
historyPopoverOpen = false;
|
||||
}
|
||||
|
||||
function cancelRenameConversation() {
|
||||
if (renameCommitInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingConversationTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
editingConversationTitle = false;
|
||||
renameConversationError = null;
|
||||
draftConversationTitle = conversationTitle ?? '';
|
||||
snapshotConversationIdForRename = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
editingConversationTitle &&
|
||||
snapshotConversationIdForRename != null &&
|
||||
activeConversationId !== snapshotConversationIdForRename
|
||||
) {
|
||||
cancelRenameConversation();
|
||||
}
|
||||
});
|
||||
|
||||
async function startRenameConversation() {
|
||||
if (!canRenameConversation || !activeConversationId || !conversationTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
renameConversationError = null;
|
||||
draftConversationTitle = conversationTitle;
|
||||
snapshotConversationIdForRename = activeConversationId;
|
||||
editingConversationTitle = true;
|
||||
|
||||
await tick();
|
||||
|
||||
titleInputRef?.focus();
|
||||
titleInputRef?.select();
|
||||
}
|
||||
|
||||
async function commitRenameConversation() {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedTitle = draftConversationTitle.trim();
|
||||
|
||||
if (trimmedTitle.length === 0) {
|
||||
cancelRenameConversation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedTitle === conversationTitle) {
|
||||
editingConversationTitle = false;
|
||||
renameConversationError = null;
|
||||
snapshotConversationIdForRename = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
renameCommitInFlight = true;
|
||||
|
||||
try {
|
||||
await onRenameConversation(activeConversationId, trimmedTitle);
|
||||
editingConversationTitle = false;
|
||||
renameConversationError = null;
|
||||
snapshotConversationIdForRename = null;
|
||||
} catch (renameError) {
|
||||
const message = renameError instanceof Error ? renameError.message : t.get('ai_assistant.rename_chat_failed');
|
||||
|
||||
renameConversationError = message;
|
||||
} finally {
|
||||
renameCommitInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTitleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRenameConversation();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRenameConversation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b px-4 py-3">
|
||||
@@ -59,7 +175,32 @@
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
|
||||
{#if conversationTitle && conversationTitle !== 'New conversation'}
|
||||
<p class="ui:text-muted-foreground truncate text-xs">{conversationTitle}</p>
|
||||
{#if editingConversationTitle}
|
||||
<div class="min-w-0">
|
||||
<Input
|
||||
bind:ref={titleInputRef}
|
||||
bind:value={draftConversationTitle}
|
||||
class="ui:h-7 ui:min-h-0 ui:px-1.5 ui:py-0 text-xs"
|
||||
placeholder={$t('ai_assistant.rename_chat_placeholder')}
|
||||
aria-label={$t('ai_assistant.rename_chat_input_aria')}
|
||||
onkeydown={handleTitleKeydown}
|
||||
onblur={cancelRenameConversation}
|
||||
/>
|
||||
{#if renameConversationError}
|
||||
<p class="ui:text-destructive mt-0.5 text-[10px]">{renameConversationError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ui:max-w-full ui:cursor-pointer ui:rounded-md ui:border ui:border-transparent ui:px-1.5 ui:py-0.5 hover:ui:bg-muted/40 hover:ui:border-border truncate text-left text-xs"
|
||||
aria-label={$t('ai_assistant.rename_chat_aria')}
|
||||
disabled={!canRenameConversation}
|
||||
onclick={startRenameConversation}
|
||||
>
|
||||
{conversationTitle}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +244,28 @@
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{#if tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
|
||||
{#if isStudent && studentMessageUsage}
|
||||
{@const usagePercent = Math.min(100, Math.round((studentMessageUsage.used / studentMessageUsage.cap) * 100))}
|
||||
<div class="mt-2">
|
||||
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
|
||||
<span
|
||||
>{studentMessageUsage.used.toLocaleString()} / {studentMessageUsage.cap.toLocaleString()}
|
||||
{$t('ai_assistant.messages_used_label')}</span
|
||||
>
|
||||
<span>{usagePercent}%</span>
|
||||
</div>
|
||||
<div class="ui:bg-muted mt-0.5 h-1 w-full rounded-full">
|
||||
<div
|
||||
class="h-1 rounded-full transition-all {usagePercent > 90
|
||||
? 'bg-red-500'
|
||||
: usagePercent > 70
|
||||
? 'bg-amber-500'
|
||||
: 'ui:bg-primary'}"
|
||||
style="width: {usagePercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !isStudent && 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">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
||||
@@ -14,6 +17,10 @@
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { ModelPicker } from '@cio/ui/custom/model-picker';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
import { AI_AGENT_RUNNING_WARNING_DISMISSED_KEY } from '$features/ai-assistant/utils/constants';
|
||||
import ContextIndicator from './context-indicator.svelte';
|
||||
import ContextFullState from './context-full-state.svelte';
|
||||
import type { ContextUsage } from './utils/context-utils';
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string;
|
||||
@@ -25,17 +32,25 @@
|
||||
isStreaming: boolean;
|
||||
isExhausted: boolean;
|
||||
isUploading: boolean;
|
||||
contextFullBusy: null | 'compact' | 'new_chat';
|
||||
compactConversationDisabled?: boolean;
|
||||
error: Error | null | undefined;
|
||||
mentionItems: MentionItem[];
|
||||
uploadedDocument: UploadedDocument | null;
|
||||
selectedModel: AgentModelId;
|
||||
lockedModelIds?: readonly AgentModelId[];
|
||||
contextUsage: ContextUsage | null;
|
||||
isStudent?: boolean;
|
||||
/** Set to 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' to render the take-a-break empty state. */
|
||||
tutorBlocked?: 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' | null;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onRemoveDocument: () => void;
|
||||
onSelectModel: (id: AgentModelId) => void;
|
||||
onLockedModelSelect?: (id: AgentModelId) => void;
|
||||
onStartNewChatWithSummary: () => void;
|
||||
onCompactConversation: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -43,23 +58,64 @@
|
||||
isStreaming,
|
||||
isExhausted,
|
||||
isUploading,
|
||||
contextFullBusy,
|
||||
compactConversationDisabled = false,
|
||||
error,
|
||||
mentionItems,
|
||||
uploadedDocument,
|
||||
selectedModel,
|
||||
lockedModelIds = [],
|
||||
contextUsage,
|
||||
isStudent = false,
|
||||
tutorBlocked = null,
|
||||
onSend,
|
||||
onStop,
|
||||
onFileSelect,
|
||||
onRemoveDocument,
|
||||
onSelectModel,
|
||||
onLockedModelSelect
|
||||
onLockedModelSelect,
|
||||
onStartNewChatWithSummary,
|
||||
onCompactConversation
|
||||
}: Props = $props();
|
||||
|
||||
function tutorBlockedMessage(reason: NonNullable<typeof tutorBlocked>): string {
|
||||
if (reason === 'LEARNER_CAP_REACHED') return t.get('aiTutor.takeABreak.learnerCap');
|
||||
if (reason === 'POOL_EXHAUSTED') return t.get('aiTutor.takeABreak.poolExhausted');
|
||||
return t.get('aiTutor.takeABreak.disabled');
|
||||
}
|
||||
|
||||
let fileInputEl: HTMLInputElement | undefined = $state();
|
||||
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
|
||||
let wasBusy = $state(false);
|
||||
|
||||
function readAgentRunningWarningDismissed() {
|
||||
if (!browser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return localStorage.getItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let agentRunningWarningDismissed = $state(readAgentRunningWarningDismissed());
|
||||
|
||||
onMount(() => {
|
||||
agentRunningWarningDismissed = readAgentRunningWarningDismissed();
|
||||
});
|
||||
|
||||
function dismissAgentRunningWarning() {
|
||||
agentRunningWarningDismissed = true;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY, '1');
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const isBusy = isStreaming || isUploading;
|
||||
|
||||
@@ -102,6 +158,31 @@
|
||||
|
||||
return t.get('ai_assistant.mention_lesson');
|
||||
}
|
||||
|
||||
function getUserFriendlyErrorMessage(errorMessage: string): string {
|
||||
const lowerMessage = errorMessage.toLowerCase();
|
||||
|
||||
if (lowerMessage.includes('quota exceeded') || lowerMessage.includes('rate limit')) {
|
||||
const retryMatch = errorMessage.match(/retry in (\d+(?:\.\d+)?)/i);
|
||||
const waitSeconds = retryMatch ? Math.ceil(parseFloat(retryMatch[1])) : null;
|
||||
|
||||
return waitSeconds
|
||||
? t.get('ai_assistant.error_rate_limit_with_wait', { seconds: waitSeconds })
|
||||
: t.get('ai_assistant.error_rate_limit');
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('context length') || lowerMessage.includes('too long')) {
|
||||
return t.get('ai_assistant.error_context_too_long');
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('network') || lowerMessage.includes('connection')) {
|
||||
return t.get('ai_assistant.error_network');
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
const displayErrorMessage = $derived(error ? getUserFriendlyErrorMessage(error.message) : null);
|
||||
</script>
|
||||
|
||||
<input
|
||||
@@ -112,108 +193,144 @@
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div class="border-t px-3 pt-3 pb-1.5">
|
||||
{#if isExhausted}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
{#if tutorBlocked}
|
||||
<div class="border-t px-3 py-4">
|
||||
<div class="ui:bg-muted/40 ui:text-muted-foreground rounded-md border px-3 py-3 text-sm">
|
||||
<p class="ui:text-foreground mb-1 text-sm font-medium">{$t('aiTutor.takeABreak.title')}</p>
|
||||
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if isStreaming}
|
||||
</div>
|
||||
{:else if contextUsage?.isFull && !isStreaming}
|
||||
<ContextFullState
|
||||
{contextFullBusy}
|
||||
{compactConversationDisabled}
|
||||
{onCompactConversation}
|
||||
onStartNewChat={onStartNewChatWithSummary}
|
||||
/>
|
||||
{:else}
|
||||
<div class="border-t px-3 pt-3 pb-1.5">
|
||||
{#if isExhausted}
|
||||
<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"
|
||||
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.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"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isUploading}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<LoaderIcon size={12} class="animate-spin" />
|
||||
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
|
||||
</div>
|
||||
{:else if uploadedDocument}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
|
||||
<button
|
||||
onclick={onRemoveDocument}
|
||||
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<ChatTextarea
|
||||
bind:ref={chatTextareaRef}
|
||||
bind:value={inputValue}
|
||||
{mentionItems}
|
||||
onSubmit={onSend}
|
||||
placeholder={$t('ai_assistant.input_placeholder')}
|
||||
disabled={isStreaming || isUploading}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
rows={2}
|
||||
class="flex-1"
|
||||
>
|
||||
{#snippet icon({ item })}
|
||||
{#if item.type === 'EXERCISE'}
|
||||
<ExerciseIcon size={14} />
|
||||
{:else if item.type === 'SECTION'}
|
||||
<TableOfContentsIcon size={14} />
|
||||
{:else}
|
||||
<LessonIcon size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
<button
|
||||
onclick={handlePaperclipClick}
|
||||
disabled={isUploading}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
|
||||
<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"
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
{$t('ai_assistant.tokens_exhausted_buy_more')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if isStreaming && !agentRunningWarningDismissed && !isStudent}
|
||||
<div
|
||||
class="mb-2 flex items-start gap-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"
|
||||
>
|
||||
<span class="min-w-0 flex-1">{$t('ai_assistant.agent_running_warning')}</span>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
tooltip={t.get('ai_assistant.agent_running_warning_dismiss')}
|
||||
class="border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900"
|
||||
onclick={dismissAgentRunningWarning}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if displayErrorMessage}
|
||||
<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"
|
||||
>
|
||||
{displayErrorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isUploading}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<LoaderIcon size={12} class="animate-spin" />
|
||||
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
|
||||
</div>
|
||||
{:else if uploadedDocument}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
|
||||
<button
|
||||
onclick={onRemoveDocument}
|
||||
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex items-end gap-2">
|
||||
<ChatTextarea
|
||||
bind:ref={chatTextareaRef}
|
||||
bind:value={inputValue}
|
||||
{mentionItems}
|
||||
onSubmit={onSend}
|
||||
placeholder={$t('ai_assistant.input_placeholder')}
|
||||
disabled={isStreaming || isUploading}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
rows={2}
|
||||
class="flex-1"
|
||||
>
|
||||
{#snippet icon({ item })}
|
||||
{#if item.type === 'EXERCISE'}
|
||||
<ExerciseIcon size={14} />
|
||||
{:else if item.type === 'SECTION'}
|
||||
<TableOfContentsIcon size={14} />
|
||||
{:else}
|
||||
<LessonIcon size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if isStreaming}
|
||||
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
||||
<SquareIcon size={12} />
|
||||
</Button>
|
||||
{:else}
|
||||
<ModelPicker
|
||||
value={selectedModel}
|
||||
disabled={isStreaming}
|
||||
{lockedModelIds}
|
||||
onChange={onSelectModel}
|
||||
onLockedSelect={onLockedModelSelect}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatTextarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
{#if !isStudent}
|
||||
<button
|
||||
onclick={handlePaperclipClick}
|
||||
disabled={isUploading}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if contextUsage && contextUsage.usedTokens > 0}
|
||||
<ContextIndicator {contextUsage} />
|
||||
{/if}
|
||||
|
||||
{#if isStreaming}
|
||||
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
||||
<SquareIcon size={12} />
|
||||
</Button>
|
||||
{:else if !isStudent}
|
||||
<ModelPicker
|
||||
value={selectedModel}
|
||||
disabled={isStreaming}
|
||||
{lockedModelIds}
|
||||
onChange={onSelectModel}
|
||||
onLockedSelect={onLockedModelSelect}
|
||||
/>
|
||||
{:else}
|
||||
<Button size="sm" onclick={onSend} disabled={!inputValue.trim()} class="shrink-0">
|
||||
{$t('ai_assistant.send')}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatTextarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,26 +5,40 @@
|
||||
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
|
||||
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
|
||||
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
|
||||
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
|
||||
|
||||
interface QuickActionOption {
|
||||
key: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface PlanExecutionState {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
currentActionLine?: ToolLineUi;
|
||||
isStopped: boolean;
|
||||
/** True while the assistant is invoking course-mutation tools (see `MUTATION_TOOLS`). */
|
||||
hasMutations?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: AiAssistantMessage[];
|
||||
status: string;
|
||||
isStreaming: boolean;
|
||||
isStudent: boolean;
|
||||
courseId: string;
|
||||
planExecutionState: PlanExecutionState | null;
|
||||
quickActions: string[];
|
||||
quickActions: QuickActionOption[];
|
||||
onQuickAction: (action: string) => void;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onAskPlanChanges: (message: string) => void;
|
||||
onSubmitTemplateAnswers: (payload: {
|
||||
templateId: CourseTemplateId;
|
||||
answers: Record<string, string>;
|
||||
fields: TemplateFormField[];
|
||||
}) => void;
|
||||
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
|
||||
onStop: () => void;
|
||||
onResume: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
@@ -32,14 +46,16 @@
|
||||
|
||||
let {
|
||||
messages,
|
||||
status,
|
||||
isStreaming,
|
||||
isStudent,
|
||||
courseId,
|
||||
planExecutionState,
|
||||
quickActions,
|
||||
onQuickAction,
|
||||
onImplementPlan,
|
||||
onAskPlanChanges,
|
||||
onSubmitTemplateAnswers,
|
||||
onSkipTemplateForm,
|
||||
onStop,
|
||||
onResume,
|
||||
onMentionClick
|
||||
@@ -48,13 +64,34 @@
|
||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||
let lastMessageCount = $state(0);
|
||||
let lastStepsCount = $state(0);
|
||||
let lastCurrentAction = $state('');
|
||||
let lastCurrentActionSig = $state('');
|
||||
let lastStreamingSig = $state(0);
|
||||
|
||||
const isEmpty = $derived(messages.length === 0);
|
||||
|
||||
function scrollToBottom() {
|
||||
// Total text length of the most recent assistant message — grows as tokens stream in.
|
||||
// Used to keep the view pinned to the latest text while the AI is typing.
|
||||
const streamingContentSig = $derived.by(() => {
|
||||
if (messages.length === 0) return 0;
|
||||
|
||||
const last = messages[messages.length - 1];
|
||||
|
||||
if (last.role !== 'assistant') return 0;
|
||||
|
||||
let total = 0;
|
||||
|
||||
for (const part of last.parts ?? []) {
|
||||
if ((part as { type?: string }).type === 'text') {
|
||||
total += ((part as { text?: string }).text ?? '').length;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
});
|
||||
|
||||
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||
requestAnimationFrame(() => {
|
||||
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
||||
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +104,16 @@
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer) return;
|
||||
|
||||
if (streamingContentSig === lastStreamingSig) return;
|
||||
|
||||
lastStreamingSig = streamingContentSig;
|
||||
// Use 'auto' during streaming — smooth scrolls queue up per token and lag behind the cursor.
|
||||
scrollToBottom('auto');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer || !planExecutionState) return;
|
||||
|
||||
@@ -77,9 +124,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const action = planExecutionState.currentAction ?? '';
|
||||
if (action !== lastCurrentAction) {
|
||||
lastCurrentAction = action;
|
||||
const actionSig = planExecutionState.currentActionLine ? JSON.stringify(planExecutionState.currentActionLine) : '';
|
||||
|
||||
if (actionSig !== lastCurrentActionSig) {
|
||||
lastCurrentActionSig = actionSig;
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -92,17 +140,17 @@
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<SparklesIcon size={32} class="ui:text-muted-foreground" />
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('ai_assistant.empty_state')}
|
||||
{$t(isStudent ? 'ai_assistant.student_empty_state' : 'ai_assistant.empty_state')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
{#each quickActions as action (action)}
|
||||
{#each quickActions as option (option.key)}
|
||||
<button
|
||||
onclick={() => onQuickAction(action)}
|
||||
onclick={() => onQuickAction(option.prompt)}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
|
||||
>
|
||||
{action}
|
||||
{$t(option.key)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -110,11 +158,31 @@
|
||||
{:else}
|
||||
<!-- Message list -->
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each messages as message (message.id)}
|
||||
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
|
||||
{#each messages as message, messageIndex (message.id)}
|
||||
{@const compaction = (message.metadata as AiAssistantMessageMetadata | undefined)?.compaction}
|
||||
{@const isLast = messageIndex === messages.length - 1}
|
||||
|
||||
{#if compaction}
|
||||
<p class="ui:text-muted-foreground mx-1 px-2 text-[11px] leading-snug">
|
||||
{$t('ai_assistant.context_compacted_badge', { count: compaction.originalMessageCount })}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<MessageBubble
|
||||
{message}
|
||||
{messages}
|
||||
{courseId}
|
||||
{isStreaming}
|
||||
{isLast}
|
||||
{onImplementPlan}
|
||||
{onAskPlanChanges}
|
||||
{onSubmitTemplateAnswers}
|
||||
{onSkipTemplateForm}
|
||||
{onMentionClick}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if status === 'submitted' && isStreaming}
|
||||
{#if isStreaming && (isStudent || !planExecutionState)}
|
||||
<div class="flex items-start">
|
||||
<div class="ui:bg-muted rounded-lg px-3 py-2">
|
||||
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
|
||||
@@ -122,12 +190,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planExecutionState}
|
||||
{#if planExecutionState && !isStudent}
|
||||
<div class="mt-2">
|
||||
<ProgressCard
|
||||
title={planExecutionState.title}
|
||||
titleKey={planExecutionState.titleKey}
|
||||
steps={planExecutionState.steps}
|
||||
currentAction={planExecutionState.currentAction}
|
||||
currentActionLine={planExecutionState.currentActionLine}
|
||||
{courseId}
|
||||
onNavigate={onMentionClick}
|
||||
onStop={isStreaming ? onStop : undefined}
|
||||
isStopped={planExecutionState.isStopped}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
|
||||
import Minimize2Icon from '@lucide/svelte/icons/minimize-2';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
contextFullBusy: null | 'compact' | 'new_chat';
|
||||
compactConversationDisabled?: boolean;
|
||||
onCompactConversation: () => void;
|
||||
onStartNewChat: () => void;
|
||||
}
|
||||
|
||||
let { contextFullBusy, compactConversationDisabled = false, onCompactConversation, onStartNewChat }: Props = $props();
|
||||
|
||||
const compactBusy = $derived(contextFullBusy === 'compact');
|
||||
const busy = $derived(contextFullBusy !== null);
|
||||
</script>
|
||||
|
||||
<div class="border-t px-3 py-4">
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-5 text-center dark:border-amber-800 dark:bg-amber-950"
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{$t('ai_assistant.context_full_title')}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('ai_assistant.context_full_description')}
|
||||
</p>
|
||||
|
||||
<div class="flex w-full max-w-xs flex-col gap-2 pt-1 sm:max-w-sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={onCompactConversation}
|
||||
disabled={busy || compactConversationDisabled}
|
||||
class="w-full gap-2"
|
||||
>
|
||||
{#if compactBusy}
|
||||
<LoaderIcon size={14} class="shrink-0 animate-spin" />
|
||||
{$t('ai_assistant.context_full_compacting')}
|
||||
{:else}
|
||||
<Minimize2Icon size={14} class="shrink-0" />
|
||||
{$t('ai_assistant.context_full_compact')}
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={onStartNewChat}
|
||||
disabled={busy}
|
||||
class="w-full gap-2 border-amber-300 bg-amber-50/70 text-amber-900 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-100 dark:hover:bg-amber-900"
|
||||
>
|
||||
{#if contextFullBusy === 'new_chat'}
|
||||
<LoaderIcon size={14} class="shrink-0 animate-spin" />
|
||||
{$t('ai_assistant.context_full_summarizing')}
|
||||
{:else}
|
||||
<MessageSquarePlusIcon size={14} class="shrink-0" />
|
||||
{$t('ai_assistant.context_full_new_chat')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { CircularProgress } from '@cio/ui/custom/circular-progress';
|
||||
import { Tooltip, Provider, Trigger, Content } from '@cio/ui/base/tooltip';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { formatTokenCount, type ContextUsage } from './utils/context-utils';
|
||||
|
||||
interface Props {
|
||||
contextUsage: ContextUsage;
|
||||
}
|
||||
|
||||
let { contextUsage }: Props = $props();
|
||||
|
||||
const progressClass = $derived(
|
||||
contextUsage.isFull ? 'ui:stroke-destructive' : contextUsage.isNearlyFull ? 'stroke-amber-500' : 'ui:stroke-primary'
|
||||
);
|
||||
|
||||
const tooltipText = $derived(
|
||||
t.get('ai_assistant.context_usage_tooltip', {
|
||||
used: formatTokenCount(contextUsage.usedTokens),
|
||||
max: formatTokenCount(contextUsage.maxTokens)
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<Provider>
|
||||
<Tooltip>
|
||||
<Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors"
|
||||
>
|
||||
<CircularProgress
|
||||
value={contextUsage.percentage}
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
{progressClass}
|
||||
trackClass="ui:stroke-muted"
|
||||
/>
|
||||
<span>{contextUsage.percentage}%</span>
|
||||
</button>
|
||||
</Trigger>
|
||||
<Content side="top">
|
||||
<p class="text-xs">{tooltipText}</p>
|
||||
</Content>
|
||||
</Tooltip>
|
||||
</Provider>
|
||||
@@ -1,4 +1,14 @@
|
||||
export { default as AiCourseChat } from './ai-course-chat.svelte';
|
||||
export { default as PlanView } from './plan-view.svelte';
|
||||
export { default as ProgressCard } from './progress-card.svelte';
|
||||
export { isAiAssistantOpen, openAiAssistant, closeAiAssistant, toggleAiAssistant } from './utils/store';
|
||||
export {
|
||||
AI_ASSISTANT_PANEL_ID,
|
||||
chatDraft,
|
||||
clearChatDraft,
|
||||
openAiAssistant,
|
||||
closeAiAssistant,
|
||||
quoteInChat,
|
||||
setChatDraft,
|
||||
toggleAiAssistant
|
||||
} from './utils/store';
|
||||
export { aiAssistantPanelDefinition } from './panel';
|
||||
|
||||
@@ -8,26 +8,64 @@
|
||||
import { renderMarkdown } from '$features/ai-assistant/utils/markdown';
|
||||
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 TemplateFormCard from '$features/ai-assistant/template-form-card.svelte';
|
||||
import { isTemplateFormResolved } from '$features/ai-assistant/utils/template-form-resolved';
|
||||
import { mergeTemplateFieldsWithRegistry } from '$features/ai-assistant/utils/template-fields';
|
||||
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
|
||||
import {
|
||||
getCompletedToolLine,
|
||||
getPendingToolI18nKey,
|
||||
getPendingToolI18nVars,
|
||||
getPendingToolLine
|
||||
} 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,
|
||||
getAgentToolInput,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
isAgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
|
||||
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
|
||||
|
||||
interface Props {
|
||||
message: AiAssistantMessage;
|
||||
messages: AiAssistantMessage[];
|
||||
courseId: string;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onAskPlanChanges: (message: string) => void;
|
||||
isStreaming: boolean;
|
||||
isLast?: boolean;
|
||||
onSubmitTemplateAnswers: (payload: {
|
||||
templateId: CourseTemplateId;
|
||||
answers: Record<string, string>;
|
||||
fields: TemplateFormField[];
|
||||
}) => void;
|
||||
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props();
|
||||
let {
|
||||
message,
|
||||
messages,
|
||||
courseId,
|
||||
onImplementPlan,
|
||||
onAskPlanChanges,
|
||||
onSubmitTemplateAnswers,
|
||||
onSkipTemplateForm,
|
||||
onMentionClick,
|
||||
isStreaming,
|
||||
isLast = false
|
||||
}: Props = $props();
|
||||
|
||||
function localizePendingTool(toolName: string): string {
|
||||
const vars = getPendingToolI18nVars(toolName);
|
||||
const key = getPendingToolI18nKey(toolName);
|
||||
|
||||
return t.get(key, vars ?? {});
|
||||
}
|
||||
|
||||
function handleBubbleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
@@ -46,12 +84,15 @@
|
||||
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
|
||||
const tokenUsage = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage);
|
||||
|
||||
/** Tool parts rendered in a second pass so narrative text always appears above them (stream order often emits tools first). */
|
||||
function isDeferredPlanPart(part: Record<string, unknown>) {
|
||||
if (!isAgentToolPart(part)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getAgentToolName(part) === 'generate_course_plan';
|
||||
const name = getAgentToolName(part);
|
||||
|
||||
return name === 'generate_course_plan' || name === 'ask_template_questions';
|
||||
}
|
||||
|
||||
function truncateErrorText(errorText: string): string {
|
||||
@@ -72,61 +113,104 @@
|
||||
return (part as { errorText?: string }).errorText;
|
||||
}
|
||||
|
||||
const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record<string, unknown>)));
|
||||
const inlineParts = $derived(message.parts.filter((part) => (part as { type?: string }).type === 'text'));
|
||||
const deferredPlanParts = $derived(
|
||||
message.parts.filter((part) => isDeferredPlanPart(part as Record<string, unknown>))
|
||||
);
|
||||
const hasBubbleContent = $derived(inlineParts.length > 0 || deferredPlanParts.length > 0 || !!messageAttachment);
|
||||
const showStreamingSpinner = $derived(message.role === 'assistant' && !hasBubbleContent && isStreaming && isLast);
|
||||
</script>
|
||||
|
||||
<div data-role={message.role} class="flex flex-col gap-1 {message.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
|
||||
? 'ui:bg-primary ui:text-primary-foreground'
|
||||
: 'ui:bg-muted'}"
|
||||
onclick={handleBubbleClick}
|
||||
>
|
||||
{#if messageAttachment}
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
|
||||
? 'bg-white/15 text-white/90'
|
||||
: 'ui:bg-background/70 ui:text-muted-foreground'}"
|
||||
>
|
||||
<FileTextIcon size={12} class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{messageAttachment.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each inlineParts as part, partIndex (partIndex)}
|
||||
{#if part.type === 'text'}
|
||||
{#if message.role === 'assistant' && !hasBubbleContent && !showStreamingSpinner}
|
||||
<!-- Assistant message has no renderable parts yet and isn't streaming — skip the empty bubble -->
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
|
||||
? 'ui:bg-primary ui:text-primary-foreground'
|
||||
: 'ui:bg-muted'}"
|
||||
onclick={handleBubbleClick}
|
||||
>
|
||||
{#if showStreamingSpinner}
|
||||
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
|
||||
{/if}
|
||||
{#if messageAttachment}
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none {message.role === 'user' && 'ui:text-primary-foreground!'}"
|
||||
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
|
||||
? 'bg-white/15 text-white/90'
|
||||
: 'ui:bg-background/70 ui:text-muted-foreground'}"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
|
||||
<FileTextIcon size={12} class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{messageAttachment.name}</span>
|
||||
</div>
|
||||
{:else if isAgentToolPart(part)}
|
||||
{/if}
|
||||
|
||||
{#each inlineParts as part, partIndex (partIndex)}
|
||||
{#if part.type === 'text'}
|
||||
<div
|
||||
class="ai-chat-prose prose prose-sm dark:prose-invert max-w-none {message.role === 'user' &&
|
||||
'ui:text-primary-foreground!'}"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each deferredPlanParts as part, partIndex (partIndex)}
|
||||
{@const toolName = getAgentToolName(part)}
|
||||
{@const toolResult = getAgentToolResult(part)}
|
||||
{@const toolStatus = getAgentToolStatus(part)}
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<PlanView plan={toolResult as CoursePlan} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
|
||||
<div class="mt-3">
|
||||
<PlanView
|
||||
plan={toolResult as CoursePlan}
|
||||
onImplement={onImplementPlan}
|
||||
onAskChanges={onAskPlanChanges}
|
||||
isBusy={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
{:else if toolName === 'ask_template_questions' && (toolStatus === 'completed' || toolStatus === 'in_progress')}
|
||||
{@const merged = (toolResult ?? getAgentToolInput(part)) as
|
||||
| { templateId?: CourseTemplateId; fields?: TemplateFormField[] }
|
||||
| undefined}
|
||||
{#if merged?.templateId}
|
||||
{@const canonicalFields = mergeTemplateFieldsWithRegistry(merged.templateId, merged.fields)}
|
||||
{#if canonicalFields.length > 0}
|
||||
<div class="mt-3">
|
||||
<TemplateFormCard
|
||||
templateId={merged.templateId}
|
||||
fields={canonicalFields}
|
||||
allMessages={messages}
|
||||
submitted={isTemplateFormResolved(messages, merged.templateId)}
|
||||
disableFormInputs={toolStatus === 'in_progress'}
|
||||
onSubmit={onSubmitTemplateAnswers}
|
||||
onSkip={onSkipTemplateForm}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="ui:bg-background/70 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
<div class="ui:bg-background/70 mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
{#if toolStatus === 'completed' && toolName}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span>{getToolResultLabel(toolName, toolResult)}</span>
|
||||
<ToolLine line={getCompletedToolLine(toolName, toolResult)} {courseId} onNavigate={onMentionClick} />
|
||||
{:else if toolStatus === 'failed' && toolName}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
|
||||
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
|
||||
{#if errorText}
|
||||
{truncateErrorText(errorText)}
|
||||
{:else}
|
||||
{$t('ai_assistant.run_failed_after', { action: localizePendingTool(toolName) })}
|
||||
{/if}
|
||||
</span>
|
||||
{#if errorText}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
|
||||
title="Copy full error"
|
||||
title={$t('ai_assistant.copy_full_error')}
|
||||
onclick={() => copyToClipboard(errorText)}
|
||||
>
|
||||
<CopyIcon size={11} />
|
||||
@@ -134,68 +218,39 @@
|
||||
{/if}
|
||||
{:else if toolStatus === 'in_progress' && toolName}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span>{getToolLabel(toolName)}</span>
|
||||
<ToolLine line={getPendingToolLine(toolName)} {courseId} onNavigate={onMentionClick} />
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
|
||||
{#if toolName}
|
||||
<span class="ui:text-muted-foreground">
|
||||
<ToolLine line={getPendingToolLine(toolName)} {courseId} onNavigate={onMentionClick} />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="ui:text-muted-foreground">{$t('ai_assistant.generic_working')}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#each deferredPlanParts as part, partIndex (partIndex)}
|
||||
{@const toolName = getAgentToolName(part)}
|
||||
{@const toolResult = getAgentToolResult(part)}
|
||||
{@const toolStatus = getAgentToolStatus(part)}
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<div class="mt-3">
|
||||
<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">
|
||||
{#if toolStatus === 'completed' && toolName}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span>{getToolResultLabel(toolName, toolResult)}</span>
|
||||
{:else if toolStatus === 'failed' && toolName}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
|
||||
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
|
||||
</span>
|
||||
{#if errorText}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
|
||||
title="Copy full error"
|
||||
onclick={() => copyToClipboard(errorText)}
|
||||
>
|
||||
<CopyIcon size={11} />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if toolStatus === 'in_progress' && toolName}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span>{getToolLabel(toolName)}</span>
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
|
||||
{/if}
|
||||
{#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}
|
||||
{/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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Reset global `apps/dashboard/src/app.css` `.prose p { mb-4 }` for chat bubbles */
|
||||
:global(.ai-chat-prose.prose p) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.mention-link) {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
@@ -207,10 +262,16 @@
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
:global([data-role='user'] a) {
|
||||
:global([data-role='user'] a),
|
||||
:global([data-role='user'] .prose a) {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:global([data-role='user'] .prose code:not(pre code)) {
|
||||
color: rgb(9 9 11);
|
||||
background-color: rgb(255 255 255 / 0.95);
|
||||
}
|
||||
|
||||
:global(.mention-link:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
import {
|
||||
AI_COURSE_CHAT_DEFAULT_WIDTH,
|
||||
AI_COURSE_CHAT_MAX_WIDTH,
|
||||
AI_COURSE_CHAT_MIN_WIDTH,
|
||||
AI_COURSE_CHAT_STORAGE_KEY
|
||||
} from './utils/constants';
|
||||
import { AI_ASSISTANT_PANEL_ID } from './utils/store';
|
||||
import type { SidePanelDefinition } from '$features/side-panel';
|
||||
import AiCourseChat from './ai-course-chat.svelte';
|
||||
|
||||
export const aiAssistantPanelDefinition: SidePanelDefinition = {
|
||||
id: AI_ASSISTANT_PANEL_ID,
|
||||
titleKey: 'side_panel.titles.ai_assistant',
|
||||
scope: 'course',
|
||||
component: AiCourseChat as Component<Record<string, unknown>>,
|
||||
defaultWidth: AI_COURSE_CHAT_DEFAULT_WIDTH,
|
||||
minWidth: AI_COURSE_CHAT_MIN_WIDTH,
|
||||
maxWidth: AI_COURSE_CHAT_MAX_WIDTH,
|
||||
widthStorageKey: AI_COURSE_CHAT_STORAGE_KEY
|
||||
};
|
||||
@@ -4,8 +4,11 @@
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { CoursePlan } from './utils/course-plan';
|
||||
|
||||
/** Token cost estimation heuristics (tokens per item) */
|
||||
@@ -18,11 +21,31 @@
|
||||
interface Props {
|
||||
plan: CoursePlan;
|
||||
onImplement: (editedPlan: CoursePlan) => void;
|
||||
onAskChanges: () => void;
|
||||
onAskChanges: (message: string) => void;
|
||||
/** When true, disables the inline change-request composer (agent is already working). */
|
||||
isBusy?: boolean;
|
||||
remainingTokens?: number;
|
||||
}
|
||||
|
||||
let { plan, onImplement, onAskChanges, remainingTokens }: Props = $props();
|
||||
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens }: Props = $props();
|
||||
|
||||
let changeRequest = $state('');
|
||||
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
|
||||
|
||||
function submitChangeRequest() {
|
||||
if (!canSendChange) return;
|
||||
|
||||
const message = changeRequest.trim();
|
||||
changeRequest = '';
|
||||
onAskChanges(message);
|
||||
}
|
||||
|
||||
function handleChangeKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submitChangeRequest();
|
||||
}
|
||||
}
|
||||
|
||||
// CoursePlan is plain JSON; structuredClone fails when the AI SDK part carries
|
||||
// non-cloneable internals, so JSON round-trip is both safer and sufficient.
|
||||
@@ -237,9 +260,29 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={() => onImplement(editablePlan)} class="flex-1">Implement Plan</Button>
|
||||
<Button size="sm" variant="outline" onclick={onAskChanges} class="flex-1">Ask for Changes</Button>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button size="sm" onclick={() => onImplement(editablePlan)} class="w-full">
|
||||
{$t('ai_assistant.plan_implement')}
|
||||
</Button>
|
||||
<div class="flex items-end gap-2">
|
||||
<Textarea
|
||||
bind:value={changeRequest}
|
||||
placeholder={$t('ai_assistant.plan_ask_changes_placeholder')}
|
||||
rows={1}
|
||||
disabled={isBusy}
|
||||
onkeydown={handleChangeKeydown}
|
||||
class="ui:min-h-9! ui:resize-none ui:py-2 ui:text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
disabled={!canSendChange}
|
||||
onclick={submitChangeRequest}
|
||||
aria-label={$t('ai_assistant.plan_ask_changes_send_aria')}
|
||||
>
|
||||
<ArrowUpIcon class="ui:size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,27 +2,42 @@
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import SquareIcon from '@lucide/svelte/icons/square';
|
||||
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
|
||||
import SquareIcon from '@lucide/svelte/icons/square';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
|
||||
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface ProgressStep {
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
courseId: string;
|
||||
/** Same handler as `@mention` clicks (SPA navigation into lesson / exercise). */
|
||||
onNavigate: (route: string) => void;
|
||||
currentActionLine?: ToolLineUi;
|
||||
onStop?: () => void;
|
||||
isStopped?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { title, steps, currentAction, onStop, isStopped = false, error }: Props = $props();
|
||||
let { titleKey, steps, courseId, onNavigate, currentActionLine, onStop, isStopped = false, error }: Props = $props();
|
||||
|
||||
function stepRowKey(step: ProgressStep, index: number): string {
|
||||
if (step.line.shape === 'i18n') {
|
||||
return `${index}-${step.line.key}-${JSON.stringify(step.line.vars ?? {})}`;
|
||||
}
|
||||
|
||||
if (step.line.shape === 'lesson_written') {
|
||||
return `${index}-lesson-${step.line.lessonId}-${step.line.charCount}-${step.line.title}`;
|
||||
}
|
||||
|
||||
if (step.line.shape === 'landing_page_updated') {
|
||||
return `${index}-landing-${step.line.title}`;
|
||||
}
|
||||
|
||||
return `${index}-exo-${step.line.exerciseId}-${step.line.action}-${step.line.count}-${step.line.title}`;
|
||||
}
|
||||
|
||||
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
|
||||
const totalCount = $derived(steps.length);
|
||||
@@ -30,14 +45,14 @@
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<div class="border-b px-3 py-2">
|
||||
<h4 class="text-sm font-semibold">{title}</h4>
|
||||
<h4 class="text-sm font-semibold">{$t(titleKey)}</h4>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{completedCount} / {totalCount} steps completed
|
||||
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
{#each steps as step, i (`${i}-${step.label}`)}
|
||||
{#each steps as step, i (stepRowKey(step, i))}
|
||||
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''}">
|
||||
{#if step.status === 'completed'}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
@@ -49,7 +64,7 @@
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
|
||||
{step.label}
|
||||
<ToolLine line={step.line} {courseId} {onNavigate} />
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -60,8 +75,10 @@
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if currentAction && !isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{currentAction}</p>
|
||||
{#if currentActionLine && !isStopped}
|
||||
<div class="ui:text-muted-foreground text-xs leading-snug">
|
||||
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isStopped}
|
||||
@@ -71,7 +88,7 @@
|
||||
{#if onStop && !isStopped && completedCount < totalCount}
|
||||
<Button size="sm" variant="outline" onclick={onStop} class="w-full">
|
||||
<SquareIcon size={12} class="mr-1" />
|
||||
Stop
|
||||
{$t('ai_assistant.stop')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
|
||||
import { getCourseTemplate } from '@cio/ai-assistant';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { DISPLAY_BY_ID } from '$features/ai-assistant/utils/template-display';
|
||||
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface Props {
|
||||
templateId: CourseTemplateId;
|
||||
fields: TemplateFormField[];
|
||||
allMessages: AiAssistantMessage[];
|
||||
submitted: boolean;
|
||||
/** True while the `ask_template_questions` tool is still in progress (not the global chat busy flag). */
|
||||
disableFormInputs: boolean;
|
||||
onSubmit: (payload: {
|
||||
templateId: CourseTemplateId;
|
||||
answers: Record<string, string>;
|
||||
fields: TemplateFormField[];
|
||||
}) => void;
|
||||
onSkip: (payload: { templateId: CourseTemplateId }) => void;
|
||||
}
|
||||
|
||||
let { templateId, fields, allMessages, submitted, disableFormInputs, onSubmit, onSkip }: Props = $props();
|
||||
|
||||
const display = $derived(DISPLAY_BY_ID[templateId]);
|
||||
|
||||
let answers = $state<Record<string, string>>({});
|
||||
|
||||
$effect.pre(() => {
|
||||
void templateId;
|
||||
void fields;
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
for (const field of fields ?? []) {
|
||||
if (!field?.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
next[field.id] = field.options?.[0]?.value ?? '';
|
||||
} else {
|
||||
next[field.id] = '';
|
||||
}
|
||||
}
|
||||
|
||||
answers = next;
|
||||
});
|
||||
|
||||
const resolutionKind = $derived.by((): 'submitted' | 'skipped' | null => {
|
||||
for (let index = allMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = allMessages[index];
|
||||
|
||||
if (message.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tmpl = message.metadata?.template;
|
||||
|
||||
if (!tmpl || !('action' in tmpl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tmpl.templateId !== templateId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tmpl.action === 'submit_template_answers') {
|
||||
return 'submitted';
|
||||
}
|
||||
|
||||
if (tmpl.action === 'skip_template_form') {
|
||||
return 'skipped';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
function resolveFieldLabel(field: TemplateFormField): string {
|
||||
const key = display?.fieldLabelKeys?.[field.id];
|
||||
|
||||
if (key) {
|
||||
const translated = t.get(key);
|
||||
|
||||
if (translated && translated !== key) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
|
||||
return field.label;
|
||||
}
|
||||
|
||||
function resolveFormTitle(): string {
|
||||
const key = display?.formTitleKey;
|
||||
|
||||
if (!key) {
|
||||
return getCourseTemplate(templateId)?.formTitle ?? '';
|
||||
}
|
||||
|
||||
const translated = t.get(key);
|
||||
|
||||
if (translated && translated !== key) {
|
||||
return translated;
|
||||
}
|
||||
|
||||
return getCourseTemplate(templateId)?.formTitle ?? '';
|
||||
}
|
||||
|
||||
function isValidHttpUrl(value: string): boolean {
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = $derived.by(() => {
|
||||
if (submitted || disableFormInputs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const field of fields ?? []) {
|
||||
if (!field?.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = answers[field.id] ?? '';
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (field.type === 'select' && !field.options?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.required && !trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.type === 'url' && trimmed && !isValidHttpUrl(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({ templateId, answers: { ...answers }, fields });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-card max-w-md rounded-xl border p-4">
|
||||
<h4 class="ui:text-foreground mb-3 text-sm font-medium">{resolveFormTitle()}</h4>
|
||||
|
||||
{#if submitted}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{resolutionKind === 'skipped'
|
||||
? $t('course.creator.template.skipped_summary')
|
||||
: $t('course.creator.template.submitted_summary')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each fields as field (field.id)}
|
||||
{#if field.type === 'text'}
|
||||
<InputField
|
||||
label={resolveFieldLabel(field)}
|
||||
bind:value={answers[field.id]}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
isRequired={field.required === true}
|
||||
isDisabled={disableFormInputs}
|
||||
/>
|
||||
{:else if field.type === 'textarea'}
|
||||
<TextareaField
|
||||
label={resolveFieldLabel(field)}
|
||||
bind:value={answers[field.id]}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
isRequired={field.required === true}
|
||||
disabled={disableFormInputs}
|
||||
rows={4}
|
||||
/>
|
||||
{:else if field.type === 'select'}
|
||||
<Field.Field>
|
||||
<Field.Label>
|
||||
{resolveFieldLabel(field)}
|
||||
{#if field.required}
|
||||
<span class="ui:text-red-700">*</span>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
<Select.Root type="single" bind:value={answers[field.id]} disabled={disableFormInputs}>
|
||||
<Select.Trigger class="ui:w-full">
|
||||
{(field.options ?? []).find((option) => option.value === answers[field.id])?.label ?? ''}
|
||||
</Select.Trigger>
|
||||
<Select.Content style="z-index: 251">
|
||||
{#each field.options ?? [] as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
{:else if field.type === 'url'}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<GlobeIcon class="ui:text-muted-foreground mt-2.5 size-4 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<InputField
|
||||
label={resolveFieldLabel(field)}
|
||||
bind:value={answers[field.id]}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
type="url"
|
||||
isRequired={false}
|
||||
isDisabled={disableFormInputs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="ui:text-muted-foreground pl-7 text-xs">
|
||||
{$t('course.creator.template.field.documentation_url.hint')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button size="sm" disabled={!canSubmit} onclick={handleSubmit}>
|
||||
{$t('course.creator.template.submit')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground hover:ui:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
disabled={disableFormInputs}
|
||||
onclick={() => onSkip({ templateId })}
|
||||
>
|
||||
{$t('course.creator.template.skip')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,27 +1,29 @@
|
||||
<script lang="ts">
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
|
||||
import ToolLine from '$features/ai-assistant/utils/tool-line.svelte';
|
||||
import { getCompletedToolLine, getPendingToolLine } from '$features/ai-assistant/utils/tool-labels';
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
state: string;
|
||||
result?: unknown;
|
||||
/** Required for linked lesson/exercise summaries in ToolLine rows */
|
||||
courseId: string;
|
||||
onNavigate?: (route: string) => void;
|
||||
}
|
||||
|
||||
let { toolName, state, result }: Props = $props();
|
||||
let { toolName, state, result, courseId, onNavigate = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background/50 flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
||||
{#if state === 'call' || state === 'partial-call'}
|
||||
<LoaderIcon size={12} class="animate-spin" />
|
||||
<span class="ui:text-muted-foreground">
|
||||
{getToolLabel(toolName)}...
|
||||
<span class="ui:text-muted-foreground inline-flex flex-wrap gap-x-1">
|
||||
<ToolLine line={getPendingToolLine(toolName)} {courseId} {onNavigate} />…
|
||||
</span>
|
||||
{:else if state === 'result'}
|
||||
<CheckIcon size={12} class="ui:text-primary" />
|
||||
<span>
|
||||
{getToolResultLabel(toolName, result)}
|
||||
</span>
|
||||
<span><ToolLine line={getCompletedToolLine(toolName, result)} {courseId} {onNavigate} /></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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