feat: widgets and ai chat
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
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 type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
isAgentToolPart,
|
||||
type AgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import { isAiAssistantOpen } from '$features/ai-assistant/utils/store';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Chat } from '@ai-sdk/svelte';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
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 { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { AiAssistantMessage, UploadedDocument } from '$features/ai-assistant/utils/types';
|
||||
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
|
||||
} from '$features/ai-assistant/utils/constants';
|
||||
import { AGENT_MODEL_IDS, DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
// Extract current lessonId/exerciseId from route params
|
||||
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
|
||||
const currentExerciseId = $derived(page.params?.exerciseId as string | undefined);
|
||||
|
||||
let inputValue = $state('');
|
||||
let uploadedDocument: UploadedDocument | null = $state(null);
|
||||
let isUploading = $state(false);
|
||||
|
||||
let usageFetched = $state(false);
|
||||
let conversationsLoaded = $state(false);
|
||||
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);
|
||||
|
||||
function isAgentModelId(value: unknown): value is AgentModelId {
|
||||
return typeof value === 'string' && (AGENT_MODEL_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function handleSelectModel(id: AgentModelId) {
|
||||
selectedModel = id;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, id);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const tokenUsage = $derived(aiAssistantApi.status?.usage ?? null);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function getActiveConversationId(courseId: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(getStorageKey(courseId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveConversationId(courseId: string, conversationId: string | null) {
|
||||
activeConversationId = conversationId;
|
||||
|
||||
try {
|
||||
if (conversationId) {
|
||||
localStorage.setItem(getStorageKey(courseId), conversationId);
|
||||
} else {
|
||||
localStorage.removeItem(getStorageKey(courseId));
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.loadConversation(conversationId);
|
||||
|
||||
const conversation = aiAssistantApi.currentConversation;
|
||||
|
||||
if (conversation) {
|
||||
chat.messages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (created) {
|
||||
chat.messages = [];
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.deleteConversation(conversationId);
|
||||
|
||||
if (activeConversationId === conversationId) {
|
||||
chat.messages = [];
|
||||
setActiveConversationId(courseId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status and load conversations when panel opens
|
||||
$effect(() => {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if ($isAiAssistantOpen && !usageFetched && courseId) {
|
||||
usageFetched = true;
|
||||
aiAssistantApi.fetchStatus(courseId);
|
||||
}
|
||||
|
||||
if ($isAiAssistantOpen && !conversationsLoaded && courseId) {
|
||||
conversationsLoaded = true;
|
||||
|
||||
const savedId = getActiveConversationId(courseId);
|
||||
|
||||
aiAssistantApi.listConversations(courseId).then(() => {
|
||||
if (savedId) {
|
||||
loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
loadConversation(aiAssistantApi.conversations[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!$isAiAssistantOpen) {
|
||||
usageFetched = false;
|
||||
conversationsLoaded = false;
|
||||
}
|
||||
});
|
||||
|
||||
const chat = new Chat({
|
||||
id: 'ai-assistant',
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${getRequestBaseUrl()}/agent/chat`,
|
||||
credentials: 'include',
|
||||
body: () => ({
|
||||
courseId: courseApi.course?.id ?? '',
|
||||
model: selectedModel,
|
||||
context: {
|
||||
lessonId: page.params?.lessonId,
|
||||
exerciseId: page.params?.exerciseId,
|
||||
documentId: uploadedDocument?.id
|
||||
}
|
||||
}),
|
||||
fetch: (input, init) => apiClient.request(input, init)
|
||||
}),
|
||||
onFinish: async () => {
|
||||
const courseId = courseApi.course?.id;
|
||||
const profileId = $profile.id;
|
||||
|
||||
// Force refetch course data so new sections/lessons/exercises show in the UI
|
||||
if (courseId && profileId) {
|
||||
courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
|
||||
// Refresh current lesson content if viewing a lesson
|
||||
if (courseId && currentLessonId) {
|
||||
lessonApi.get(courseId, currentLessonId);
|
||||
}
|
||||
|
||||
// Refresh current exercise if viewing an exercise
|
||||
if (courseId && currentExerciseId) {
|
||||
await refreshExercisePageData(courseId, currentExerciseId);
|
||||
}
|
||||
|
||||
// Refresh usage meter
|
||||
if (courseId) {
|
||||
aiAssistantApi.fetchStatus(courseId);
|
||||
}
|
||||
|
||||
// Clear document attachment after message is processed
|
||||
uploadedDocument = null;
|
||||
|
||||
// Persist messages to the active conversation
|
||||
if (activeConversationId) {
|
||||
await aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
|
||||
|
||||
// Generate a smart title after the first exchange (when title is still default)
|
||||
const activeConv = aiAssistantApi.conversations.find((c) => c.id === activeConversationId);
|
||||
const isDefaultTitle = !activeConv?.title || activeConv.title === 'New conversation';
|
||||
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
|
||||
const textPart = firstUserMsg?.parts?.find((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text');
|
||||
|
||||
if (isDefaultTitle && textPart?.text) {
|
||||
await aiAssistantApi.generateTitle(activeConversationId, textPart.text.slice(0, 500));
|
||||
}
|
||||
} else if (courseId) {
|
||||
// No active conversation yet — create one and save
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
await aiAssistantApi.saveMessages(created.id, chat.messages as AiAssistantMessage[]);
|
||||
|
||||
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
|
||||
const textPart = firstUserMsg?.parts?.find(
|
||||
(p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text'
|
||||
);
|
||||
|
||||
if (textPart?.text) {
|
||||
await aiAssistantApi.generateTitle(created.id, textPart.text.slice(0, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const quickActions = [
|
||||
'Upload a document to create a course',
|
||||
'Draft a lesson',
|
||||
'Generate questions from this lesson',
|
||||
"Improve this lesson's text",
|
||||
'Summarize this lesson'
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!inputValue.trim() || chat.status === 'streaming') return;
|
||||
|
||||
const text = inputValue;
|
||||
const messageAttachment = uploadedDocument
|
||||
? {
|
||||
documentId: uploadedDocument.id,
|
||||
name: uploadedDocument.name
|
||||
}
|
||||
: undefined;
|
||||
|
||||
inputValue = '';
|
||||
|
||||
// Create conversation eagerly before sending the first message
|
||||
if (!activeConversationId) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chat.sendMessage({
|
||||
text,
|
||||
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureActiveConversation(courseId: string): Promise<string | null> {
|
||||
if (activeConversationId) return activeConversationId;
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (!created) return null;
|
||||
|
||||
setActiveConversationId(courseId, created.id);
|
||||
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const conversationId = await ensureActiveConversation(courseId);
|
||||
|
||||
if (!conversationId) return;
|
||||
|
||||
isUploading = true;
|
||||
const result = await aiAssistantApi.uploadDocument(file, courseId, conversationId);
|
||||
isUploading = false;
|
||||
|
||||
if (result) {
|
||||
uploadedDocument = { id: result.documentId, name: result.fileName };
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveDocument() {
|
||||
uploadedDocument = null;
|
||||
}
|
||||
|
||||
function handleQuickAction(action: string) {
|
||||
inputValue = action;
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
chat.stop();
|
||||
}
|
||||
|
||||
function handleImplementPlan(editedPlan: unknown) {
|
||||
if (chat.status === 'streaming') return;
|
||||
|
||||
inputValue = '';
|
||||
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!activeConversationId) {
|
||||
if (!courseId) return;
|
||||
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chat.sendMessage({
|
||||
text: 'Implement this plan.',
|
||||
metadata: {
|
||||
plan: {
|
||||
action: 'implement_course_plan',
|
||||
payload: editedPlan
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleAskPlanChanges() {
|
||||
inputValue = '';
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
inputValue = 'Continue implementing the plan from where you left off.';
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function handleMentionClick(route: string) {
|
||||
goto(resolve(route, {}));
|
||||
}
|
||||
|
||||
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
||||
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
||||
|
||||
const mentionItems = $derived(
|
||||
getMentionableContent(courseApi.course).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
type: item.type
|
||||
}))
|
||||
);
|
||||
|
||||
// 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');
|
||||
|
||||
if (!lastAssistantMsg) return null;
|
||||
|
||||
const allToolParts = lastAssistantMsg.parts.filter((part: Record<string, unknown>) =>
|
||||
isAgentToolPart(part)
|
||||
) as AgentToolPart[];
|
||||
|
||||
// generate_course_plan renders its own PlanView — exclude it from the card
|
||||
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
|
||||
|
||||
if (toolParts.length === 0) return null;
|
||||
|
||||
const steps: ProgressStep[] = toolParts.flatMap((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
|
||||
if (!toolName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
|
||||
const status = getAgentToolStatus(part);
|
||||
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
|
||||
|
||||
return [{ label, status }];
|
||||
});
|
||||
|
||||
const allDone = steps.every((s) => s.status === 'completed');
|
||||
const isStopped = !isStreaming && !allDone;
|
||||
|
||||
// Hide the card once the agent finishes cleanly — the text response takes over
|
||||
if (allDone && !isStreaming) return null;
|
||||
|
||||
const hasMutations = toolParts.some((part) => {
|
||||
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;
|
||||
|
||||
return { steps, currentAction, isStopped, title };
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (isAgentModelId(storedModel)) {
|
||||
selectedModel = storedModel;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
hasLoadedChatWidth = true;
|
||||
|
||||
return () => {
|
||||
clearChatResizeListeners();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearChatResizeListeners();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!hasLoadedChatWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet chatPanelContent()}
|
||||
<ChatHeader
|
||||
{tokenUsage}
|
||||
{conversationTitle}
|
||||
conversations={aiAssistantApi.conversations}
|
||||
{activeConversationId}
|
||||
isNewChatDisabled={chat.messages.length === 0}
|
||||
onNewChat={startNewChat}
|
||||
onLoadConversation={loadConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<ChatMessageList
|
||||
messages={chat.messages}
|
||||
status={chat.status}
|
||||
{isStreaming}
|
||||
courseId={courseApi.course?.id ?? ''}
|
||||
{planExecutionState}
|
||||
{quickActions}
|
||||
onQuickAction={handleQuickAction}
|
||||
onImplementPlan={handleImplementPlan}
|
||||
onAskPlanChanges={handleAskPlanChanges}
|
||||
onStop={handleStop}
|
||||
onResume={handleResume}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
bind:inputValue
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
/>
|
||||
{/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>
|
||||
@@ -0,0 +1,171 @@
|
||||
import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api';
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationCreateData,
|
||||
AgentConversationSummary,
|
||||
AgentStatusData,
|
||||
AiAssistantMessage
|
||||
} from '../utils/types';
|
||||
|
||||
class AiAssistantApi extends BaseApiWithErrors {
|
||||
status: AgentStatusData | null = $state(null);
|
||||
conversations: AgentConversationSummary[] = $state([]);
|
||||
currentConversation: AgentConversation | null = $state(null);
|
||||
|
||||
async fetchStatus(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.status.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.status.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'fetching agent status',
|
||||
onSuccess: (result) => {
|
||||
this.status = result.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listConversations(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.history.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'listing conversations',
|
||||
onSuccess: (result) => {
|
||||
this.conversations = result.data as AgentConversationSummary[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadConversation(conversationId: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$get({
|
||||
param: { conversationId }
|
||||
}),
|
||||
logContext: 'loading conversation',
|
||||
onSuccess: (result) => {
|
||||
this.currentConversation = {
|
||||
...(result.data as AgentConversation),
|
||||
messages: ((result.data as AgentConversation).messages ?? []) as AiAssistantMessage[]
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createConversation(courseId: string, title?: string): Promise<{ id: string } | null> {
|
||||
let created: { id: string } | null = null;
|
||||
|
||||
await this.execute<typeof classroomio.agent.history.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history.$post({
|
||||
json: { courseId, title }
|
||||
}),
|
||||
logContext: 'creating conversation',
|
||||
onSuccess: (result) => {
|
||||
const newConversation = result.data as AgentConversationCreateData;
|
||||
created = newConversation;
|
||||
|
||||
this.conversations = [
|
||||
{ ...newConversation, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
...this.conversations
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async saveMessages(conversationId: string, messages: AiAssistantMessage[], title?: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$put({
|
||||
param: { conversationId },
|
||||
json: { messages, title }
|
||||
}),
|
||||
logContext: 'saving messages'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$delete({
|
||||
param: { conversationId }
|
||||
}),
|
||||
logContext: 'deleting conversation',
|
||||
onSuccess: () => {
|
||||
this.conversations = this.conversations.filter((c) => c.id !== conversationId);
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async uploadDocument(
|
||||
file: File,
|
||||
courseId: string,
|
||||
conversationId: string
|
||||
): Promise<{ documentId: string; fileName: string; wordCount: number; truncated: boolean } | null> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const url =
|
||||
`${getRequestBaseUrl()}/agent/upload` +
|
||||
`?courseId=${encodeURIComponent(courseId)}` +
|
||||
`&conversationId=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const response = await apiClient.request(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: { documentId: string; fileName: string; wordCount: number; truncated: boolean };
|
||||
};
|
||||
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading document:', error);
|
||||
this.error = 'Failed to upload document';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async generateTitle(conversationId: string, firstMessageText: string): Promise<string | null> {
|
||||
let generatedTitle: string | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['generate-title']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId']['generate-title'].$post({
|
||||
param: { conversationId },
|
||||
json: { firstMessageText }
|
||||
}),
|
||||
logContext: 'generating title',
|
||||
onSuccess: (result) => {
|
||||
generatedTitle = (result.data as { title: string }).title;
|
||||
|
||||
// Update the title in the local conversations list
|
||||
this.conversations = this.conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, title: generatedTitle } : c
|
||||
);
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = { ...this.currentConversation, title: generatedTitle };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return generatedTitle;
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAssistantApi = new AiAssistantApi();
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
import ChatHistoryPopover from '$features/ai-assistant/chat-history-popover.svelte';
|
||||
import { closeAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TokenUsage {
|
||||
used: number;
|
||||
allowance: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tokenUsage: TokenUsage | null;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
conversationTitle: string | null;
|
||||
isNewChatDisabled: boolean;
|
||||
onNewChat: () => void;
|
||||
onLoadConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tokenUsage,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
conversationTitle,
|
||||
isNewChatDisabled,
|
||||
onNewChat,
|
||||
onLoadConversation,
|
||||
onDeleteConversation
|
||||
}: Props = $props();
|
||||
|
||||
let historyPopoverOpen = $state(false);
|
||||
|
||||
function handleLoadConversation(id: string) {
|
||||
onLoadConversation(id);
|
||||
historyPopoverOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<SparklesIcon size={18} class="ui:text-primary shrink-0" />
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
onclick={onNewChat}
|
||||
disabled={isNewChatDisabled}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('ai_assistant.new_chat')}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<Popover.Root bind:open={historyPopoverOpen}>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="outline" size="icon-xs" tooltip={$t('ai_assistant.chat_history')}>
|
||||
<HistoryIcon size={16} />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="ui:p-0! w-72" align="end">
|
||||
<ChatHistoryPopover
|
||||
{conversations}
|
||||
{activeConversationId}
|
||||
onLoad={handleLoadConversation}
|
||||
onDelete={onDeleteConversation}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<IconButton onclick={closeAiAssistant} variant="outline" size="icon-xs">
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{#if tokenUsage && tokenUsage.allowance > 0}
|
||||
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))}
|
||||
<div class="mt-2">
|
||||
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
|
||||
<span
|
||||
>{tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()}
|
||||
{$t('ai_assistant.tokens_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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onLoad: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
let { conversations, activeConversationId, onLoad, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-b px-3 py-2">
|
||||
<p class="text-xs font-medium">{$t('ai_assistant.chat_history')}</p>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#if conversations.length === 0}
|
||||
<p class="ui:text-muted-foreground px-3 py-4 text-center text-xs">{$t('ai_assistant.no_conversations')}</p>
|
||||
{:else}
|
||||
{#each conversations as conversation (conversation.id)}
|
||||
<div
|
||||
class="group flex items-center gap-2 border-b px-3 py-2 last:border-b-0 {conversation.id ===
|
||||
activeConversationId
|
||||
? 'ui:bg-muted'
|
||||
: 'hover:ui:bg-muted/50'} transition-colors"
|
||||
>
|
||||
<button onclick={() => onLoad(conversation.id)} class="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||
<MessageSquareIcon size={14} class="ui:text-muted-foreground flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium">{conversation.title || $t('ai_assistant.untitled')}</p>
|
||||
<p class="ui:text-muted-foreground text-[10px]">
|
||||
{new Date(conversation.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => onDelete(conversation.id)}
|
||||
class="ui:text-muted-foreground flex-shrink-0 rounded p-1 opacity-0 transition-all group-hover:opacity-100 hover:text-red-500"
|
||||
>
|
||||
<TrashIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/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';
|
||||
import SquareIcon from '@lucide/svelte/icons/square';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import ModelPicker from '$features/ai-assistant/model-picker.svelte';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
inputValue: string;
|
||||
isStreaming: boolean;
|
||||
isExhausted: boolean;
|
||||
isUploading: boolean;
|
||||
error: Error | null | undefined;
|
||||
mentionItems: MentionItem[];
|
||||
uploadedDocument: UploadedDocument | null;
|
||||
selectedModel: AgentModelId;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onRemoveDocument: () => void;
|
||||
onSelectModel: (id: AgentModelId) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
inputValue = $bindable(),
|
||||
isStreaming,
|
||||
isExhausted,
|
||||
isUploading,
|
||||
error,
|
||||
mentionItems,
|
||||
uploadedDocument,
|
||||
selectedModel,
|
||||
onSend,
|
||||
onStop,
|
||||
onFileSelect,
|
||||
onRemoveDocument,
|
||||
onSelectModel
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputEl: HTMLInputElement | undefined = $state();
|
||||
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
|
||||
let wasBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const isBusy = isStreaming || isUploading;
|
||||
|
||||
if (isBusy) {
|
||||
wasBusy = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasBusy || isExhausted || !chatTextareaRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
wasBusy = false;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
chatTextareaRef?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
function handlePaperclipClick() {
|
||||
if ($isFreePlan) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
fileInputEl?.click();
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelect(file);
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(item: MentionItem) {
|
||||
if (item.type === 'EXERCISE') return t.get('ai_assistant.mention_exercise');
|
||||
if (item.type === 'SECTION') return t.get('ai_assistant.mention_section');
|
||||
|
||||
return t.get('ai_assistant.mention_lesson');
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.pptx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
class="hidden"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div class="border-t px-3 pt-3 pb-1.5">
|
||||
{#if isExhausted}
|
||||
<div
|
||||
class="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"
|
||||
>
|
||||
{$t('ai_assistant.tokens_exhausted')}
|
||||
</div>
|
||||
{:else}
|
||||
{#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')}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if isStreaming}
|
||||
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
||||
<SquareIcon size={12} />
|
||||
</Button>
|
||||
{:else}
|
||||
<ModelPicker value={selectedModel} disabled={isStreaming} onChange={onSelectModel} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatTextarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
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';
|
||||
|
||||
interface PlanExecutionState {
|
||||
title: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
isStopped: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: AiAssistantMessage[];
|
||||
status: string;
|
||||
isStreaming: boolean;
|
||||
courseId: string;
|
||||
planExecutionState: PlanExecutionState | null;
|
||||
quickActions: string[];
|
||||
onQuickAction: (action: string) => void;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onStop: () => void;
|
||||
onResume: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
messages,
|
||||
status,
|
||||
isStreaming,
|
||||
courseId,
|
||||
planExecutionState,
|
||||
quickActions,
|
||||
onQuickAction,
|
||||
onImplementPlan,
|
||||
onAskPlanChanges,
|
||||
onStop,
|
||||
onResume,
|
||||
onMentionClick
|
||||
}: Props = $props();
|
||||
|
||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||
let lastMessageCount = $state(0);
|
||||
let lastStepsCount = $state(0);
|
||||
let lastCurrentAction = $state('');
|
||||
|
||||
const isEmpty = $derived(messages.length === 0);
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer || messages.length === 0) return;
|
||||
|
||||
if (messages.length === lastMessageCount) return;
|
||||
|
||||
lastMessageCount = messages.length;
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer || !planExecutionState) return;
|
||||
|
||||
const stepsCount = planExecutionState.steps.length;
|
||||
if (stepsCount !== lastStepsCount) {
|
||||
lastStepsCount = stepsCount;
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const action = planExecutionState.currentAction ?? '';
|
||||
if (action !== lastCurrentAction) {
|
||||
lastCurrentAction = action;
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4" bind:this={messagesContainer}>
|
||||
{#if isEmpty}
|
||||
<!-- Empty state with quick actions -->
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4">
|
||||
<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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
{#each quickActions as action (action)}
|
||||
<button
|
||||
onclick={() => onQuickAction(action)}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Message list -->
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each messages as message (message.id)}
|
||||
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
|
||||
{/each}
|
||||
|
||||
{#if status === 'submitted' && isStreaming}
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planExecutionState}
|
||||
<div class="mt-2">
|
||||
<ProgressCard
|
||||
title={planExecutionState.title}
|
||||
steps={planExecutionState.steps}
|
||||
currentAction={planExecutionState.currentAction}
|
||||
onStop={isStreaming ? onStop : undefined}
|
||||
isStopped={planExecutionState.isStopped}
|
||||
/>
|
||||
{#if planExecutionState.isStopped}
|
||||
<Button size="sm" variant="outline" onclick={onResume} class="mt-2 w-full"
|
||||
>{$t('ai_assistant.resume')}</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
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';
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { MentionPopover, type MentionItem } from '@cio/ui/custom/mention-popover';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface ContentMentionItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: ContentMentionItem[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (item: ContentMentionItem) => void;
|
||||
}
|
||||
|
||||
let { items, query, selectedIndex, onSelect }: Props = $props();
|
||||
|
||||
const mentionItems: MentionItem[] = $derived(
|
||||
items.map((item) => ({ id: item.id, label: item.title, type: item.type }))
|
||||
);
|
||||
|
||||
function handleSelect(mentionItem: MentionItem) {
|
||||
const original = items.find((item) => item.id === mentionItem.id);
|
||||
|
||||
if (original) {
|
||||
onSelect(original);
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(item: MentionItem) {
|
||||
return item.type === 'EXERCISE' ? t.get('ai_assistant.mention_exercise') : t.get('ai_assistant.mention_lesson');
|
||||
}
|
||||
</script>
|
||||
|
||||
<MentionPopover
|
||||
items={mentionItems}
|
||||
{query}
|
||||
{selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
>
|
||||
{#snippet icon({ item })}
|
||||
{#if item.type === 'EXERCISE'}
|
||||
<ExerciseIcon size={14} />
|
||||
{:else}
|
||||
<LessonIcon size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MentionPopover>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
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 {
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
isAgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface Props {
|
||||
message: AiAssistantMessage;
|
||||
courseId: string;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props();
|
||||
|
||||
function handleBubbleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const mentionLink = target.closest('[data-mention-route]') as HTMLElement | null;
|
||||
|
||||
if (mentionLink) {
|
||||
event.preventDefault();
|
||||
const route = mentionLink.dataset.mentionRoute;
|
||||
|
||||
if (route) {
|
||||
onMentionClick(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
|
||||
|
||||
function isDeferredPlanPart(part: Record<string, unknown>) {
|
||||
if (!isAgentToolPart(part)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getAgentToolName(part) === 'generate_course_plan';
|
||||
}
|
||||
|
||||
function truncateErrorText(errorText: string): string {
|
||||
// Show just the summary before the raw Value JSON dump
|
||||
const valueIndex = errorText.indexOf(' Value: ');
|
||||
if (valueIndex !== -1) {
|
||||
return errorText.slice(0, valueIndex);
|
||||
}
|
||||
|
||||
return errorText.length > 120 ? errorText.slice(0, 120) + '…' : errorText;
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function getPartErrorText(part: unknown): string | undefined {
|
||||
return (part as { errorText?: string }).errorText;
|
||||
}
|
||||
|
||||
const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record<string, unknown>)));
|
||||
const deferredPlanParts = $derived(
|
||||
message.parts.filter((part) => isDeferredPlanPart(part as Record<string, unknown>))
|
||||
);
|
||||
</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'}
|
||||
<div
|
||||
class="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>
|
||||
{:else if isAgentToolPart(part)}
|
||||
{@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} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
|
||||
{:else}
|
||||
<div class="ui:bg-background/70 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}
|
||||
</div>
|
||||
{/if}
|
||||
{/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'}
|
||||
<div class="mt-3">
|
||||
<PlanView plan={toolResult} 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}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
:global([data-role='user'] a) {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:global(.mention-link:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { AGENT_MODELS, UI_PICKER_MODEL_IDS, type AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
interface Props {
|
||||
value: AgentModelId;
|
||||
disabled?: boolean;
|
||||
onChange: (id: AgentModelId) => void;
|
||||
}
|
||||
|
||||
let { value, disabled = false, onChange }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
function handleSelect(id: AgentModelId) {
|
||||
onChange(id);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
{disabled}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted ui:hover:text-foreground flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<span class="truncate">{AGENT_MODELS[value].label}</span>
|
||||
<ChevronDownIcon size={12} />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content class="ui:p-0! ui:z-[110] w-48" align="end" side="top">
|
||||
<div class="py-1">
|
||||
{#each UI_PICKER_MODEL_IDS as id (id)}
|
||||
{@const isSelected = id === value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(id)}
|
||||
class="hover:ui:bg-muted flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors"
|
||||
>
|
||||
<span class="min-w-0 truncate">{AGENT_MODELS[id].label}</span>
|
||||
{#if isSelected}
|
||||
<CheckIcon size={14} class="ui:text-primary shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/** Token cost estimation heuristics (tokens per item) */
|
||||
const TOKEN_COST_ESTIMATES = {
|
||||
SECTION_CREATE: 500,
|
||||
LESSON_CREATE_AND_CONTENT: 3000,
|
||||
EXERCISE_CREATE: 1500
|
||||
} as const;
|
||||
|
||||
interface PlanItem {
|
||||
type: 'lesson' | 'exercise';
|
||||
title: string;
|
||||
description: string;
|
||||
order: number;
|
||||
hasExercise: boolean;
|
||||
}
|
||||
|
||||
interface PlanSection {
|
||||
title: string;
|
||||
order: number;
|
||||
items: PlanItem[];
|
||||
}
|
||||
|
||||
interface CoursePlan {
|
||||
title: string;
|
||||
sections: PlanSection[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
plan: CoursePlan;
|
||||
onImplement: (editedPlan: CoursePlan) => void;
|
||||
onAskChanges: () => void;
|
||||
remainingTokens?: number;
|
||||
}
|
||||
|
||||
let { plan, onImplement, onAskChanges, remainingTokens }: Props = $props();
|
||||
|
||||
// CoursePlan is plain JSON; structuredClone fails when the AI SDK part carries
|
||||
// non-cloneable internals, so JSON round-trip is both safer and sufficient.
|
||||
function clonePlan(input: CoursePlan): CoursePlan {
|
||||
return JSON.parse(JSON.stringify(input)) as CoursePlan;
|
||||
}
|
||||
|
||||
// Local editable copy of the plan
|
||||
let editablePlan: CoursePlan = $state(clonePlan(plan));
|
||||
|
||||
// Reset editable plan when the prop changes (e.g., agent revises the plan)
|
||||
$effect(() => {
|
||||
editablePlan = clonePlan(plan);
|
||||
});
|
||||
|
||||
let expandedSections = new SvelteSet<number>();
|
||||
|
||||
$effect(() => {
|
||||
expandedSections.clear();
|
||||
|
||||
for (let i = 0; i < editablePlan.sections.length; i++) {
|
||||
expandedSections.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
// Track which field is being edited: "section-0-title", "lesson-1-2-title", "lesson-1-2-desc"
|
||||
let editingField: string | null = $state(null);
|
||||
|
||||
function toggleSection(index: number) {
|
||||
if (expandedSections.has(index)) {
|
||||
expandedSections.delete(index);
|
||||
} else {
|
||||
expandedSections.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(fieldKey: string) {
|
||||
editingField = fieldKey;
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingField = null;
|
||||
}
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
stopEditing();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
stopEditing();
|
||||
}
|
||||
}
|
||||
|
||||
const totalLessons = $derived(
|
||||
editablePlan.sections.reduce((sum, s) => sum + s.items.filter((i) => i.type === 'lesson').length, 0)
|
||||
);
|
||||
const totalExercises = $derived(
|
||||
editablePlan.sections.reduce(
|
||||
(sum, s) => sum + s.items.filter((i) => i.type === 'exercise' || i.hasExercise).length,
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
const estimatedTokens = $derived(
|
||||
editablePlan.sections.length * TOKEN_COST_ESTIMATES.SECTION_CREATE +
|
||||
totalLessons * TOKEN_COST_ESTIMATES.LESSON_CREATE_AND_CONTENT +
|
||||
totalExercises * TOKEN_COST_ESTIMATES.EXERCISE_CREATE
|
||||
);
|
||||
|
||||
const costPercentage = $derived(remainingTokens ? Math.round((estimatedTokens / remainingTokens) * 100) : null);
|
||||
const isHighCost = $derived(costPercentage !== null && costPercentage > 50);
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<!-- Plan header -->
|
||||
<div class="border-b px-3 py-2">
|
||||
{#if editingField === 'plan-title'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-sm font-semibold focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing('plan-title')}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left text-sm font-semibold"
|
||||
>
|
||||
{editablePlan.title}
|
||||
<PencilIcon size={10} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{editablePlan.sections.length} sections, {totalLessons} lessons{totalExercises > 0
|
||||
? `, ${totalExercises} exercises`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sections tree -->
|
||||
<div class="max-h-[300px] overflow-y-auto p-2">
|
||||
{#each editablePlan.sections as section, sectionIndex (sectionIndex)}
|
||||
<div class="mb-1">
|
||||
<div class="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium">
|
||||
<button onclick={() => toggleSection(sectionIndex)} class="flex-shrink-0">
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<ChevronDownIcon size={12} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if editingField === `section-${sectionIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary flex-1 rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`section-${sectionIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left"
|
||||
>
|
||||
Section {section.order}: {section.title}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<div class="ml-5 space-y-1">
|
||||
{#each section.items as item, itemIndex (item.order)}
|
||||
<div class="flex items-start gap-1.5 rounded px-2 py-1 text-xs">
|
||||
{#if item.type === 'exercise'}
|
||||
<FileQuestionIcon size={11} class="ui:text-primary mt-0.5 flex-shrink-0" />
|
||||
{:else}
|
||||
<BookOpenIcon size={11} class="ui:text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-xs font-medium focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left font-medium"
|
||||
>
|
||||
{#if item.type === 'exercise'}
|
||||
<span class="ui:text-primary">{item.title}</span>
|
||||
{:else}
|
||||
{item.title}
|
||||
{/if}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-desc`}
|
||||
<textarea
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].description}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
rows={2}
|
||||
class="ui:bg-muted/50 ui:text-muted-foreground focus:ui:ring-primary mt-0.5 w-full rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
></textarea>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-desc`)}
|
||||
class="group ui:text-muted-foreground hover:ui:text-foreground mt-0.5 flex items-start gap-1 text-left"
|
||||
>
|
||||
<span class="line-clamp-2">{item.description}</span>
|
||||
<PencilIcon size={9} class="mt-0.5 flex-shrink-0 opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if item.type === 'lesson' && item.hasExercise}
|
||||
<div class="ui:text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<FileQuestionIcon size={10} />
|
||||
<span>Exercise included</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cost estimate + actions -->
|
||||
<div class="space-y-2 border-t px-3 py-2">
|
||||
{#if estimatedTokens > 0}
|
||||
<p class="text-xs {isHighCost ? 'text-amber-600 dark:text-amber-400' : 'ui:text-muted-foreground'}">
|
||||
Estimated cost: ~{estimatedTokens.toLocaleString()} tokens
|
||||
{#if costPercentage !== null}
|
||||
({costPercentage}% of remaining)
|
||||
{/if}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
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 { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface ProgressStep {
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
onStop?: () => void;
|
||||
isStopped?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { title, steps, currentAction, onStop, isStopped = false, error }: Props = $props();
|
||||
|
||||
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
|
||||
const totalCount = $derived(steps.length);
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<div class="border-b px-3 py-2">
|
||||
<h4 class="text-sm font-semibold">{title}</h4>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{completedCount} / {totalCount} steps completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
{#each steps as step, i (`${i}-${step.label}`)}
|
||||
<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" />
|
||||
{:else if step.status === 'in_progress'}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
{:else if step.status === 'failed'}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 border-t px-3 py-2">
|
||||
{#if error}
|
||||
<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}
|
||||
|
||||
{#if isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('ai_assistant.stopped_content_kept')}</p>
|
||||
{/if}
|
||||
|
||||
{#if onStop && !isStopped && completedCount < totalCount}
|
||||
<Button size="sm" variant="outline" onclick={onStop} class="w-full">
|
||||
<SquareIcon size={12} class="mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<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';
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
state: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
let { toolName, state, result }: 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>
|
||||
{:else if state === 'result'}
|
||||
<CheckIcon size={12} class="ui:text-primary" />
|
||||
<span>
|
||||
{getToolResultLabel(toolName, result)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const AI_COURSE_CHAT_DEFAULT_WIDTH = 360;
|
||||
export const AI_COURSE_CHAT_MIN_WIDTH = 280;
|
||||
export const AI_COURSE_CHAT_MAX_WIDTH = 520;
|
||||
export const AI_COURSE_CHAT_STORAGE_KEY = 'classroomio-ai-course-chat-width';
|
||||
export const AI_CHAT_MODEL_STORAGE_KEY = 'classroomio-ai-chat-model';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
// Configure marked for safe, minimal output
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert markdown text to sanitized HTML for rendering in the chat.
|
||||
* Agent responses come as markdown — this makes them render properly.
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
return marked.parse(text, { async: false }) as string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
type MentionType = 'lesson' | 'exercise' | 'section';
|
||||
|
||||
/**
|
||||
* Mention format: @[Title](type:id)
|
||||
* Where type is "lesson", "exercise", or "section" and id is the content item ID.
|
||||
*/
|
||||
const RAW_MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise|section):([a-zA-Z0-9_-]+)\)/gi;
|
||||
const HTML_LINK_MENTION_REGEX = /@?<a\b[^>]*href=(["'])(lesson|exercise|section):([a-zA-Z0-9_-]+)\1[^>]*>(.*?)<\/a>/gi;
|
||||
|
||||
function normalizeMentionType(type: string): MentionType | null {
|
||||
const normalizedType = type.toLowerCase();
|
||||
|
||||
if (normalizedType === 'lesson' || normalizedType === 'exercise' || normalizedType === 'section') {
|
||||
return normalizedType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMentionRoute(courseId: string, type: MentionType, id: string) {
|
||||
if (type === 'exercise') return `/courses/${courseId}/exercises/${id}`;
|
||||
if (type === 'section') return `/courses/${courseId}/lessons#section-${id}`;
|
||||
|
||||
return `/courses/${courseId}/lessons/${id}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function buildMentionLink(route: string, label: string, title: string) {
|
||||
return `<a href="${route}" data-mention-route="${route}" class="mention-link" title="${escapeHtml(title)}">${label}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace AI lesson/exercise mentions with real in-app links.
|
||||
* Handles both raw mention syntax and markdown-generated anchor tags.
|
||||
*/
|
||||
export function renderMentions(html: string, courseId: string): string {
|
||||
const linksWithRoutes = html.replace(
|
||||
HTML_LINK_MENTION_REGEX,
|
||||
(match, _quote: string, type: string, id: string, label: string) => {
|
||||
const mentionType = normalizeMentionType(type);
|
||||
|
||||
if (!mentionType) return match;
|
||||
|
||||
const route = getMentionRoute(courseId, mentionType, id);
|
||||
return buildMentionLink(route, label, `${mentionType}: ${id}`);
|
||||
}
|
||||
);
|
||||
|
||||
return linksWithRoutes.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
|
||||
const mentionType = normalizeMentionType(type);
|
||||
|
||||
if (!mentionType) return match;
|
||||
|
||||
const route = getMentionRoute(courseId, mentionType, id);
|
||||
return buildMentionLink(route, escapeHtml(title), `${mentionType}: ${title}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/** Controls whether the AI assistant right sidebar Sheet is open. */
|
||||
export const isAiAssistantOpen = writable(false);
|
||||
|
||||
export function openAiAssistant() {
|
||||
isAiAssistantOpen.set(true);
|
||||
}
|
||||
|
||||
export function closeAiAssistant() {
|
||||
isAiAssistantOpen.set(false);
|
||||
}
|
||||
|
||||
export function toggleAiAssistant() {
|
||||
isAiAssistantOpen.update((open) => !open);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_course_structure: 'Reading course structure',
|
||||
get_lesson_content: 'Reading lesson content',
|
||||
get_exercise_details: 'Reading exercise',
|
||||
create_section: 'Creating section',
|
||||
update_section: 'Updating section',
|
||||
create_lesson: 'Creating lesson',
|
||||
update_lesson: 'Updating lesson',
|
||||
update_lesson_content: 'Writing lesson content',
|
||||
create_exercise: 'Creating exercise',
|
||||
update_exercise: 'Updating exercise',
|
||||
add_questions: 'Adding questions',
|
||||
update_questions: 'Updating questions',
|
||||
reorder_content: 'Reordering content',
|
||||
update_course_landing_page: 'Updating landing page',
|
||||
check_course_go_live_readiness: 'Checking go-live readiness',
|
||||
go_live_course: 'Publishing course',
|
||||
generate_course_plan: 'Generating course plan'
|
||||
};
|
||||
|
||||
export function getToolLabel(toolName: string): string {
|
||||
return TOOL_LABELS[toolName] || toolName;
|
||||
}
|
||||
|
||||
export function getToolResultLabel(toolName: string, result: unknown): string {
|
||||
const r = result as Record<string, unknown>;
|
||||
|
||||
switch (toolName) {
|
||||
case 'create_section':
|
||||
return `Created section: ${r.title}`;
|
||||
case 'update_section':
|
||||
return `Updated section: ${r.title}`;
|
||||
case 'create_lesson':
|
||||
return `Created lesson: ${r.title}`;
|
||||
case 'update_lesson':
|
||||
return `Updated lesson: ${r.title}`;
|
||||
case 'update_lesson_content':
|
||||
return `Wrote ${r.contentLength} chars`;
|
||||
case 'create_exercise':
|
||||
return `Created exercise with ${r.questionCount} questions`;
|
||||
case 'update_exercise':
|
||||
return `Updated exercise: ${r.title}`;
|
||||
case 'add_questions':
|
||||
return `Added ${r.addedCount} questions`;
|
||||
case 'update_questions':
|
||||
return `Updated ${r.updatedCount} questions`;
|
||||
case 'get_course_structure':
|
||||
return 'Course structure loaded';
|
||||
case 'get_lesson_content':
|
||||
return 'Lesson content loaded';
|
||||
case 'get_exercise_details':
|
||||
return 'Exercise loaded';
|
||||
case 'reorder_content':
|
||||
return 'Content reordered';
|
||||
case 'update_course_landing_page':
|
||||
return `Updated landing page: ${r.title}`;
|
||||
case 'check_course_go_live_readiness': {
|
||||
const blockerCount = Array.isArray(r.blockers) ? r.blockers.length : 0;
|
||||
const warningCount = Array.isArray(r.warnings) ? r.warnings.length : 0;
|
||||
|
||||
return blockerCount === 0
|
||||
? `Ready to go live${warningCount > 0 ? ` with ${warningCount} warnings` : ''}`
|
||||
: `${blockerCount} blockers before go-live`;
|
||||
}
|
||||
case 'go_live_course':
|
||||
return r.isPublished ? `Published course: ${r.title}` : 'Course was not published';
|
||||
case 'generate_course_plan':
|
||||
return 'Course plan generated';
|
||||
default:
|
||||
return 'Done';
|
||||
}
|
||||
}
|
||||
|
||||
export const MUTATION_TOOLS = [
|
||||
'create_section',
|
||||
'update_section',
|
||||
'create_lesson',
|
||||
'update_lesson',
|
||||
'update_lesson_content',
|
||||
'create_exercise',
|
||||
'update_exercise',
|
||||
'add_questions',
|
||||
'update_questions',
|
||||
'reorder_content',
|
||||
'update_course_landing_page',
|
||||
'go_live_course'
|
||||
];
|
||||
|
||||
export interface ProgressStep {
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
indent?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ProgressStep } from './tool-labels';
|
||||
|
||||
export interface AgentToolPart {
|
||||
type: string;
|
||||
state?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
toolInvocation?: {
|
||||
toolName?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function isAgentToolPart(part: Record<string, unknown>): part is AgentToolPart {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof part.type === 'string' && part.type.startsWith('tool-');
|
||||
}
|
||||
|
||||
export function getAgentToolName(part: AgentToolPart): string | null {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return part.toolInvocation?.toolName ?? null;
|
||||
}
|
||||
|
||||
if (!part.type.startsWith('tool-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return part.type.slice(5);
|
||||
}
|
||||
|
||||
export function getAgentToolResult(part: AgentToolPart): unknown {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return part.toolInvocation?.result;
|
||||
}
|
||||
|
||||
return part.output;
|
||||
}
|
||||
|
||||
export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] {
|
||||
if (part.state === 'result' || part.state === 'output-available') {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (part.state === 'output-error') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if (
|
||||
part.state === 'call' ||
|
||||
part.state === 'partial-call' ||
|
||||
part.state === 'input-streaming' ||
|
||||
part.state === 'input-available'
|
||||
) {
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { UIMessage } from 'ai';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export interface UploadedDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageAttachment {
|
||||
documentId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageMetadata {
|
||||
attachment?: AiAssistantMessageAttachment;
|
||||
}
|
||||
|
||||
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
|
||||
|
||||
export type AgentStatusRequest = typeof classroomio.agent.status.$get;
|
||||
export type AgentStatusSuccess = Extract<InferResponseType<AgentStatusRequest>, { success: true }>;
|
||||
export type AgentStatusData = AgentStatusSuccess['data'];
|
||||
|
||||
export type AgentUsageRequest = typeof classroomio.agent.usage.$get;
|
||||
export type AgentUsageSuccess = Extract<InferResponseType<AgentUsageRequest>, { success: true }>;
|
||||
export type AgentUsageData = AgentUsageSuccess['data'];
|
||||
|
||||
export type AgentHistoryGetRequest = typeof classroomio.agent.history.$get;
|
||||
export type AgentHistoryGetSuccess = Extract<InferResponseType<AgentHistoryGetRequest>, { success: true }>;
|
||||
export type AgentHistoryData = AgentHistoryGetSuccess['data'];
|
||||
|
||||
export type AgentConversationSummary = AgentHistoryData[number];
|
||||
|
||||
export type AgentConversationRequest = (typeof classroomio.agent.history)[':conversationId']['$get'];
|
||||
export type AgentConversationSuccess = Extract<InferResponseType<AgentConversationRequest>, { success: true }>;
|
||||
export type AgentConversation = Omit<AgentConversationSuccess['data'], 'messages'> & {
|
||||
messages: AiAssistantMessage[];
|
||||
};
|
||||
|
||||
export type AgentConversationCreateRequest = typeof classroomio.agent.history.$post;
|
||||
export type AgentConversationCreateSuccess = Extract<
|
||||
InferResponseType<AgentConversationCreateRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type AgentConversationCreateData = AgentConversationCreateSuccess['data'];
|
||||
|
||||
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
|
||||
|
||||
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
|
||||
Reference in New Issue
Block a user