feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# Chat-mode vs Run-mode — where the split lives and why it's fragile
|
||||
|
||||
The AI assistant has two execution modes that share most of their plumbing. This doc enumerates exactly where each mode is decided, surfaced, and routed, and the three places the split is currently load-bearing on heuristics rather than enforcement.
|
||||
|
||||
## Mode definitions
|
||||
|
||||
- **Chat mode** — interactive, request/response. The user types, the API streams an assistant turn, and tool calls execute _inline inside that turn_. No `agent_run` row. Lives in the foreground HTTP process. Bounded by AI SDK `stopWhen` (currently `stepCountIs(...)`) and the user's session.
|
||||
- **Run mode** — durable background execution. The user (or the agent on the user's behalf) creates an `agent_run` row; a BullMQ job is enqueued; the worker process drives the same tools to completion across many rounds. Pollable. Cancellable. Resumable.
|
||||
|
||||
Both modes call the **same tool implementations** (`packages/core/src/services/agent/chat-tools.ts`) against the **same schemas** (`agent-tool-schemas.ts`). The only difference is _who is driving the loop and where_.
|
||||
|
||||
## Where the split is decided
|
||||
|
||||
### 1. Trigger — only one path creates a run
|
||||
|
||||
- `apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte:617` — `handleImplementPlan(editedPlan)`. Called when the user clicks **Implement Plan** in the plan-view card.
|
||||
- `apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts:291` — `aiAssistantApi.createRun(...)` posts to the API.
|
||||
- `apps/api/src/routes/agent/runs.ts:41` — `POST /agent/runs` inserts the row and enqueues a BullMQ job.
|
||||
|
||||
Everything else — every other user message, every tool call the model makes during a chat turn — is **chat mode by default**. There is no second trigger.
|
||||
|
||||
### 2. Worker — what actually executes a run
|
||||
|
||||
- `apps/jobs/src/workers/agent-course-generation.ts` — the BullMQ worker that pulls run jobs.
|
||||
- `packages/core/src/services/agent/course-generation-runner.ts` — `runAgentCourseGenerationJob(...)`, the loop the worker invokes. Streams tool calls, persists steps, respects cancel/pause.
|
||||
|
||||
### 3. Chat — the other endpoint
|
||||
|
||||
- `apps/api/src/routes/agent/agent.ts` — the `/agent/chat` route. Streams an assistant turn via the AI SDK with the same tool set bound. This is where every chat-mode tool call actually fires.
|
||||
|
||||
### 4. UI mode switching
|
||||
|
||||
- `ai-course-chat.svelte:97-103` — the status sets that classify a run:
|
||||
- `RUN_MODE_STATUSES` — `queued|running|waiting_for_input|paused|failed|canceled`. Any of these keeps the panel in "running" mode.
|
||||
- `RUN_RESUMABLE_STATUSES` — `paused|waiting_for_input|canceled`. Controls the Resume button.
|
||||
- `INJECTABLE_RUN_STATUSES` — `completed`. The only status that auto-injects a synthetic summary message.
|
||||
- `ai-course-chat.svelte:898-903` — `panelMode` derivation. Reads `aiAssistantApi.currentRun.status` and flips the bottom of the chat between `RunMonitorPane` and `ChatInput`.
|
||||
- `ai-course-chat.svelte:1320-1346` — render switch.
|
||||
|
||||
### 5. Prompt — the _de facto_ routing logic
|
||||
|
||||
- `packages/ai-assistant/src/prompt/teacher.ts:55-104` — Plan Mode vs Agent Mode, plus "Driving an approved plan to completion" and "Re-engaging a course where an approved plan exists".
|
||||
|
||||
This file is where the model is _told_ to use `generate_course_plan` for big work and inline tools for small edits. **It is the only thing keeping the model from doing everything inline in chat.** No code rejects a chat-mode call to a "heavy" tool.
|
||||
|
||||
## Why the split is fragile
|
||||
|
||||
### 1. Mode routing is LLM-decided, not enforced
|
||||
|
||||
The same tool set is bound in both modes. Whether `update_lesson_content` for ten lessons runs inline (chat) or via a plan→run is decided entirely by how the model interprets the system prompt. Drift in the prompt, a new model with different defaults, or a user prompt that subtly bypasses the plan flow — any of these route heavy work into chat mode where there's no retry, no resume, no progress card, and no cancel button.
|
||||
|
||||
**Observed:** a transcript where the agent updated three lessons inline across one streamed turn. None of it ran through the durable runner. The user only realized when they noticed there was no run history entry.
|
||||
|
||||
### 2. Run can stick at `queued`
|
||||
|
||||
Run creation enqueues to BullMQ. If the worker can't pick the job up (worker crashed, Redis blip, module-resolution boot failure, schema mismatch on payload), the run sits at `queued` indefinitely. The frontend polls run status but has no "worker hasn't moved past queued in N seconds" guardrail. The user sees a stalled progress card with no error.
|
||||
|
||||
**Observed:** `ERR_MODULE_NOT_FOUND` from a cross-app import made the worker boot fail silently from the user's perspective. Cause was only visible in worker logs.
|
||||
|
||||
### 3. Resume/Dismiss/chat boundary
|
||||
|
||||
Canceled run → user clicks Dismiss → `aiAssistantApi.currentRun = null` → `panelMode` flips to `planning` → chat input returns. From here the user can type anything, and the model handles it as a fresh chat turn — including content edits — without the prompt-level "this was canceled, reconcile first" hint being load-bearing.
|
||||
|
||||
We mitigate (3) at the prompt level only:
|
||||
|
||||
- `chat-history.ts:TOOLS_WITH_FULL_OUTPUT` now includes `get_course_structure` and `get_exercise_details` so the model can see what was already created.
|
||||
- `teacher.ts` has the "Re-engaging a course where an approved plan exists" rule telling the model to call `get_course_structure` first.
|
||||
|
||||
Both are prompt-trust mitigations. Neither is enforcement.
|
||||
|
||||
## Tools that are shared between modes
|
||||
|
||||
Every tool defined in `chat-tools.ts` is callable from both endpoints. The notable mutation-heavy ones:
|
||||
|
||||
- `update_lesson_content` — writes full lesson HTML. A single call can be 5–20k tokens of output.
|
||||
- `add_questions` / `update_questions` — can add/update arbitrary-sized question batches in one call.
|
||||
- `create_lesson`, `update_lesson`, `create_exercise`, `update_exercise`.
|
||||
- `update_course_landing_page` — full landing-page mutation, including image generation.
|
||||
- `go_live_course` — publishes the course.
|
||||
|
||||
Reads (`get_course_structure`, `get_lesson_content`, `get_exercise_details`) are also shared and have no execution-cost concern.
|
||||
|
||||
## Recommended hardening (not implemented)
|
||||
|
||||
Three bite-sized steps that move the routing decision from prompt-land into code-land:
|
||||
|
||||
1. **Gate heavy tools to run-mode at the API layer.** Whitelist a small set of cheap chat-mode tools; reject the rest from `/agent/chat` with a structured error that the model can read and translate into "I need to make a plan for this." This stops mode drift.
|
||||
2. **Surface run health.** If `currentRun.status === 'queued'` for >30s with no `lastHeartbeat`, render a "Worker not responding" affordance with a retry button. Stops silent stalls.
|
||||
3. **Tighten the chat-after-cancel boundary.** Either keep the canceled-run pane visible alongside the chat input (so Resume is always one click away) or disable chat input until the user explicitly Dismisses. Removes the "type continue and hope the prompt does the right thing" failure mode.
|
||||
|
||||
None of these unify the two modes — that's a multi-month rewrite. They just stop the LLM and the worker queue from being single points of failure for routing.
|
||||
@@ -4,9 +4,19 @@
|
||||
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 RunMonitorPane from '$features/ai-assistant/run-monitor-pane.svelte';
|
||||
import ChatChangesCard from '$features/ai-assistant/chat-changes-card.svelte';
|
||||
import {
|
||||
buildRunSummaryMetadata,
|
||||
extractChangedItems,
|
||||
extractChangedItemsFromMessages,
|
||||
formatRunSummaryCounts,
|
||||
type ExerciseTitleLookup
|
||||
} from '$features/ai-assistant/utils/run-summary';
|
||||
import type { AiAssistantRunSummaryMetadata, RunChangedItem } from '$features/ai-assistant/utils/types';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolInput,
|
||||
getAgentToolName,
|
||||
@@ -43,6 +53,7 @@
|
||||
AiAssistantMessage,
|
||||
AiAssistantMessageMetadata,
|
||||
AiAssistantTemplateMetadata,
|
||||
AgentRunStep,
|
||||
UploadedDocument
|
||||
} from '$features/ai-assistant/utils/types';
|
||||
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
|
||||
@@ -66,7 +77,30 @@
|
||||
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
|
||||
let lastSeenStreamingFlag = false;
|
||||
|
||||
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
|
||||
/** Tracks the active durable run so we reset thresholds when a new run starts. */
|
||||
let lastSeenDurableRunId = '';
|
||||
/** Last step count seen for the active durable run; lets us skip recompute on no-op polls. */
|
||||
let lastSeenDurableStepCount = 0;
|
||||
/** Run ids we've already injected a synthetic summary message for in this session. */
|
||||
let lastInjectedRunSummaryId = '';
|
||||
/**
|
||||
* Statuses that warrant injecting a synthetic summary message into the
|
||||
* conversation. Only `completed` is auto-injected — canceled runs are
|
||||
* resumable, so the summary is deferred until the user explicitly
|
||||
* dismisses the canceled run.
|
||||
*/
|
||||
const INJECTABLE_RUN_STATUSES = new Set(['completed']);
|
||||
/**
|
||||
* Statuses that keep the panel in run-mode (composer hidden). `canceled`
|
||||
* stays here so the user can either Resume or Dismiss — only `completed`
|
||||
* unconditionally flips back to planning.
|
||||
*/
|
||||
const RUN_MODE_STATUSES = new Set(['queued', 'running', 'waiting_for_input', 'paused', 'failed', 'canceled']);
|
||||
|
||||
const RUN_POLL_INTERVAL_MS = 3_000;
|
||||
const RUN_POLL_STATUSES = new Set(['queued', 'running', 'waiting_for_input']);
|
||||
const RUN_RESUMABLE_STATUSES = new Set(['paused', 'waiting_for_input', 'canceled']);
|
||||
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'paused']);
|
||||
|
||||
// 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.
|
||||
@@ -84,7 +118,10 @@
|
||||
|
||||
let statusFetchedForCourseId: string | null = $state(null);
|
||||
let conversationsLoadedForCourseId: string | null = $state(null);
|
||||
let runsLoadedForCourseId: string | null = $state(null);
|
||||
let lastRunStatusSig = $state('');
|
||||
let activeConversationId: string | null = $state(null);
|
||||
let isLoadingHistory = $state(false);
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||
|
||||
@@ -153,24 +190,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
let pendingLoadId: string | null = null;
|
||||
|
||||
async function loadConversation(conversationId: string) {
|
||||
if (!courseId) return;
|
||||
|
||||
pendingLoadId = conversationId;
|
||||
await aiAssistantApi.loadConversation(conversationId);
|
||||
|
||||
// Another switch was requested while this one was in flight — drop this result.
|
||||
if (pendingLoadId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = aiAssistantApi.currentConversation;
|
||||
|
||||
if (conversation) {
|
||||
const loadedMessages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
// Only overwrite in-memory messages if the loaded conversation actually has saved
|
||||
// messages. An empty result means the conversation was just created and the first
|
||||
// message hasn't been persisted yet — overwriting would wipe the optimistic message
|
||||
// that chat.sendMessage already placed in the UI.
|
||||
if (loadedMessages.length > 0) {
|
||||
chat.messages = loadedMessages;
|
||||
}
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chat.status === 'streaming' || chat.status === 'submitted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedMessages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
|
||||
// Only overwrite in-memory messages if the loaded conversation actually has saved
|
||||
// messages. An empty result means the conversation was just created and the first
|
||||
// message hasn't been persisted yet — overwriting would wipe the optimistic message
|
||||
// that chat.sendMessage already placed in the UI.
|
||||
if (loadedMessages.length > 0) {
|
||||
chat.messages = loadedMessages;
|
||||
}
|
||||
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
@@ -232,20 +285,41 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Content Ask AI bar (and org home) set this before opening the panel; loading a
|
||||
// saved conversation would race handleSend and wipe the optimistic user message.
|
||||
const pendingPrompt = get(initialChatPrompt);
|
||||
|
||||
if (pendingPrompt?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listForCourseId = courseId;
|
||||
const savedId = getActiveConversationId(listForCourseId);
|
||||
|
||||
aiAssistantApi.listConversations(listForCourseId).then(() => {
|
||||
if (courseId !== listForCourseId) return;
|
||||
isLoadingHistory = true;
|
||||
aiAssistantApi
|
||||
.listConversations(listForCourseId)
|
||||
.then(async () => {
|
||||
if (courseId !== listForCourseId) return;
|
||||
if (activeConversationId) return;
|
||||
if (chat.status === 'streaming' || chat.status === 'submitted') return;
|
||||
|
||||
if (activeConversationId) return;
|
||||
if (savedId) {
|
||||
await loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
await loadConversation(aiAssistantApi.conversations[0].id);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (courseId === listForCourseId) {
|
||||
isLoadingHistory = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (savedId) {
|
||||
loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
loadConversation(aiAssistantApi.conversations[0].id);
|
||||
}
|
||||
});
|
||||
if (runsLoadedForCourseId !== courseId) {
|
||||
runsLoadedForCourseId = courseId;
|
||||
void aiAssistantApi.loadActiveRun(courseId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -503,26 +577,146 @@
|
||||
chat.stop();
|
||||
}
|
||||
|
||||
function isRunActive(status: string | undefined): boolean {
|
||||
return status === 'queued' || status === 'running' || status === 'waiting_for_input';
|
||||
}
|
||||
|
||||
function getRunToolName(step: AgentRunStep): string | null {
|
||||
if (step.stepType.startsWith('tool:')) {
|
||||
return step.stepType.slice('tool:'.length);
|
||||
}
|
||||
|
||||
const toolName = step.input?.toolName;
|
||||
|
||||
return typeof toolName === 'string' && toolName.length > 0 ? toolName : null;
|
||||
}
|
||||
|
||||
function getRunStepInput(step: AgentRunStep): unknown {
|
||||
const args = step.input?.args;
|
||||
|
||||
return args ?? step.input ?? undefined;
|
||||
}
|
||||
|
||||
function getRunStepResult(step: AgentRunStep): unknown {
|
||||
const output = step.output;
|
||||
|
||||
if (!output || typeof output !== 'object') {
|
||||
return output ?? undefined;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(output, 'result')) {
|
||||
return (output as Record<string, unknown>).result;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function getRunProgressStatus(status: string): ProgressStep['status'] {
|
||||
if (status === 'completed') return 'completed';
|
||||
if (status === 'running') return 'in_progress';
|
||||
if (status === 'failed' || status === 'canceled') return 'failed';
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function toRunProgressStep(step: AgentRunStep): ProgressStep {
|
||||
const toolName = getRunToolName(step);
|
||||
const status = getRunProgressStatus(step.status);
|
||||
|
||||
if (!toolName) {
|
||||
return {
|
||||
status,
|
||||
line: { shape: 'i18n', key: 'ai_assistant.run_checkpoint' }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
line:
|
||||
status === 'completed'
|
||||
? getCompletedToolLine(toolName, getRunStepResult(step))
|
||||
: getPendingToolLine(toolName, getRunStepInput(step))
|
||||
};
|
||||
}
|
||||
|
||||
async function handleImplementPlan(editedPlan: unknown) {
|
||||
if (chat.status === 'streaming') return;
|
||||
if (!courseId) return;
|
||||
|
||||
const existingRunStatus = aiAssistantApi.currentRun?.run.status;
|
||||
if (isRunActive(existingRunStatus)) return;
|
||||
|
||||
const conversationId = await ensureActiveConversation(courseId);
|
||||
if (!conversationId) return;
|
||||
if (!editedPlan || typeof editedPlan !== 'object') return;
|
||||
|
||||
inputValue = '';
|
||||
await persistFinishedChat(chat.messages as AiAssistantMessage[], conversationId);
|
||||
|
||||
chat.sendMessage({
|
||||
text: 'Implement this plan.',
|
||||
metadata: {
|
||||
plan: {
|
||||
action: 'implement_course_plan',
|
||||
payload: editedPlan
|
||||
}
|
||||
}
|
||||
await aiAssistantApi.createRun({
|
||||
courseId,
|
||||
conversationId,
|
||||
approvedPlan: editedPlan as Record<string, unknown>,
|
||||
model: selectedModel
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCancelRun() {
|
||||
const runId = aiAssistantApi.currentRun?.run.id;
|
||||
if (!runId) return;
|
||||
|
||||
await aiAssistantApi.cancelRun(runId);
|
||||
refreshCourseStateAfterChat();
|
||||
}
|
||||
|
||||
async function handleRetryRun() {
|
||||
const runId = aiAssistantApi.currentRun?.run.id;
|
||||
if (!runId) return;
|
||||
|
||||
await aiAssistantApi.retryRun(runId);
|
||||
}
|
||||
|
||||
async function handleResumeRun() {
|
||||
const runId = aiAssistantApi.currentRun?.run.id;
|
||||
if (!runId) return;
|
||||
|
||||
await aiAssistantApi.resumeRun(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk away from a canceled run: inject the run-summary message into the
|
||||
* conversation (the same one we used to auto-write on cancel) and clear
|
||||
* the local currentRun so the panel flips back to planning. The run row
|
||||
* itself stays in the DB — user could still find it in run history.
|
||||
*/
|
||||
async function handleDismissRun() {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (!runDetail || !activeConversationId) return;
|
||||
if (runDetail.run.conversationId !== activeConversationId) return;
|
||||
|
||||
const summaryMeta = buildRunSummaryMetadata({ runDetail, resolveExerciseTitle });
|
||||
if (summaryMeta) {
|
||||
const alreadyInConversation = (chat.messages as AiAssistantMessage[]).some(
|
||||
(msg) => (msg.metadata as AiAssistantMessageMetadata | undefined)?.runSummary?.runId === summaryMeta.runId
|
||||
);
|
||||
|
||||
if (!alreadyInConversation) {
|
||||
const summaryMessage: AiAssistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: buildRunSummaryText(summaryMeta) }],
|
||||
metadata: { runSummary: summaryMeta } satisfies AiAssistantMessageMetadata
|
||||
};
|
||||
|
||||
chat.messages = [...(chat.messages as AiAssistantMessage[]), summaryMessage];
|
||||
lastInjectedRunSummaryId = summaryMeta.runId;
|
||||
void aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
|
||||
}
|
||||
}
|
||||
|
||||
aiAssistantApi.currentRun = null;
|
||||
}
|
||||
|
||||
async function handleAskPlanChanges(message: string) {
|
||||
const trimmed = message.trim();
|
||||
|
||||
@@ -535,11 +729,6 @@
|
||||
chat.sendMessage({ text: trimmed });
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
|
||||
void handleSend();
|
||||
}
|
||||
|
||||
function handleMentionClick(route: string) {
|
||||
goto(resolve(route, {}));
|
||||
}
|
||||
@@ -686,6 +875,263 @@
|
||||
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
|
||||
});
|
||||
|
||||
const durableRunState = $derived.by(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
|
||||
if (!runDetail || runDetail.run.courseId !== courseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const run = runDetail.run;
|
||||
const steps = runDetail.steps.map(toRunProgressStep);
|
||||
|
||||
const titleKey =
|
||||
run.status === 'completed'
|
||||
? 'ai_assistant.run_completed'
|
||||
: run.status === 'failed'
|
||||
? 'ai_assistant.run_failed'
|
||||
: run.status === 'canceled'
|
||||
? 'ai_assistant.run_canceled'
|
||||
: 'ai_assistant.run_running';
|
||||
|
||||
const waitingLine: ToolLineUi | undefined =
|
||||
steps.length === 0 && run.status !== 'failed' && run.status !== 'canceled'
|
||||
? { shape: 'i18n' as const, key: 'ai_assistant.run_waiting_for_worker' }
|
||||
: undefined;
|
||||
const currentActionLine = steps.find((step) => step.status === 'in_progress')?.line ?? waitingLine;
|
||||
|
||||
return {
|
||||
titleKey,
|
||||
steps,
|
||||
currentActionLine,
|
||||
isStopped: run.status === 'failed' || run.status === 'canceled' || run.status === 'paused',
|
||||
error: run.lastError?.message,
|
||||
canCancel: run.status === 'queued' || run.status === 'running' || run.status === 'waiting_for_input',
|
||||
canRetry: run.status === 'failed',
|
||||
canResume: RUN_RESUMABLE_STATUSES.has(run.status)
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Decides what the bottom of the chat panel is showing: the composer
|
||||
* (`planning`) or the durable run monitor (`running`). The run must belong
|
||||
* to the active conversation — a paused run on a different conversation
|
||||
* should not hijack this one. Completed/canceled runs flip back to
|
||||
* planning even if `currentRun` is still in memory.
|
||||
*/
|
||||
const panelMode = $derived.by<'planning' | 'running'>(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (!runDetail) return 'planning';
|
||||
if (runDetail.run.courseId !== courseId) return 'planning';
|
||||
if (activeConversationId && runDetail.run.conversationId !== activeConversationId) return 'planning';
|
||||
return RUN_MODE_STATUSES.has(runDetail.run.status) ? 'running' : 'planning';
|
||||
});
|
||||
|
||||
/**
|
||||
* True when any historical `PlanView` card in this conversation should default
|
||||
* to its collapsed form. We minimize either when the plan card has been
|
||||
* pushed back by newer assistant content (no longer the latest message) or
|
||||
* the moment implementation kicks off — both signal the user's attention is
|
||||
* elsewhere. `PlanView` itself preserves manual expand/collapse overrides.
|
||||
*/
|
||||
const planShouldCollapse = $derived(panelMode === 'running');
|
||||
|
||||
/** Statuses where the worker is still actively executing — hide the changes card. */
|
||||
const ACTIVELY_EXECUTING = new Set(['queued', 'running', 'waiting_for_input']);
|
||||
|
||||
/**
|
||||
* Build a map of every exercise id → title from the loaded course outline so
|
||||
* the changes extractor can resolve titles for exercise-section actions
|
||||
* (which only expose the parent exercise id, not its title).
|
||||
*/
|
||||
const resolveExerciseTitle = $derived.by<ExerciseTitleLookup>(() => {
|
||||
const course = courseApi.course;
|
||||
if (!course?.content) return () => undefined;
|
||||
|
||||
const titles = new Map<string, string>();
|
||||
|
||||
const visitItems = (items: { id: string; title?: string; type?: unknown }[] | undefined) => {
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
// Heuristic: only the exercise content type has a route-relevant id we care about.
|
||||
// We index everything that has a title — the lookup is exercise-scoped at call time.
|
||||
if (item.id && typeof item.title === 'string') {
|
||||
titles.set(item.id, item.title);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visitItems(course.content.items as unknown as { id: string; title?: string; type?: unknown }[] | undefined);
|
||||
for (const section of course.content.sections ?? []) {
|
||||
visitItems((section as unknown as { items?: { id: string; title?: string; type?: unknown }[] }).items);
|
||||
}
|
||||
|
||||
return (exerciseId: string) => titles.get(exerciseId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-item changes the agent just made to this conversation's content.
|
||||
* Three sources, in priority order:
|
||||
* 1. The active background run, once it has stopped executing — covers
|
||||
* Agent-mode work (BullMQ runner) with a Completed/Canceled/Paused status.
|
||||
* 2. The latest persisted `runSummary.changes` on a message — survives
|
||||
* refresh for runs that have already injected their summary message.
|
||||
* 3. Inline chat-mode tool calls aggregated from `chat.messages` — covers
|
||||
* conversations where the agent updated content via tool calls embedded
|
||||
* directly in assistant messages (no background run involved). Carries
|
||||
* no status label.
|
||||
*/
|
||||
const changedItems = $derived.by<RunChangedItem[]>(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (runDetail && runDetail.run.conversationId === activeConversationId) {
|
||||
if (!ACTIVELY_EXECUTING.has(runDetail.run.status)) {
|
||||
return extractChangedItems(runDetail, resolveExerciseTitle);
|
||||
}
|
||||
}
|
||||
|
||||
const messages = chat.messages as AiAssistantMessage[];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const summary = (messages[i].metadata as AiAssistantMessageMetadata | undefined)?.runSummary;
|
||||
if (summary?.changes && summary.changes.length > 0) {
|
||||
return summary.changes;
|
||||
}
|
||||
}
|
||||
|
||||
return extractChangedItemsFromMessages(messages, resolveExerciseTitle);
|
||||
});
|
||||
|
||||
function runStatusLabel(status: string): string {
|
||||
if (status === 'completed') return t.get('ai_assistant.changes_card.status.completed');
|
||||
if (status === 'failed') return t.get('ai_assistant.changes_card.status.failed');
|
||||
if (status === 'canceled') return t.get('ai_assistant.changes_card.status.canceled');
|
||||
if (status === 'paused') return t.get('ai_assistant.changes_card.status.paused');
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Status label shown next to the count in the changes card header ("Completed", …). */
|
||||
const changedItemsStatusLabel = $derived.by<string | null>(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (runDetail && runDetail.run.conversationId === activeConversationId) {
|
||||
if (!ACTIVELY_EXECUTING.has(runDetail.run.status)) {
|
||||
return runStatusLabel(runDetail.run.status) || null;
|
||||
}
|
||||
}
|
||||
|
||||
const messages = chat.messages as AiAssistantMessage[];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const summary = (messages[i].metadata as AiAssistantMessageMetadata | undefined)?.runSummary;
|
||||
if (summary?.changes && summary.changes.length > 0) {
|
||||
return runStatusLabel(summary.status) || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
function buildRunSummaryText(metadata: AiAssistantRunSummaryMetadata): string {
|
||||
const countsText = formatRunSummaryCounts(metadata.counts);
|
||||
|
||||
if (metadata.status === 'completed') {
|
||||
return countsText
|
||||
? t.get('ai_assistant.run_summary.completed_with_counts', { counts: countsText })
|
||||
: t.get('ai_assistant.run_summary.completed');
|
||||
}
|
||||
|
||||
if (metadata.status === 'canceled') {
|
||||
return countsText
|
||||
? t.get('ai_assistant.run_summary.canceled_with_counts', { counts: countsText })
|
||||
: t.get('ai_assistant.run_summary.canceled');
|
||||
}
|
||||
|
||||
if (metadata.status === 'failed') {
|
||||
return metadata.error
|
||||
? t.get('ai_assistant.run_summary.failed_with_reason', { reason: metadata.error })
|
||||
: t.get('ai_assistant.run_summary.failed');
|
||||
}
|
||||
|
||||
return countsText
|
||||
? t.get('ai_assistant.run_summary.paused_with_counts', { counts: countsText })
|
||||
: t.get('ai_assistant.run_summary.paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a synthetic assistant message summarising the run the first time
|
||||
* we observe it reach a settled terminal status. Persists so a refresh
|
||||
* still surfaces the summary — and dedupes via metadata.runSummary.runId
|
||||
* so we don't double-write across refreshes.
|
||||
*/
|
||||
$effect(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (!runDetail) return;
|
||||
if (runDetail.run.courseId !== courseId) return;
|
||||
if (!activeConversationId) return;
|
||||
if (runDetail.run.conversationId !== activeConversationId) return;
|
||||
if (!INJECTABLE_RUN_STATUSES.has(runDetail.run.status)) return;
|
||||
|
||||
const runId = runDetail.run.id;
|
||||
if (runId === lastInjectedRunSummaryId) return;
|
||||
|
||||
const alreadyInConversation = (chat.messages as AiAssistantMessage[]).some(
|
||||
(msg) => (msg.metadata as AiAssistantMessageMetadata | undefined)?.runSummary?.runId === runId
|
||||
);
|
||||
|
||||
if (alreadyInConversation) {
|
||||
lastInjectedRunSummaryId = runId;
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryMeta = buildRunSummaryMetadata({ runDetail, resolveExerciseTitle });
|
||||
if (!summaryMeta) return;
|
||||
|
||||
const summaryMessage: AiAssistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: buildRunSummaryText(summaryMeta) }],
|
||||
metadata: { runSummary: summaryMeta } satisfies AiAssistantMessageMetadata
|
||||
};
|
||||
|
||||
chat.messages = [...(chat.messages as AiAssistantMessage[]), summaryMessage];
|
||||
lastInjectedRunSummaryId = runId;
|
||||
|
||||
void aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const run = aiAssistantApi.currentRun?.run;
|
||||
|
||||
if (!run) {
|
||||
lastRunStatusSig = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSig = `${run.id}:${run.status}`;
|
||||
|
||||
if (nextSig === lastRunStatusSig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSig = lastRunStatusSig;
|
||||
lastRunStatusSig = nextSig;
|
||||
|
||||
if (previousSig.startsWith(`${run.id}:`) && RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||
refreshCourseStateAfterChat();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const run = aiAssistantApi.currentRun?.run;
|
||||
|
||||
if (!run || !RUN_POLL_STATUSES.has(run.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pollTimer = window.setInterval(() => {
|
||||
void aiAssistantApi.loadRun(run.id);
|
||||
}, RUN_POLL_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(pollTimer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const streamingNow = isStreaming;
|
||||
|
||||
@@ -729,6 +1175,49 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Mirrors the inline-streaming threshold refresh for durable background runs
|
||||
// (the /agent/runs path). Recomputes only when the run's step count actually
|
||||
// changes, so 2s polls that bring no new steps don't trigger work.
|
||||
$effect(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
|
||||
if (!runDetail || runDetail.run.courseId !== courseId || !RUN_POLL_STATUSES.has(runDetail.run.status)) {
|
||||
lastSeenDurableRunId = '';
|
||||
lastSeenDurableStepCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = runDetail.run.id;
|
||||
|
||||
if (runId !== lastSeenDurableRunId) {
|
||||
agentMutationProgressThresholdsTriggered.clear();
|
||||
lastSeenDurableRunId = runId;
|
||||
lastSeenDurableStepCount = 0;
|
||||
}
|
||||
|
||||
const total = runDetail.steps.length;
|
||||
if (total === 0 || total === lastSeenDurableStepCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSeenDurableStepCount = total;
|
||||
|
||||
const completedStepCount = runDetail.steps.filter((step) => step.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
|
||||
);
|
||||
@@ -818,9 +1307,11 @@
|
||||
<ChatMessageList
|
||||
messages={chat.messages}
|
||||
{isStreaming}
|
||||
{isLoadingHistory}
|
||||
{isStudent}
|
||||
{courseId}
|
||||
{planExecutionState}
|
||||
{planShouldCollapse}
|
||||
{quickActions}
|
||||
onQuickAction={handleQuickAction}
|
||||
onImplementPlan={handleImplementPlan}
|
||||
@@ -828,27 +1319,48 @@
|
||||
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
|
||||
onSkipTemplateForm={handleSkipTemplateForm}
|
||||
onStop={handleStop}
|
||||
onResume={handleResume}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
bind:inputValue
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
{isStudent}
|
||||
{tutorBlocked}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
/>
|
||||
{#if panelMode === 'running' && durableRunState}
|
||||
<RunMonitorPane
|
||||
state={durableRunState}
|
||||
{courseId}
|
||||
onCancel={handleCancelRun}
|
||||
onResume={handleResumeRun}
|
||||
onRetry={handleRetryRun}
|
||||
onDismiss={handleDismissRun}
|
||||
runStatus={aiAssistantApi.currentRun?.run.status ?? null}
|
||||
queuedSince={aiAssistantApi.currentRun?.run.createdAt ?? null}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
{:else}
|
||||
{#if changedItems.length > 0}
|
||||
<ChatChangesCard
|
||||
items={changedItems}
|
||||
{courseId}
|
||||
onNavigate={handleMentionClick}
|
||||
statusLabel={changedItemsStatusLabel}
|
||||
/>
|
||||
{/if}
|
||||
<ChatInput
|
||||
bind:inputValue
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
{isStudent}
|
||||
{tutorBlocked}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,21 @@ import type {
|
||||
AgentConversation,
|
||||
AgentConversationCreateData,
|
||||
AgentConversationSummary,
|
||||
AgentRunDetail,
|
||||
AgentRunSummary,
|
||||
AgentStatusData,
|
||||
AiAssistantMessage,
|
||||
CompactConversationRequest,
|
||||
CompactConversationSuccess
|
||||
} from '../utils/types';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
class AiAssistantApi extends BaseApiWithErrors {
|
||||
status: AgentStatusData | null = $state(null);
|
||||
conversations: AgentConversationSummary[] = $state([]);
|
||||
currentConversation: AgentConversation | null = $state(null);
|
||||
runs: AgentRunSummary[] = $state([]);
|
||||
currentRun: AgentRunDetail | null = $state(null);
|
||||
|
||||
async fetchStatus(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.status.$get>({
|
||||
@@ -250,6 +255,144 @@ class AiAssistantApi extends BaseApiWithErrors {
|
||||
|
||||
return compacted;
|
||||
}
|
||||
|
||||
async listRuns(courseId: string): Promise<AgentRunSummary[]> {
|
||||
let runs: AgentRunSummary[] = [];
|
||||
|
||||
await this.execute<typeof classroomio.agent.runs.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'listing agent runs',
|
||||
onSuccess: (result) => {
|
||||
runs = result.data as AgentRunSummary[];
|
||||
this.runs = runs;
|
||||
}
|
||||
});
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
async loadActiveRun(courseId: string): Promise<AgentRunDetail | null> {
|
||||
const runs = await this.listRuns(courseId);
|
||||
const active = runs.find((run) =>
|
||||
['queued', 'running', 'waiting_for_input', 'paused', 'failed'].includes(run.status)
|
||||
);
|
||||
|
||||
if (!active) {
|
||||
this.currentRun = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.loadRun(active.id);
|
||||
}
|
||||
|
||||
async createRun(input: {
|
||||
courseId: string;
|
||||
conversationId: string;
|
||||
approvedPlan: Record<string, unknown>;
|
||||
model: AgentModelId;
|
||||
}): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<typeof classroomio.agent.runs.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs.$post({
|
||||
json: {
|
||||
courseId: input.courseId,
|
||||
conversationId: input.conversationId,
|
||||
approvedPlan: input.approvedPlan,
|
||||
model: input.model,
|
||||
phase: 'implementing'
|
||||
}
|
||||
}),
|
||||
logContext: 'creating agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = [runDetail.run, ...this.runs.filter((run) => run.id !== runDetail.run.id)];
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async loadRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].$get({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'loading agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async cancelRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['cancel']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].cancel.$post({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'canceling agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async retryRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['retry']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].retry.$post({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'retrying agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async resumeRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['resume']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].resume.$post({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'resuming agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAssistantApi = new AiAssistantApi();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { RunChangedItem } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface Props {
|
||||
items: RunChangedItem[];
|
||||
courseId: string;
|
||||
onNavigate: (route: string) => void;
|
||||
/** Optional status label rendered next to the count ("Completed", "Canceled", …). */
|
||||
statusLabel?: string | null;
|
||||
}
|
||||
|
||||
let { items, courseId, onNavigate, statusLabel = null }: Props = $props();
|
||||
|
||||
let isExpanded = $state(true);
|
||||
|
||||
function toggle() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function pathFor(item: RunChangedItem): string {
|
||||
if (item.targetType === 'lesson') {
|
||||
return resolve(`/courses/${courseId}/lessons/${item.targetId}`, {});
|
||||
}
|
||||
|
||||
return resolve(`/courses/${courseId}/exercises/${item.targetId}`, {});
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-t-lg border-t text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
class="hover:ui:bg-muted/40 flex w-full cursor-pointer items-center gap-2 rounded-t-lg px-3 py-1.5 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? $t('ai_assistant.changes_card.collapse') : $t('ai_assistant.changes_card.expand')}
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDownIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
{:else}
|
||||
<ChevronRightIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class="ui:text-foreground font-medium">
|
||||
{$t('ai_assistant.changes_card.summary_count', { count: items.length })}
|
||||
</span>
|
||||
{#if statusLabel}
|
||||
<span class="ui:text-muted-foreground">· {statusLabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<ul class="max-h-48 overflow-y-auto">
|
||||
{#each items as item (item.targetType + ':' + item.targetId)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onNavigate(pathFor(item))}
|
||||
class="hover:ui:bg-muted/40 flex w-full cursor-pointer items-center gap-2 px-3 py-1 text-left"
|
||||
>
|
||||
{#if item.targetType === 'lesson'}
|
||||
<LessonIcon size={12} />
|
||||
{:else}
|
||||
<ExerciseIcon size={12} />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="ui:text-primary min-w-0 flex-1 truncate font-medium underline decoration-dotted underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
|
||||
{#if item.tokens}
|
||||
<span class="ui:text-muted-foreground shrink-0 tabular-nums">{formatTokens(item.tokens)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 { Spinner } from '@cio/ui/base/spinner';
|
||||
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';
|
||||
@@ -26,9 +26,12 @@
|
||||
interface Props {
|
||||
messages: AiAssistantMessage[];
|
||||
isStreaming: boolean;
|
||||
isLoadingHistory: boolean;
|
||||
isStudent: boolean;
|
||||
courseId: string;
|
||||
planExecutionState: PlanExecutionState | null;
|
||||
/** When true (e.g. implementation has started), historical PlanView cards default to collapsed. */
|
||||
planShouldCollapse?: boolean;
|
||||
quickActions: QuickActionOption[];
|
||||
onQuickAction: (action: string) => void;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
@@ -40,16 +43,17 @@
|
||||
}) => void;
|
||||
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
|
||||
onStop: () => void;
|
||||
onResume: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
messages,
|
||||
isStreaming,
|
||||
isLoadingHistory,
|
||||
isStudent,
|
||||
courseId,
|
||||
planExecutionState,
|
||||
planShouldCollapse = false,
|
||||
quickActions,
|
||||
onQuickAction,
|
||||
onImplementPlan,
|
||||
@@ -57,7 +61,6 @@
|
||||
onSubmitTemplateAnswers,
|
||||
onSkipTemplateForm,
|
||||
onStop,
|
||||
onResume,
|
||||
onMentionClick
|
||||
}: Props = $props();
|
||||
|
||||
@@ -152,7 +155,11 @@
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4" bind:this={messagesContainer} onscroll={handleScroll}>
|
||||
{#if isEmpty}
|
||||
{#if isLoadingHistory}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner class="ui:size-6 ui:text-muted-foreground" />
|
||||
</div>
|
||||
{:else 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">
|
||||
@@ -192,6 +199,7 @@
|
||||
{courseId}
|
||||
{isStreaming}
|
||||
{isLast}
|
||||
{planShouldCollapse}
|
||||
{onImplementPlan}
|
||||
{onAskPlanChanges}
|
||||
{onSubmitTemplateAnswers}
|
||||
@@ -219,11 +227,6 @@
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { tick } from 'svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Kbd from '@cio/ui/base/kbd';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sendPromptToAssistant } from '$features/ai-assistant/utils/store';
|
||||
|
||||
interface Props {
|
||||
/** Width classes matching the page content column (e.g. mx-auto w-full max-w-3xl). */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: contentClass = 'mx-auto w-full max-w-3xl' }: Props = $props();
|
||||
|
||||
const pageShellClass = 'mx-auto w-[90%] max-w-4xl px-3 sm:px-4 lg:max-w-5xl';
|
||||
|
||||
let expanded = $state(false);
|
||||
let draft = $state('');
|
||||
let textareaRef: HTMLTextAreaElement | null = $state(null);
|
||||
|
||||
const isMac = $derived(browser && /Mac|iPhone|iPad|iPod/.test(navigator.platform));
|
||||
|
||||
const expandIn = { y: 20, duration: 280, easing: cubicOut };
|
||||
const expandOut = { y: 12, duration: 200, easing: cubicOut };
|
||||
const collapseIn = { duration: 200, easing: cubicOut };
|
||||
const collapseOut = { duration: 150, easing: cubicOut };
|
||||
|
||||
function shouldIgnoreGlobalShortcut() {
|
||||
const active = document.activeElement;
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tag = active.tagName;
|
||||
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (active instanceof HTMLElement && active.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function expand() {
|
||||
expanded = true;
|
||||
await tick();
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
expanded = false;
|
||||
}
|
||||
|
||||
function handleTextareaBlur() {
|
||||
collapse();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const trimmed = draft.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendPromptToAssistant(trimmed);
|
||||
draft = '';
|
||||
collapse();
|
||||
}
|
||||
|
||||
function handleTextareaKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
collapse();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Enter' || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'i') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldIgnoreGlobalShortcut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void expand();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
{#if expanded}
|
||||
<div
|
||||
class="ui:bg-background ui:border-border fixed right-0 bottom-0 left-0 z-40 border-t py-3 shadow-sm md:left-[var(--sidebar-width,0px)]"
|
||||
in:fly={expandIn}
|
||||
out:fly={expandOut}
|
||||
>
|
||||
<div class={pageShellClass}>
|
||||
<div class={contentClass}>
|
||||
<Textarea
|
||||
bind:ref={textareaRef}
|
||||
bind:value={draft}
|
||||
rows={1}
|
||||
placeholder={$t('ai_assistant.content_input_placeholder')}
|
||||
aria-label={$t('ai_assistant.ask_ai_input_aria')}
|
||||
class="ui:min-h-10 ui:max-h-64 ui:resize-none ui:rounded-none ui:shadow-none"
|
||||
onkeydown={handleTextareaKeydown}
|
||||
onblur={handleTextareaBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="pointer-events-none fixed right-0 bottom-4 left-0 z-40 md:left-[var(--sidebar-width,0px)]"
|
||||
in:fade={collapseIn}
|
||||
out:fade={collapseOut}
|
||||
>
|
||||
<div class={pageShellClass}>
|
||||
<div class={`flex justify-center ${contentClass}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="ui:bg-background ui:shadow-sm pointer-events-auto"
|
||||
aria-label={$t('ai_assistant.ask_ai_expand_aria')}
|
||||
onclick={() => expand()}
|
||||
onmouseenter={() => expand()}
|
||||
>
|
||||
{$t('ai_assistant.ask_ai')}
|
||||
<Separator orientation="vertical" class="ui:!h-4" />
|
||||
<span class="ui:text-muted-foreground flex items-center gap-0.5 font-normal">
|
||||
{#if isMac}
|
||||
<Kbd.Root>⌘</Kbd.Root>
|
||||
{:else}
|
||||
<Kbd.Root>Ctrl</Kbd.Root>
|
||||
{/if}
|
||||
<Kbd.Root>I</Kbd.Root>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as AiCourseChat } from './ai-course-chat.svelte';
|
||||
export { default as ContentAskAiBar } from './content-ask-ai-bar.svelte';
|
||||
export { default as PlanView } from './plan-view.svelte';
|
||||
export { default as ProgressCard } from './progress-card.svelte';
|
||||
export {
|
||||
@@ -8,6 +9,7 @@ export {
|
||||
openAiAssistant,
|
||||
closeAiAssistant,
|
||||
quoteInChat,
|
||||
sendPromptToAssistant,
|
||||
setChatDraft,
|
||||
toggleAiAssistant
|
||||
} from './utils/store';
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
onAskPlanChanges: (message: string) => void;
|
||||
isStreaming: boolean;
|
||||
isLast?: boolean;
|
||||
/** When true, historical PlanView cards default to their collapsed form. */
|
||||
planShouldCollapse?: boolean;
|
||||
onSubmitTemplateAnswers: (payload: {
|
||||
templateId: CourseTemplateId;
|
||||
answers: Record<string, string>;
|
||||
@@ -57,9 +59,17 @@
|
||||
onSkipTemplateForm,
|
||||
onMentionClick,
|
||||
isStreaming,
|
||||
isLast = false
|
||||
isLast = false,
|
||||
planShouldCollapse = false
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
* Collapse a PlanView card when implementation has started anywhere in the
|
||||
* conversation OR when newer assistant messages have pushed it back. The
|
||||
* very first plan card the teacher sees stays expanded.
|
||||
*/
|
||||
const planCardDefaultCollapsed = $derived(planShouldCollapse || !isLast);
|
||||
|
||||
function localizePendingTool(toolName: string): string {
|
||||
const vars = getPendingToolI18nVars(toolName);
|
||||
const key = getPendingToolI18nKey(toolName);
|
||||
@@ -170,6 +180,7 @@
|
||||
onImplement={onImplementPlan}
|
||||
onAskChanges={onAskPlanChanges}
|
||||
isBusy={isStreaming}
|
||||
defaultCollapsed={planCardDefaultCollapsed}
|
||||
/>
|
||||
</div>
|
||||
{:else if toolName === 'ask_template_questions' && (toolStatus === 'completed' || toolStatus === 'in_progress')}
|
||||
|
||||
@@ -25,9 +25,33 @@
|
||||
/** When true, disables the inline change-request composer (agent is already working). */
|
||||
isBusy?: boolean;
|
||||
remainingTokens?: number;
|
||||
/**
|
||||
* When true on mount, the card starts collapsed (header-only). When the
|
||||
* prop flips from false → true and the user hasn't manually toggled the
|
||||
* card, it collapses automatically. Manual user toggles are sticky.
|
||||
*/
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens }: Props = $props();
|
||||
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens, defaultCollapsed = false }: Props = $props();
|
||||
|
||||
let isExpanded = $state(!defaultCollapsed);
|
||||
/** Sticky once the user clicks the top-level chevron; defeats the auto-collapse signal. */
|
||||
let userToggled = $state(false);
|
||||
|
||||
function toggleCard() {
|
||||
userToggled = true;
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the parent flips defaultCollapsed (e.g. implementation just began),
|
||||
* collapse — unless the user has explicitly opened the card already.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (userToggled) return;
|
||||
isExpanded = !defaultCollapsed;
|
||||
});
|
||||
|
||||
let changeRequest = $state('');
|
||||
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
|
||||
@@ -122,167 +146,184 @@
|
||||
</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>
|
||||
<!-- Plan header: chevron + title + counts. Chevron toggles the whole card. -->
|
||||
<div class="flex items-start gap-2 {isExpanded ? 'border-b' : ''} px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleCard}
|
||||
class="ui:text-muted-foreground hover:ui:text-foreground mt-0.5 shrink-0"
|
||||
aria-label={isExpanded ? $t('ai_assistant.plan_view.collapse') : $t('ai_assistant.plan_view.expand')}
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDownIcon size={14} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- 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"
|
||||
<div class="min-w-0 flex-1">
|
||||
{#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
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
disabled={!canSendChange}
|
||||
onclick={submitChangeRequest}
|
||||
aria-label={$t('ai_assistant.plan_ask_changes_send_aria')}
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing('plan-title')}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left text-sm font-semibold"
|
||||
>
|
||||
<ArrowUpIcon class="ui:size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<!-- 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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
@@ -19,9 +21,26 @@
|
||||
onStop?: () => void;
|
||||
isStopped?: boolean;
|
||||
error?: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
let { titleKey, steps, courseId, onNavigate, currentActionLine, onStop, isStopped = false, error }: Props = $props();
|
||||
let {
|
||||
titleKey,
|
||||
steps,
|
||||
courseId,
|
||||
onNavigate,
|
||||
currentActionLine,
|
||||
onStop,
|
||||
isStopped = false,
|
||||
error,
|
||||
showTitle = true
|
||||
}: Props = $props();
|
||||
|
||||
/** Per-char delay for typewriter — adjusted down for long lines to stay under TYPEWRITER_MAX_MS total. */
|
||||
const TYPEWRITER_MS_PER_CHAR = 25;
|
||||
const TYPEWRITER_MAX_MS = 1500;
|
||||
/** "At the bottom" tolerance — same idea as `chat-message-list.svelte`. */
|
||||
const SCROLL_PIN_THRESHOLD = 40;
|
||||
|
||||
function stepRowKey(step: ProgressStep, index: number): string {
|
||||
if (step.line.shape === 'i18n') {
|
||||
@@ -39,32 +58,147 @@
|
||||
return `${index}-exo-${step.line.exerciseId}-${step.line.action}-${step.line.count}-${step.line.title}`;
|
||||
}
|
||||
|
||||
// ─── Typewriter ────────────────────────────────────────────────────────────
|
||||
// Only i18n-shape lines animate (those are plain text). Lines containing
|
||||
// mention links render in full immediately — partial-rendering a mention
|
||||
// would look broken.
|
||||
|
||||
/** Step keys whose text has finished animating (or never needed to). */
|
||||
let animatedKeys = new SvelteSet<string>();
|
||||
/** key → currently-displayed substring during animation. */
|
||||
let animatingTexts = new SvelteMap<string, string>();
|
||||
|
||||
function resolveI18nText(line: ToolLineUi): string | null {
|
||||
if (line.shape !== 'i18n') return null;
|
||||
return t.get(line.key, line.vars ?? {});
|
||||
}
|
||||
|
||||
function startTypewriter(key: string, fullText: string) {
|
||||
if (fullText.length === 0) {
|
||||
animatedKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Squeeze the per-char delay so even long lines finish under the cap.
|
||||
const perCharDelay = Math.max(
|
||||
15,
|
||||
Math.min(TYPEWRITER_MS_PER_CHAR, Math.floor(TYPEWRITER_MAX_MS / fullText.length))
|
||||
);
|
||||
let revealed = 0;
|
||||
animatingTexts.set(key, '');
|
||||
|
||||
const handle = setInterval(() => {
|
||||
revealed += 1;
|
||||
animatingTexts.set(key, fullText.slice(0, revealed));
|
||||
|
||||
if (revealed >= fullText.length) {
|
||||
clearInterval(handle);
|
||||
animatingTexts.delete(key);
|
||||
animatedKeys.add(key);
|
||||
}
|
||||
}, perCharDelay);
|
||||
}
|
||||
|
||||
// Detect newly arrived steps every render. Reading `steps` ties this effect
|
||||
// to step list growth.
|
||||
$effect(() => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const key = stepRowKey(steps[i], i);
|
||||
if (animatedKeys.has(key) || animatingTexts.has(key)) continue;
|
||||
|
||||
const text = resolveI18nText(steps[i].line);
|
||||
if (text === null) {
|
||||
// Non-i18n shape — render in full, skip the typewriter.
|
||||
animatedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
startTypewriter(key, text);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Auto-scroll ───────────────────────────────────────────────────────────
|
||||
let stepsContainer: HTMLDivElement | undefined = $state();
|
||||
let isPinnedToBottom = $state(true);
|
||||
let lastSeenStepCount = 0;
|
||||
|
||||
function handleScroll() {
|
||||
if (!stepsContainer) return;
|
||||
|
||||
const distanceFromBottom = stepsContainer.scrollHeight - stepsContainer.scrollTop - stepsContainer.clientHeight;
|
||||
isPinnedToBottom = distanceFromBottom <= SCROLL_PIN_THRESHOLD;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (steps.length === lastSeenStepCount) return;
|
||||
|
||||
const grew = steps.length > lastSeenStepCount;
|
||||
lastSeenStepCount = steps.length;
|
||||
|
||||
if (grew && isPinnedToBottom) {
|
||||
void tick().then(() => {
|
||||
if (stepsContainer) {
|
||||
stepsContainer.scrollTop = stepsContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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">{$t(titleKey)}</h4>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
<!-- Header: title, progress count, and the prominent "now doing" strip -->
|
||||
<div class="space-y-1.5 border-b px-3 py-2">
|
||||
{#if showTitle}
|
||||
<h4 class="text-sm font-semibold">{$t(titleKey)}</h4>
|
||||
{/if}
|
||||
{#if totalCount > 0}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if currentActionLine && !isStopped}
|
||||
<div class="ui:bg-muted/40 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
<LoaderIcon size={14} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span class="ui:text-muted-foreground shrink-0 text-[10px] font-medium tracking-wide uppercase">
|
||||
{$t('ai_assistant.run_monitor.now_doing')}
|
||||
</span>
|
||||
<span class="ui:text-foreground min-w-0 flex-1 truncate">
|
||||
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
<div bind:this={stepsContainer} onscroll={handleScroll} class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
{#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' : ''}">
|
||||
{@const rowKey = stepRowKey(step, i)}
|
||||
{@const animatingSlice = animatingTexts.get(rowKey)}
|
||||
{@const isAnimating = animatingSlice !== undefined && step.line.shape === 'i18n'}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''} {step.status ===
|
||||
'in_progress'
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
>
|
||||
{#if step.status === 'completed'}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<CheckIcon size={14} class="ui:text-primary shrink-0" />
|
||||
{:else if step.status === 'in_progress'}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<LoaderIcon size={14} class="ui:text-primary shrink-0 animate-spin" />
|
||||
{:else if step.status === 'failed'}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<AlertCircleIcon size={14} class="shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<CircleIcon size={14} class="ui:text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
|
||||
<ToolLine line={step.line} {courseId} {onNavigate} />
|
||||
{#if isAnimating}
|
||||
{animatingSlice}
|
||||
{:else}
|
||||
<ToolLine line={step.line} {courseId} {onNavigate} />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -75,12 +209,6 @@
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if currentActionLine && !isStopped}
|
||||
<div class="ui:text-muted-foreground text-xs leading-snug">
|
||||
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('ai_assistant.stopped_content_kept')}</p>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CheckCircleIcon from '@lucide/svelte/icons/check-circle-2';
|
||||
import XCircleIcon from '@lucide/svelte/icons/x-circle';
|
||||
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
|
||||
import PauseIcon from '@lucide/svelte/icons/pause-circle';
|
||||
|
||||
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
|
||||
|
||||
interface RunMonitorState {
|
||||
titleKey: string;
|
||||
steps: ProgressStep[];
|
||||
currentActionLine?: ToolLineUi;
|
||||
isStopped: boolean;
|
||||
error?: string;
|
||||
canCancel: boolean;
|
||||
canRetry: boolean;
|
||||
canResume: boolean;
|
||||
}
|
||||
|
||||
/** Queued for longer than this with no worker pickup = surface "worker not responding". */
|
||||
const STUCK_QUEUED_THRESHOLD_MS = 30_000;
|
||||
|
||||
interface Props {
|
||||
state: RunMonitorState;
|
||||
courseId: string;
|
||||
/** Underlying run status; used to enable Dismiss only on canceled runs. */
|
||||
runStatus?: string | null;
|
||||
/** When run is `queued`, the ISO timestamp it was enqueued. Lets us flag stuck-queued runs. */
|
||||
queuedSince?: string | null;
|
||||
onCancel: () => void;
|
||||
onResume: () => void;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
onRetryStuck?: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
state: runState,
|
||||
courseId,
|
||||
runStatus = null,
|
||||
queuedSince = null,
|
||||
onCancel,
|
||||
onResume,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
onRetryStuck,
|
||||
onMentionClick
|
||||
}: Props = $props();
|
||||
|
||||
const isCanceled = $derived(runStatus === 'canceled');
|
||||
|
||||
// `now` ticks every 5s so the stuck-queued detector keeps re-evaluating even
|
||||
// when the run row itself isn't changing (which is exactly the symptom we're
|
||||
// trying to catch — the worker isn't moving the row past `queued`).
|
||||
let now = $state(Date.now());
|
||||
onMount(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 5_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
const isStuckQueued = $derived.by(() => {
|
||||
if (runStatus !== 'queued' || !queuedSince) return false;
|
||||
const queuedAt = Date.parse(queuedSince);
|
||||
if (!Number.isFinite(queuedAt)) return false;
|
||||
return now - queuedAt > STUCK_QUEUED_THRESHOLD_MS;
|
||||
});
|
||||
|
||||
// Two-step Dismiss: first click reveals a confirm/cancel pair so the user
|
||||
// can't accidentally walk away from a canceled run and lose the Resume button.
|
||||
let isConfirmingDismiss = $state(false);
|
||||
|
||||
function requestDismiss() {
|
||||
isConfirmingDismiss = true;
|
||||
}
|
||||
|
||||
function confirmDismiss() {
|
||||
isConfirmingDismiss = false;
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
function cancelDismiss() {
|
||||
isConfirmingDismiss = false;
|
||||
}
|
||||
|
||||
// The titleKey already covers running / completed / failed / canceled — pick
|
||||
// a matching icon so the pane state is readable at a glance without parsing
|
||||
// the text.
|
||||
const StatusIcon = $derived.by(() => {
|
||||
if (runState.error || runState.canRetry) return XCircleIcon;
|
||||
if (runState.canResume) return PauseIcon;
|
||||
if (runState.isStopped) return AlertTriangleIcon;
|
||||
if (runState.titleKey === 'ai_assistant.run_completed') return CheckCircleIcon;
|
||||
return LoaderIcon;
|
||||
});
|
||||
|
||||
const isWorking = $derived(!runState.isStopped && runState.titleKey !== 'ai_assistant.run_completed');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ui:bg-background flex w-full flex-col gap-3 border-t px-4 py-3"
|
||||
role="region"
|
||||
aria-label={$t('ai_assistant.run_monitor_aria')}
|
||||
>
|
||||
<div class="ui:text-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<StatusIcon size={16} class={isWorking ? 'ui:text-primary animate-spin' : 'ui:text-muted-foreground'} />
|
||||
<span>{$t(runState.titleKey)}</span>
|
||||
</div>
|
||||
|
||||
<ProgressCard
|
||||
titleKey={runState.titleKey}
|
||||
steps={runState.steps}
|
||||
currentActionLine={runState.currentActionLine}
|
||||
{courseId}
|
||||
onNavigate={onMentionClick}
|
||||
onStop={undefined}
|
||||
isStopped={runState.isStopped}
|
||||
error={runState.error}
|
||||
showTitle={false}
|
||||
/>
|
||||
|
||||
{#if isStuckQueued}
|
||||
<div
|
||||
class="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"
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangleIcon size={14} class="mt-0.5 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium">{$t('ai_assistant.run_monitor.stuck_queued.title')}</p>
|
||||
<p class="mt-0.5">{$t('ai_assistant.run_monitor.stuck_queued.body')}</p>
|
||||
</div>
|
||||
{#if onRetryStuck}
|
||||
<Button size="sm" variant="outline" onclick={onRetryStuck} class="shrink-0">
|
||||
{$t('ai_assistant.retry')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if runState.canCancel}
|
||||
<Button size="sm" variant="outline" onclick={onCancel} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.cancel')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if runState.canRetry}
|
||||
<Button size="sm" variant="outline" onclick={onRetry} class="flex-1">
|
||||
{$t('ai_assistant.retry')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if runState.canResume}
|
||||
<Button size="sm" variant="default" onclick={onResume} class="flex-1">
|
||||
{$t('ai_assistant.resume')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if isCanceled && !isConfirmingDismiss}
|
||||
<Button size="sm" variant="ghost" onclick={requestDismiss} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.dismiss')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isCanceled && isConfirmingDismiss}
|
||||
<div class="ui:bg-muted/30 flex flex-col gap-2 rounded border px-3 py-2 text-xs">
|
||||
<p class="ui:text-foreground">{$t('ai_assistant.run_monitor.dismiss_confirm_body')}</p>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onclick={cancelDismiss} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.dismiss_cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onclick={confirmDismiss} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.dismiss_confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="ui:text-muted-foreground text-[11px] leading-snug">
|
||||
{$t('ai_assistant.run_monitor.input_disabled_hint')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Course, CourseContentItem } from '$features/course/utils/types';
|
||||
import { getCourseContent } from '$features/course/utils/content';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
function findContentItem(
|
||||
course: Course | null,
|
||||
contentId: string,
|
||||
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise
|
||||
): CourseContentItem | undefined {
|
||||
const content = getCourseContent(course);
|
||||
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
|
||||
|
||||
return items.find((item) => item.id === contentId && item.type === contentType);
|
||||
}
|
||||
|
||||
export function isCourseContentLockedForStudent(
|
||||
course: Course | null,
|
||||
contentId: string | undefined,
|
||||
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise | null
|
||||
): boolean {
|
||||
if (!contentId || !contentType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = findContentItem(course, contentId, contentType);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (item.isUnlocked ?? true) === false;
|
||||
}
|
||||
|
||||
export function getContentAskAiBarWidthClass(options: {
|
||||
lessonId: string | undefined;
|
||||
isLessonEditMode: boolean;
|
||||
}): string {
|
||||
if (options.lessonId) {
|
||||
return options.isLessonEditMode ? 'mx-auto w-full lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl';
|
||||
}
|
||||
|
||||
return 'mx-auto w-full md:max-w-3xl';
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
AgentRunDetail,
|
||||
AgentRunStep,
|
||||
AiAssistantMessage,
|
||||
AiAssistantRunSummaryMetadata,
|
||||
AiAssistantRunSummaryStatus,
|
||||
RunChangeAction,
|
||||
RunChangedItem
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Look up an exercise title by id. Caller supplies the function — keeps this
|
||||
* file decoupled from any specific course shape on the dashboard side.
|
||||
*/
|
||||
export type ExerciseTitleLookup = (exerciseId: string) => string | undefined;
|
||||
|
||||
const STATUSES_TERMINAL: ReadonlySet<AiAssistantRunSummaryStatus> = new Set([
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
'paused'
|
||||
]);
|
||||
|
||||
export function isRunSummaryStatus(status: string | undefined | null): status is AiAssistantRunSummaryStatus {
|
||||
return !!status && STATUSES_TERMINAL.has(status as AiAssistantRunSummaryStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate tool-call counts from a run's checkpointed steps into the four
|
||||
* categories the summary message surfaces (sections / lessons / exercises /
|
||||
* question blocks). Anything else the worker did is intentionally omitted —
|
||||
* the message should read at a glance, not log every tool call.
|
||||
*/
|
||||
function countToolCalls(runDetail: AgentRunDetail): AiAssistantRunSummaryMetadata['counts'] {
|
||||
const counts: Required<AiAssistantRunSummaryMetadata['counts']> = {
|
||||
sections: 0,
|
||||
lessons: 0,
|
||||
exercises: 0,
|
||||
questionBlocks: 0
|
||||
};
|
||||
|
||||
for (const step of runDetail.steps) {
|
||||
if (step.status !== 'completed') continue;
|
||||
const toolName = step.stepType.startsWith('tool:') ? step.stepType.slice('tool:'.length) : null;
|
||||
if (!toolName) continue;
|
||||
|
||||
if (toolName === 'create_section') counts.sections += 1;
|
||||
else if (toolName === 'create_lesson') counts.lessons += 1;
|
||||
else if (toolName === 'create_exercise') counts.exercises += 1;
|
||||
else if (toolName === 'add_questions') counts.questionBlocks += 1;
|
||||
}
|
||||
|
||||
// Trim zeros so consumers can render only what was actually created.
|
||||
const trimmed: AiAssistantRunSummaryMetadata['counts'] = {};
|
||||
if (counts.sections) trimmed.sections = counts.sections;
|
||||
if (counts.lessons) trimmed.lessons = counts.lessons;
|
||||
if (counts.exercises) trimmed.exercises = counts.exercises;
|
||||
if (counts.questionBlocks) trimmed.questionBlocks = counts.questionBlocks;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getStepToolName(step: AgentRunStep): string | null {
|
||||
if (!step.stepType.startsWith('tool:')) return null;
|
||||
return step.stepType.slice('tool:'.length);
|
||||
}
|
||||
|
||||
function getStepResult(step: AgentRunStep): Record<string, unknown> | null {
|
||||
const output = step.output as { result?: unknown } | null;
|
||||
if (!output || typeof output !== 'object') return null;
|
||||
const result = output.result;
|
||||
if (!result || typeof result !== 'object') return null;
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStepArgs(step: AgentRunStep): Record<string, unknown> | null {
|
||||
const input = step.input as { args?: unknown } | null;
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const args = input.args;
|
||||
if (!args || typeof args !== 'object') return null;
|
||||
return args as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an action to an existing changed item, dedup-aware. `questions_added`
|
||||
* and `questions_updated` accumulate counts; the rest are flagged once.
|
||||
*/
|
||||
function mergeAction(item: RunChangedItem, action: RunChangeAction): void {
|
||||
if (action.kind === 'questions_added' || action.kind === 'questions_updated') {
|
||||
const existing = item.actions.find((a) => a.kind === action.kind) as
|
||||
| { kind: typeof action.kind; count: number }
|
||||
| undefined;
|
||||
|
||||
if (existing) {
|
||||
existing.count += action.count;
|
||||
return;
|
||||
}
|
||||
|
||||
item.actions.push(action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.actions.some((a) => a.kind === action.kind)) return;
|
||||
item.actions.push(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-agnostic aggregator. Caller hands a stream of `{ tool, result, args }`
|
||||
* tuples (one per completed tool call) and gets back the per-item summary used
|
||||
* by the changes card. Lets the same mapping table serve both run steps and
|
||||
* inline chat-mode tool parts.
|
||||
*/
|
||||
interface ToolCallView {
|
||||
tool: string;
|
||||
result: Record<string, unknown> | null;
|
||||
args: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function aggregateToolCalls(
|
||||
calls: Iterable<ToolCallView>,
|
||||
resolveExerciseTitle: ExerciseTitleLookup
|
||||
): RunChangedItem[] {
|
||||
const byKey = new Map<string, RunChangedItem>();
|
||||
|
||||
function upsert(
|
||||
targetType: 'lesson' | 'exercise',
|
||||
targetId: string,
|
||||
titleFromStep: string | null,
|
||||
action: RunChangeAction,
|
||||
tokensDelta: number | null
|
||||
): void {
|
||||
const key = `${targetType}:${targetId}`;
|
||||
let item = byKey.get(key);
|
||||
|
||||
if (!item) {
|
||||
const title =
|
||||
titleFromStep ?? (targetType === 'exercise' ? (resolveExerciseTitle(targetId) ?? 'Exercise') : 'Lesson');
|
||||
item = { targetType, targetId, title, actions: [] };
|
||||
byKey.set(key, item);
|
||||
} else if (titleFromStep && (item.title === 'Lesson' || item.title === 'Exercise')) {
|
||||
item.title = titleFromStep;
|
||||
}
|
||||
|
||||
mergeAction(item, action);
|
||||
|
||||
if (tokensDelta !== null && tokensDelta > 0) {
|
||||
item.tokens = (item.tokens ?? 0) + tokensDelta;
|
||||
}
|
||||
}
|
||||
|
||||
for (const { tool, result, args } of calls) {
|
||||
if (tool === 'create_lesson' || tool === 'update_lesson') {
|
||||
const id = asString(result?.id);
|
||||
if (!id) continue;
|
||||
upsert(
|
||||
'lesson',
|
||||
id,
|
||||
asString(result?.title),
|
||||
tool === 'create_lesson' ? { kind: 'created' } : { kind: 'metadata_updated' },
|
||||
null
|
||||
);
|
||||
} else if (tool === 'update_lesson_content') {
|
||||
const id = asString(result?.lessonId);
|
||||
if (!id) continue;
|
||||
const contentLength = asNumber(result?.contentLength);
|
||||
const tokens = contentLength !== null ? Math.ceil(contentLength / 4) : null;
|
||||
upsert('lesson', id, asString(result?.lessonTitle), { kind: 'content_written' }, tokens);
|
||||
} else if (tool === 'create_exercise' || tool === 'update_exercise') {
|
||||
const id = asString(result?.id);
|
||||
if (!id) continue;
|
||||
upsert(
|
||||
'exercise',
|
||||
id,
|
||||
asString(result?.title),
|
||||
tool === 'create_exercise' ? { kind: 'created' } : { kind: 'metadata_updated' },
|
||||
null
|
||||
);
|
||||
} else if (tool === 'add_questions') {
|
||||
const id = asString(result?.exerciseId);
|
||||
if (!id) continue;
|
||||
const count = asNumber(result?.addedCount) ?? 0;
|
||||
if (count <= 0) continue;
|
||||
upsert('exercise', id, asString(result?.exerciseTitle), { kind: 'questions_added', count }, count);
|
||||
} else if (tool === 'update_questions') {
|
||||
const id = asString(result?.exerciseId);
|
||||
if (!id) continue;
|
||||
const count = asNumber(result?.updatedCount) ?? 0;
|
||||
if (count <= 0) continue;
|
||||
upsert('exercise', id, asString(result?.exerciseTitle), { kind: 'questions_updated', count }, count);
|
||||
} else if (tool === 'create_exercise_section' || tool === 'update_exercise_section') {
|
||||
const id = asString(args?.exerciseId);
|
||||
if (!id) continue;
|
||||
upsert(
|
||||
'exercise',
|
||||
id,
|
||||
null,
|
||||
tool === 'create_exercise_section' ? { kind: 'section_added' } : { kind: 'section_edited' },
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a run's completed tool steps and aggregate them into a list of unique
|
||||
* lesson/exercise items with the actions performed on each. Sections,
|
||||
* landing-page edits, structure reads, etc. are intentionally dropped — the
|
||||
* UI surface is "what content did the teacher just get".
|
||||
*/
|
||||
export function extractChangedItems(
|
||||
runDetail: AgentRunDetail,
|
||||
resolveExerciseTitle: ExerciseTitleLookup = () => undefined
|
||||
): RunChangedItem[] {
|
||||
function* toCalls(): Generator<ToolCallView> {
|
||||
for (const step of runDetail.steps) {
|
||||
if (step.status !== 'completed') continue;
|
||||
const tool = getStepToolName(step);
|
||||
if (!tool) continue;
|
||||
yield { tool, result: getStepResult(step), args: getStepArgs(step) };
|
||||
}
|
||||
}
|
||||
|
||||
return aggregateToolCalls(toCalls(), resolveExerciseTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same aggregation, but sourced from inline chat-mode tool parts on
|
||||
* `AiAssistantMessage.parts`. Chat parts use `type: 'tool-<name>'`, place the
|
||||
* tool's return value directly on `part.output` (no `result` wrapper), and
|
||||
* carry tool args directly on `part.input` (no `args` wrapper) — so we adapt
|
||||
* to those shapes before delegating to the shared aggregator.
|
||||
*/
|
||||
export function extractChangedItemsFromMessages(
|
||||
messages: AiAssistantMessage[],
|
||||
resolveExerciseTitle: ExerciseTitleLookup = () => undefined
|
||||
): RunChangedItem[] {
|
||||
function* toCalls(): Generator<ToolCallView> {
|
||||
for (const message of messages) {
|
||||
const parts = (message as { parts?: unknown }).parts;
|
||||
if (!Array.isArray(parts)) continue;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
const type = typeof part.type === 'string' ? part.type : '';
|
||||
if (!type.startsWith('tool-')) continue;
|
||||
if (part.state !== 'output-available') continue;
|
||||
|
||||
const tool = type.slice('tool-'.length);
|
||||
const result = isRecord(part.output) ? part.output : null;
|
||||
const args = isRecord(part.input) ? part.input : null;
|
||||
yield { tool, result, args };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aggregateToolCalls(toCalls(), resolveExerciseTitle);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export interface BuildRunSummaryInput {
|
||||
runDetail: AgentRunDetail;
|
||||
resolveExerciseTitle?: ExerciseTitleLookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata block that gets attached to the synthetic summary
|
||||
* message. Caller is responsible for formatting it into i18n text.
|
||||
*/
|
||||
export function buildRunSummaryMetadata({
|
||||
runDetail,
|
||||
resolveExerciseTitle
|
||||
}: BuildRunSummaryInput): AiAssistantRunSummaryMetadata | null {
|
||||
const status = runDetail.run.status;
|
||||
if (!isRunSummaryStatus(status)) return null;
|
||||
|
||||
const changes = extractChangedItems(runDetail, resolveExerciseTitle);
|
||||
|
||||
return {
|
||||
runId: runDetail.run.id,
|
||||
status,
|
||||
counts: countToolCalls(runDetail),
|
||||
changes: changes.length > 0 ? changes : undefined,
|
||||
finishedAt: runDetail.run.finishedAt ?? new Date().toISOString(),
|
||||
error: runDetail.run.lastError?.message
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRunSummaryCounts(counts: AiAssistantRunSummaryMetadata['counts']): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (counts.sections) parts.push(`${counts.sections} section${counts.sections === 1 ? '' : 's'}`);
|
||||
if (counts.lessons) parts.push(`${counts.lessons} lesson${counts.lessons === 1 ? '' : 's'}`);
|
||||
if (counts.exercises) parts.push(`${counts.exercises} exercise${counts.exercises === 1 ? '' : 's'}`);
|
||||
if (counts.questionBlocks) {
|
||||
parts.push(`${counts.questionBlocks} question block${counts.questionBlocks === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
@@ -51,6 +51,18 @@ export function clearInitialChatModel() {
|
||||
initialChatModel.set(null);
|
||||
}
|
||||
|
||||
/** Opens the AI assistant panel and sends `prompt` as the next user message. */
|
||||
export function sendPromptToAssistant(prompt: string) {
|
||||
const trimmed = prompt.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInitialChatPrompt(trimmed);
|
||||
openAiAssistant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending composer action picked up by the chat component:
|
||||
* - `append` adds the text to whatever is already in the input (panel was open).
|
||||
|
||||
@@ -34,12 +34,59 @@ export interface AiAssistantCompactionMetadata {
|
||||
originalMessageCount: number;
|
||||
}
|
||||
|
||||
export type AiAssistantRunSummaryStatus = 'completed' | 'failed' | 'canceled' | 'paused';
|
||||
|
||||
/**
|
||||
* Per-item action describing what the agent did to a lesson or exercise.
|
||||
* `questions_added` / `questions_updated` carry counts; the rest are atomic
|
||||
* flags that dedupe by `kind` when aggregating multiple tool calls against
|
||||
* the same target.
|
||||
*/
|
||||
export type RunChangeAction =
|
||||
| { kind: 'created' }
|
||||
| { kind: 'metadata_updated' }
|
||||
| { kind: 'content_written' }
|
||||
| { kind: 'questions_added'; count: number }
|
||||
| { kind: 'questions_updated'; count: number }
|
||||
| { kind: 'section_added' }
|
||||
| { kind: 'section_edited' };
|
||||
|
||||
export interface RunChangedItem {
|
||||
targetType: 'lesson' | 'exercise';
|
||||
targetId: string;
|
||||
title: string;
|
||||
/** Aggregated actions; `questions_*` counts accumulate. */
|
||||
actions: RunChangeAction[];
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an assistant message as the terminal summary of a durable run.
|
||||
* Used to (1) render the summary card and (2) prevent duplicate injection
|
||||
* after a refresh when the message is already persisted in the conversation.
|
||||
*/
|
||||
export interface AiAssistantRunSummaryMetadata {
|
||||
runId: string;
|
||||
status: AiAssistantRunSummaryStatus;
|
||||
counts: {
|
||||
sections?: number;
|
||||
lessons?: number;
|
||||
exercises?: number;
|
||||
questionBlocks?: number;
|
||||
};
|
||||
/** Per-item changes for the top-of-chat ChangesCard; empty array means no lesson/exercise touched. */
|
||||
changes?: RunChangedItem[];
|
||||
finishedAt: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageMetadata {
|
||||
attachment?: AiAssistantMessageAttachment;
|
||||
tokenUsage?: AiAssistantMessageTokenUsage;
|
||||
continuation?: AiAssistantMessageContinuation;
|
||||
template?: AiAssistantTemplateMetadata;
|
||||
compaction?: AiAssistantCompactionMetadata;
|
||||
runSummary?: AiAssistantRunSummaryMetadata;
|
||||
}
|
||||
|
||||
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
|
||||
@@ -83,3 +130,20 @@ export type CompactConversationSuccess = Extract<InferResponseType<CompactConver
|
||||
export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post'];
|
||||
export type GenerateCourseTitleSuccess = Extract<InferResponseType<GenerateCourseTitleRequest>, { success: true }>;
|
||||
export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data'];
|
||||
|
||||
export type AgentRunsListRequest = typeof classroomio.agent.runs.$get;
|
||||
export type AgentRunsListSuccess = Extract<InferResponseType<AgentRunsListRequest>, { success: true }>;
|
||||
export type AgentRunsListData = AgentRunsListSuccess['data'];
|
||||
export type AgentRunSummary = AgentRunsListData[number];
|
||||
|
||||
export type AgentRunCreateRequest = typeof classroomio.agent.runs.$post;
|
||||
export type AgentRunCreateSuccess = Extract<InferResponseType<AgentRunCreateRequest>, { success: true }>;
|
||||
export type AgentRunDetail = AgentRunCreateSuccess['data'];
|
||||
export type AgentRun = AgentRunDetail['run'];
|
||||
export type AgentRunStep = AgentRunDetail['steps'][number];
|
||||
export type AgentRunEvent = AgentRunDetail['events'][number];
|
||||
|
||||
export type AgentRunGetRequest = (typeof classroomio.agent.runs)[':runId']['$get'];
|
||||
export type AgentRunCancelRequest = (typeof classroomio.agent.runs)[':runId']['cancel']['$post'];
|
||||
export type AgentRunRetryRequest = (typeof classroomio.agent.runs)[':runId']['retry']['$post'];
|
||||
export type AgentRunResumeRequest = (typeof classroomio.agent.runs)[':runId']['resume']['$post'];
|
||||
|
||||
Reference in New Issue
Block a user