Files
classroomio/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte
T

289 lines
10 KiB
Svelte

<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 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) */
const TOKEN_COST_ESTIMATES = {
SECTION_CREATE: 500,
LESSON_CREATE_AND_CONTENT: 3000,
EXERCISE_CREATE: 1500
} as const;
interface Props {
plan: CoursePlan;
onImplement: (editedPlan: CoursePlan) => 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, 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.
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 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>