feat(ai-assistant): odt support, document-to-lesson attach, upload UX fixes
- Add .odt (application/vnd.oasis.opendocument.text) as a supported upload format; extract text via JSZip + content.xml parsing - Add attach_document_to_lesson agent tool so the AI can attach an uploaded document to a lesson as a downloadable material - Instruct the agent to create a "Course Materials" lesson (first lesson of the first section) and attach the uploaded document to it on implementation - Suppress hallucinated references: uploaded documents do not count as fetched URLs — omit the References section when no fetch_documentation_url calls were made - Surface specific upload error snackbars (file_too_large, unsupported_file_type) instead of silently logging; fix generic fallback message - Allow typing in the chat textarea while a document is uploading; only block Enter/send, not the whole input - Fix retry endpoint to return the current run instead of erroring when the run is already active (queued/running/waiting_for_input/paused) - Connect @cio/core Redis singleton in the jobs bootstrap so course-generation workers no longer fail with "The client is closed" - Expose documentAssets (assetId + fileName) alongside documentText in AgentContext and pass it through both the chat route and course runner
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationCreateData,
|
||||
@@ -158,7 +160,25 @@ class AiAssistantApi extends BaseApiWithErrors {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading document:', error);
|
||||
this.error = 'Failed to upload document';
|
||||
|
||||
let errorCode: string | null = null;
|
||||
|
||||
if (error instanceof Error) {
|
||||
try {
|
||||
const body = JSON.parse(error.message) as { error?: string };
|
||||
errorCode = body.error ?? null;
|
||||
} catch {
|
||||
// message was not JSON
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCode === 'file_too_large') {
|
||||
snackbar.error(t.get('ai_assistant.upload_error_file_too_large'));
|
||||
} else if (errorCode === 'unsupported_file_type') {
|
||||
snackbar.error(t.get('ai_assistant.upload_error_unsupported_type'));
|
||||
} else {
|
||||
snackbar.error(t.get('ai_assistant.upload_error_generic'));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.pptx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
accept=".pdf,.docx,.pptx,.odt,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.oasis.opendocument.text"
|
||||
class="hidden"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
@@ -255,9 +255,9 @@
|
||||
bind:ref={chatTextareaRef}
|
||||
bind:value={inputValue}
|
||||
{mentionItems}
|
||||
onSubmit={onSend}
|
||||
onSubmit={isUploading ? undefined : onSend}
|
||||
placeholder={$t('ai_assistant.input_placeholder')}
|
||||
disabled={isStreaming || isUploading}
|
||||
disabled={isStreaming}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
rows={2}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
? 'bg-white/15 text-white/90'
|
||||
: 'ui:bg-background/70 ui:text-muted-foreground'}"
|
||||
>
|
||||
<FileTextIcon size={12} class="shrink-0" />
|
||||
<FileTextIcon size={12} class="custom shrink-0" />
|
||||
<span class="min-w-0 truncate">{messageAttachment.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -53,6 +54,12 @@
|
||||
isExpanded = !defaultCollapsed;
|
||||
});
|
||||
|
||||
let isImplementing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (isBusy) isImplementing = false;
|
||||
});
|
||||
|
||||
let changeRequest = $state('');
|
||||
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
|
||||
|
||||
@@ -301,7 +308,18 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button size="sm" onclick={() => onImplement(editablePlan)} class="w-full">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isImplementing || isBusy}
|
||||
onclick={() => {
|
||||
isImplementing = true;
|
||||
onImplement(editablePlan);
|
||||
}}
|
||||
class="w-full"
|
||||
>
|
||||
{#if isImplementing || isBusy}
|
||||
<LoaderIcon size={14} class="animate-spin" />
|
||||
{/if}
|
||||
{$t('ai_assistant.plan_implement')}
|
||||
</Button>
|
||||
<div class="flex items-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user