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:
@@ -69,6 +69,7 @@ import {
|
|||||||
collectDocumentIds,
|
collectDocumentIds,
|
||||||
getActiveCourseTemplateId,
|
getActiveCourseTemplateId,
|
||||||
getLatestImplementationPlan,
|
getLatestImplementationPlan,
|
||||||
|
loadDocumentsMeta,
|
||||||
loadDocumentsText,
|
loadDocumentsText,
|
||||||
verifyExerciseBelongsToCourse,
|
verifyExerciseBelongsToCourse,
|
||||||
verifyLessonBelongsToCourse
|
verifyLessonBelongsToCourse
|
||||||
@@ -410,6 +411,7 @@ const agentCoreRouter = new Hono()
|
|||||||
.select({
|
.select({
|
||||||
title: schema.course.title,
|
title: schema.course.title,
|
||||||
description: schema.course.description,
|
description: schema.course.description,
|
||||||
|
metadata: schema.course.metadata,
|
||||||
organizationId: schema.group.organizationId
|
organizationId: schema.group.organizationId
|
||||||
})
|
})
|
||||||
.from(schema.course)
|
.from(schema.course)
|
||||||
@@ -449,7 +451,10 @@ const agentCoreRouter = new Hono()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const documentIds = collectDocumentIds(messages, context?.documentId);
|
const documentIds = collectDocumentIds(messages, context?.documentId);
|
||||||
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
|
const [documentText, documentAssets] = await Promise.all([
|
||||||
|
documentIds.length > 0 ? loadDocumentsText(documentIds, user.id) : Promise.resolve(undefined),
|
||||||
|
documentIds.length > 0 ? loadDocumentsMeta(documentIds) : Promise.resolve([])
|
||||||
|
]);
|
||||||
|
|
||||||
const existingSections = await listCourseSections(courseId);
|
const existingSections = await listCourseSections(courseId);
|
||||||
|
|
||||||
@@ -496,7 +501,9 @@ const agentCoreRouter = new Hono()
|
|||||||
exerciseTitle,
|
exerciseTitle,
|
||||||
documentId: context?.documentId,
|
documentId: context?.documentId,
|
||||||
documentText,
|
documentText,
|
||||||
existingSectionCount: existingSections.length
|
documentAssets,
|
||||||
|
existingSectionCount: existingSections.length,
|
||||||
|
isContentGroupingEnabled: courseRow.metadata?.isContentGroupingEnabled ?? true
|
||||||
};
|
};
|
||||||
|
|
||||||
trackAgentEvent(AgentEvent.CHAT_STARTED, {
|
trackAgentEvent(AgentEvent.CHAT_STARTED, {
|
||||||
@@ -531,7 +538,9 @@ const agentCoreRouter = new Hono()
|
|||||||
const agentTools =
|
const agentTools =
|
||||||
role === AgentRole.STUDENT
|
role === AgentRole.STUDENT
|
||||||
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
||||||
: filterToolsForChatMode(buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid }));
|
: filterToolsForChatMode(
|
||||||
|
buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid, documentAssets })
|
||||||
|
);
|
||||||
|
|
||||||
const contextManaged = await buildModelContextMessages({
|
const contextManaged = await buildModelContextMessages({
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|||||||
@@ -331,6 +331,10 @@ export async function resumeAgentRunState(runId: string, userId: string, orgId:
|
|||||||
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||||
const run = await getOwnedAgentRun(runId, userId, orgId);
|
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||||
|
|
||||||
|
if (ACTIVE_RUN_STATUSES.has(run.status)) {
|
||||||
|
return buildAgentRunDetail(run);
|
||||||
|
}
|
||||||
|
|
||||||
if (run.status !== 'failed') {
|
if (run.status !== 'failed') {
|
||||||
throw new AppError('Only failed agent runs can be retried', 'AGENT_RUN_NOT_RETRYABLE', 409);
|
throw new AppError('Only failed agent runs can be retried', 'AGENT_RUN_NOT_RETRYABLE', 409);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api';
|
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 {
|
import type {
|
||||||
AgentConversation,
|
AgentConversation,
|
||||||
AgentConversationCreateData,
|
AgentConversationCreateData,
|
||||||
@@ -158,7 +160,25 @@ class AiAssistantApi extends BaseApiWithErrors {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading document:', 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;
|
return null;
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
<input
|
<input
|
||||||
bind:this={fileInputEl}
|
bind:this={fileInputEl}
|
||||||
type="file"
|
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"
|
class="hidden"
|
||||||
onchange={handleFileChange}
|
onchange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
@@ -255,9 +255,9 @@
|
|||||||
bind:ref={chatTextareaRef}
|
bind:ref={chatTextareaRef}
|
||||||
bind:value={inputValue}
|
bind:value={inputValue}
|
||||||
{mentionItems}
|
{mentionItems}
|
||||||
onSubmit={onSend}
|
onSubmit={isUploading ? undefined : onSend}
|
||||||
placeholder={$t('ai_assistant.input_placeholder')}
|
placeholder={$t('ai_assistant.input_placeholder')}
|
||||||
disabled={isStreaming || isUploading}
|
disabled={isStreaming}
|
||||||
typeLabel={getTypeLabel}
|
typeLabel={getTypeLabel}
|
||||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
? 'bg-white/15 text-white/90'
|
? 'bg-white/15 text-white/90'
|
||||||
: 'ui:bg-background/70 ui:text-muted-foreground'}"
|
: '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>
|
<span class="min-w-0 truncate">{messageAttachment.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
||||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||||
|
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { Textarea } from '@cio/ui/base/textarea';
|
import { Textarea } from '@cio/ui/base/textarea';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
@@ -53,6 +54,12 @@
|
|||||||
isExpanded = !defaultCollapsed;
|
isExpanded = !defaultCollapsed;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let isImplementing = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isBusy) isImplementing = false;
|
||||||
|
});
|
||||||
|
|
||||||
let changeRequest = $state('');
|
let changeRequest = $state('');
|
||||||
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
|
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
|
||||||
|
|
||||||
@@ -301,7 +308,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<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')}
|
{$t('ai_assistant.plan_implement')}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-end gap-2">
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Spørg AI",
|
"ask_ai": "Spørg AI",
|
||||||
"content_input_placeholder": "Spørg om dette indhold...",
|
"content_input_placeholder": "Spørg om dette indhold...",
|
||||||
"ask_ai_expand_aria": "Åbn Spørg AI-input",
|
"ask_ai_expand_aria": "Åbn Spørg AI-input",
|
||||||
"ask_ai_input_aria": "Spørg AI om dette indhold"
|
"ask_ai_input_aria": "Spørg AI om dette indhold",
|
||||||
|
"upload_error_file_too_large": "Filen er for stor. Maksimal størrelse er 5 MB.",
|
||||||
|
"upload_error_unsupported_type": "Filtype understøttes ikke. Upload venligst en PDF-, DOCX-, PPTX- eller ODT-fil.",
|
||||||
|
"upload_error_generic": "Kunne ikke uploade dokument. Prøv venligst igen."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "Widgets",
|
"heading": "Widgets",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Fragen Sie die KI",
|
"ask_ai": "Fragen Sie die KI",
|
||||||
"content_input_placeholder": "Fragen Sie nach diesem Inhalt…",
|
"content_input_placeholder": "Fragen Sie nach diesem Inhalt…",
|
||||||
"ask_ai_expand_aria": "Öffnen Sie „Ask AI Input“.",
|
"ask_ai_expand_aria": "Öffnen Sie „Ask AI Input“.",
|
||||||
"ask_ai_input_aria": "Fragen Sie AI nach diesem Inhalt"
|
"ask_ai_input_aria": "Fragen Sie AI nach diesem Inhalt",
|
||||||
|
"upload_error_file_too_large": "Die Datei ist zu groß. Die maximale Größe beträgt 5 MB.",
|
||||||
|
"upload_error_unsupported_type": "Dateityp wird nicht unterstützt. Bitte laden Sie eine PDF-, DOCX-, PPTX- oder ODT-Datei hoch.",
|
||||||
|
"upload_error_generic": "Das Hochladen des Dokuments ist fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "Widgets",
|
"heading": "Widgets",
|
||||||
|
|||||||
@@ -4613,7 +4613,10 @@
|
|||||||
"ask_ai_expand_aria": "Open Ask AI input",
|
"ask_ai_expand_aria": "Open Ask AI input",
|
||||||
"ask_ai_input_aria": "Ask AI about this content",
|
"ask_ai_input_aria": "Ask AI about this content",
|
||||||
"quote_lesson_note": "Quote in assistant",
|
"quote_lesson_note": "Quote in assistant",
|
||||||
"attach_document": "Attach a document (PDF, DOCX, PPTX)",
|
"attach_document": "Attach a document (PDF, DOCX, PPTX, ODT)",
|
||||||
|
"upload_error_file_too_large": "File is too large. Maximum size is 5 MB.",
|
||||||
|
"upload_error_unsupported_type": "File type not supported. Please upload a PDF, DOCX, PPTX, or ODT file.",
|
||||||
|
"upload_error_generic": "Failed to upload document. Please try again.",
|
||||||
"upgrade_to_upload": "Upgrade to upload documents",
|
"upgrade_to_upload": "Upgrade to upload documents",
|
||||||
"tokens_exhausted": "Monthly AI token limit reached.",
|
"tokens_exhausted": "Monthly AI token limit reached.",
|
||||||
"tokens_exhausted_buy_more": "Buy more",
|
"tokens_exhausted_buy_more": "Buy more",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Pregúntale a la IA",
|
"ask_ai": "Pregúntale a la IA",
|
||||||
"content_input_placeholder": "Pregunta por este contenido...",
|
"content_input_placeholder": "Pregunta por este contenido...",
|
||||||
"ask_ai_expand_aria": "Abrir Preguntar entrada a AI",
|
"ask_ai_expand_aria": "Abrir Preguntar entrada a AI",
|
||||||
"ask_ai_input_aria": "Pregúntale a AI sobre este contenido"
|
"ask_ai_input_aria": "Pregúntale a AI sobre este contenido",
|
||||||
|
"upload_error_file_too_large": "El archivo es demasiado grande. El tamaño máximo es 5 MB.",
|
||||||
|
"upload_error_unsupported_type": "Tipo de archivo no compatible. Cargue un archivo PDF, DOCX, PPTX u ODT.",
|
||||||
|
"upload_error_generic": "No se pudo cargar el documento. Por favor inténtalo de nuevo."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "widgets",
|
"heading": "widgets",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Demandez à l'IA",
|
"ask_ai": "Demandez à l'IA",
|
||||||
"content_input_placeholder": "Renseignez-vous sur ce contenu…",
|
"content_input_placeholder": "Renseignez-vous sur ce contenu…",
|
||||||
"ask_ai_expand_aria": "Ouvrir la saisie de Ask AI",
|
"ask_ai_expand_aria": "Ouvrir la saisie de Ask AI",
|
||||||
"ask_ai_input_aria": "Interrogez AI à propos de ce contenu"
|
"ask_ai_input_aria": "Interrogez AI à propos de ce contenu",
|
||||||
|
"upload_error_file_too_large": "Le fichier est trop volumineux. La taille maximale est de 5 Mo.",
|
||||||
|
"upload_error_unsupported_type": "Type de fichier non pris en charge. Veuillez télécharger un fichier PDF, DOCX, PPTX ou ODT.",
|
||||||
|
"upload_error_generic": "Échec du téléchargement du document. Veuillez réessayer."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "Widgets",
|
"heading": "Widgets",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "एआई से पूछें",
|
"ask_ai": "एआई से पूछें",
|
||||||
"content_input_placeholder": "इस सामग्री के बारे में पूछें...",
|
"content_input_placeholder": "इस सामग्री के बारे में पूछें...",
|
||||||
"ask_ai_expand_aria": "एआई इनपुट पूछें खोलें",
|
"ask_ai_expand_aria": "एआई इनपुट पूछें खोलें",
|
||||||
"ask_ai_input_aria": "इस सामग्री के बारे में AI से पूछें"
|
"ask_ai_input_aria": "इस सामग्री के बारे में AI से पूछें",
|
||||||
|
"upload_error_file_too_large": "फ़ाइल बहुत बड़ी है। अधिकतम आकार 5 एमबी है.",
|
||||||
|
"upload_error_unsupported_type": "फ़ाइल प्रकार समर्थित नहीं है. कृपया एक PDF, DOCX, PPTX, या ODT फ़ाइल अपलोड करें।",
|
||||||
|
"upload_error_generic": "दस्तावेज़ अपलोड करने में विफल. कृपया पुन: प्रयास करें।"
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "विजेट्स",
|
"heading": "विजेट्स",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Zapytaj AI",
|
"ask_ai": "Zapytaj AI",
|
||||||
"content_input_placeholder": "Zapytaj o tę treść…",
|
"content_input_placeholder": "Zapytaj o tę treść…",
|
||||||
"ask_ai_expand_aria": "Otwórz wejście Zapytaj AI",
|
"ask_ai_expand_aria": "Otwórz wejście Zapytaj AI",
|
||||||
"ask_ai_input_aria": "Zapytaj AI o tę treść"
|
"ask_ai_input_aria": "Zapytaj AI o tę treść",
|
||||||
|
"upload_error_file_too_large": "Plik jest za duży. Maksymalny rozmiar to 5 MB.",
|
||||||
|
"upload_error_unsupported_type": "Typ pliku nie jest obsługiwany. Prześlij plik PDF, DOCX, PPTX lub ODT.",
|
||||||
|
"upload_error_generic": "Nie udało się przesłać dokumentu. Spróbuj ponownie."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "Widżety",
|
"heading": "Widżety",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Pergunte à IA",
|
"ask_ai": "Pergunte à IA",
|
||||||
"content_input_placeholder": "Pergunte sobre este conteúdo…",
|
"content_input_placeholder": "Pergunte sobre este conteúdo…",
|
||||||
"ask_ai_expand_aria": "Abra a entrada Ask AI",
|
"ask_ai_expand_aria": "Abra a entrada Ask AI",
|
||||||
"ask_ai_input_aria": "Pergunte à IA sobre este conteúdo"
|
"ask_ai_input_aria": "Pergunte à IA sobre este conteúdo",
|
||||||
|
"upload_error_file_too_large": "O arquivo é muito grande. O tamanho máximo é 5 MB.",
|
||||||
|
"upload_error_unsupported_type": "Tipo de arquivo não suportado. Faça upload de um arquivo PDF, DOCX, PPTX ou ODT.",
|
||||||
|
"upload_error_generic": "Falha ao carregar o documento. Por favor, tente novamente."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "Widgets",
|
"heading": "Widgets",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Спросите ИИ",
|
"ask_ai": "Спросите ИИ",
|
||||||
"content_input_placeholder": "Спросите об этом контенте…",
|
"content_input_placeholder": "Спросите об этом контенте…",
|
||||||
"ask_ai_expand_aria": "Открыть вход Ask AI",
|
"ask_ai_expand_aria": "Открыть вход Ask AI",
|
||||||
"ask_ai_input_aria": "Спросите ИИ об этом контенте"
|
"ask_ai_input_aria": "Спросите ИИ об этом контенте",
|
||||||
|
"upload_error_file_too_large": "Файл слишком большой. Максимальный размер — 5 МБ.",
|
||||||
|
"upload_error_unsupported_type": "Тип файла не поддерживается. Загрузите файл PDF, DOCX, PPTX или ODT.",
|
||||||
|
"upload_error_generic": "Не удалось загрузить документ. Пожалуйста, попробуйте еще раз."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "Виджеты",
|
"heading": "Виджеты",
|
||||||
|
|||||||
@@ -4316,7 +4316,10 @@
|
|||||||
"ask_ai": "Hỏi AI",
|
"ask_ai": "Hỏi AI",
|
||||||
"content_input_placeholder": "Hỏi về nội dung này…",
|
"content_input_placeholder": "Hỏi về nội dung này…",
|
||||||
"ask_ai_expand_aria": "Mở đầu vào Hỏi AI",
|
"ask_ai_expand_aria": "Mở đầu vào Hỏi AI",
|
||||||
"ask_ai_input_aria": "Hỏi AI về nội dung này"
|
"ask_ai_input_aria": "Hỏi AI về nội dung này",
|
||||||
|
"upload_error_file_too_large": "Tệp quá lớn. Kích thước tối đa là 5 MB.",
|
||||||
|
"upload_error_unsupported_type": "Loại tệp không được hỗ trợ. Vui lòng tải lên tệp PDF, DOCX, PPTX hoặc ODT.",
|
||||||
|
"upload_error_generic": "Không thể tải tài liệu lên. Vui lòng thử lại."
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"heading": "Widget",
|
"heading": "Widget",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dotenv/config';
|
|||||||
import { env } from './config/env';
|
import { env } from './config/env';
|
||||||
import { warnIfFfmpegMissing } from './utils/ffmpeg';
|
import { warnIfFfmpegMissing } from './utils/ffmpeg';
|
||||||
import { log } from './utils/logger';
|
import { log } from './utils/logger';
|
||||||
|
import { connectRedis } from '@cio/core/utils/redis/redis';
|
||||||
|
|
||||||
void warnIfFfmpegMissing();
|
void warnIfFfmpegMissing();
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ if (!env.REDIS_URL) {
|
|||||||
throw new Error('REDIS_URL is required to start a worker process.');
|
throw new Error('REDIS_URL is required to start a worker process.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void connectRedis();
|
||||||
|
|
||||||
log.info('worker-bootstrap', {
|
log.info('worker-bootstrap', {
|
||||||
nodeEnv: env.NODE_ENV,
|
nodeEnv: env.NODE_ENV,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
|
|||||||
@@ -279,6 +279,8 @@ If this conversation contains any successful \`fetch_documentation_url\` tool re
|
|||||||
|
|
||||||
When any \`fetch_documentation_url\` results exist in this conversation, **every** lesson you create or update via \`update_lesson_content\` MUST end with a References section so the teacher can verify your sourcing. Skip the References section ONLY when zero docs have been fetched in the entire conversation.
|
When any \`fetch_documentation_url\` results exist in this conversation, **every** lesson you create or update via \`update_lesson_content\` MUST end with a References section so the teacher can verify your sourcing. Skip the References section ONLY when zero docs have been fetched in the entire conversation.
|
||||||
|
|
||||||
|
**Uploaded documents do NOT count as fetched documentation for the purposes of References.** If the only source material is an uploaded document (no \`fetch_documentation_url\` calls were made), omit the References section entirely — do not fabricate URLs or cite the document by a made-up link.
|
||||||
|
|
||||||
Format the section as the last block of the lesson HTML, in this exact shape:
|
Format the section as the last block of the lesson HTML, in this exact shape:
|
||||||
|
|
||||||
\`\`\`html
|
\`\`\`html
|
||||||
@@ -431,11 +433,30 @@ export function buildTeacherContextMessage(
|
|||||||
contextLines.push(exerciseInfo);
|
contextLines.push(exerciseInfo);
|
||||||
}
|
}
|
||||||
if (context.documentText) {
|
if (context.documentText) {
|
||||||
|
const docAssets = context.documentAssets ?? [];
|
||||||
|
const assetLines = docAssets
|
||||||
|
.filter((d) => d.assetId)
|
||||||
|
.map((d) => `- documentId: ${d.documentId}, fileName: ${d.fileName}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const courseMaterialsInstruction =
|
||||||
|
assetLines.length > 0
|
||||||
|
? `\n\nWhen implementing a course plan, create a lesson titled **"Course Materials"** as the **first lesson of the first section**. Give it no text content. Then immediately call \`attach_document_to_lesson\` for each document listed below to attach the original file to that lesson:\n${assetLines}`
|
||||||
|
: '';
|
||||||
|
|
||||||
contextLines.push(
|
contextLines.push(
|
||||||
`The teacher has uploaded a document. Use this content as the source material for course planning and content generation:\n\n<document>\n${context.documentText}\n</document>`
|
`The teacher has uploaded a document. Use this content as the source material for course planning and content generation:${courseMaterialsInstruction}\n\n<document>\n${context.documentText}\n</document>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (context.existingSectionCount && context.existingSectionCount > 0) {
|
if (context.isContentGroupingEnabled === false) {
|
||||||
|
contextLines.push(
|
||||||
|
`**Content grouping is DISABLED for this course.** Lessons and exercises are displayed as a flat ordered list — sections are invisible to students and teachers. Follow these rules strictly:\n` +
|
||||||
|
`- Do NOT plan or create multiple sections. Use exactly ONE section as a technical container for all content.\n` +
|
||||||
|
`- In \`generate_course_plan\`, output a single section whose title matches the course title. Place all lessons and exercises as items inside that one section, ordered sequentially (order 0, 1, 2, …).\n` +
|
||||||
|
`- When implementing a plan, create that one section first, then add every lesson and exercise into it with strictly ascending \`order\` values (0, 1, 2, …). The order field is the only thing that controls the visible sequence.\n` +
|
||||||
|
`- Never call \`create_section\` more than once for this course.`
|
||||||
|
);
|
||||||
|
} else if (context.existingSectionCount && context.existingSectionCount > 0) {
|
||||||
contextLines.push(
|
contextLines.push(
|
||||||
`This course already has ${context.existingSectionCount} sections. When creating new sections, set their order values starting after the existing sections.`
|
`This course already has ${context.existingSectionCount} sections. When creating new sections, set their order values starting after the existing sections.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ export interface AgentContext {
|
|||||||
exerciseTitle?: string;
|
exerciseTitle?: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
documentText?: string;
|
documentText?: string;
|
||||||
|
/** Metadata for documents attached to this conversation — used to attach originals to lessons. */
|
||||||
|
documentAssets?: { documentId: string; assetId: string | null; fileName: string }[];
|
||||||
existingSectionCount?: number;
|
existingSectionCount?: number;
|
||||||
|
/** When false, content grouping is disabled: lessons/exercises are displayed as a flat ordered list and sections are hidden from students. */
|
||||||
|
isContentGroupingEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Course Plan (generated by the agent in Plan Mode) ───────────────────────
|
// ─── Course Plan (generated by the agent in Plan Mode) ───────────────────────
|
||||||
@@ -215,11 +219,12 @@ export const MAX_STEPS_PER_ROUND = 15;
|
|||||||
export const SUPPORTED_DOCUMENT_TYPES = [
|
export const SUPPORTED_DOCUMENT_TYPES = [
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'application/vnd.oasis.opendocument.text'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Supported file extensions for document upload */
|
/** Supported file extensions for document upload */
|
||||||
export const SUPPORTED_DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.pptx'] as const;
|
export const SUPPORTED_DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.pptx', '.odt'] as const;
|
||||||
|
|
||||||
/** Token cost estimation heuristics (tokens per item) */
|
/** Token cost estimation heuristics (tokens per item) */
|
||||||
export const TOKEN_COST_ESTIMATES = {
|
export const TOKEN_COST_ESTIMATES = {
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export const createLessonParam = z.object({
|
|||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
order: z.number().int().min(0)
|
order: z.number().int().min(0)
|
||||||
});
|
});
|
||||||
|
export const attachDocumentToLessonParam = z.object({
|
||||||
|
lessonId: z.string().describe('ID of the lesson to attach the document to'),
|
||||||
|
documentId: z.string().describe('documentId of the uploaded document (from the conversation context)')
|
||||||
|
});
|
||||||
export const updateLessonParam = z
|
export const updateLessonParam = z
|
||||||
.object({
|
.object({
|
||||||
lessonId: z.string(),
|
lessonId: z.string(),
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { AppError } from '@cio/utils/errors';
|
import { AppError } from '@cio/utils/errors';
|
||||||
import { getDocumentText } from './document';
|
import { getDocumentText } from './document';
|
||||||
import { redis } from '../../utils/redis/redis';
|
import { redis } from '../../utils/redis/redis';
|
||||||
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
|
import {
|
||||||
|
getCourseSectionBinding,
|
||||||
|
getExerciseCourseBinding,
|
||||||
|
getLessonCourseBinding,
|
||||||
|
getChatDocumentsByIds
|
||||||
|
} from '@cio/db/queries/agent';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
|
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
|
||||||
|
|
||||||
@@ -113,6 +118,16 @@ export function collectDocumentIds(messages: unknown[], currentDocumentId?: stri
|
|||||||
return Array.from(ids);
|
return Array.from(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadDocumentsMeta(
|
||||||
|
documentIds: string[]
|
||||||
|
): Promise<{ documentId: string; assetId: string | null; fileName: string }[]> {
|
||||||
|
if (documentIds.length === 0) return [];
|
||||||
|
|
||||||
|
const records = await getChatDocumentsByIds(documentIds);
|
||||||
|
|
||||||
|
return records.map((r) => ({ documentId: r.id, assetId: r.assetId, fileName: r.fileName }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadDocumentsText(documentIds: string[], userId: string): Promise<string | undefined> {
|
export async function loadDocumentsText(documentIds: string[], userId: string): Promise<string | undefined> {
|
||||||
const loaded = await Promise.all(
|
const loaded = await Promise.all(
|
||||||
documentIds.map(async (id) => {
|
documentIds.map(async (id) => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
coursePlanParam,
|
coursePlanParam,
|
||||||
createExerciseParam,
|
createExerciseParam,
|
||||||
createExerciseSectionParam,
|
createExerciseSectionParam,
|
||||||
|
attachDocumentToLessonParam,
|
||||||
createLessonParam,
|
createLessonParam,
|
||||||
createSectionParam,
|
createSectionParam,
|
||||||
emptyParam,
|
emptyParam,
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
updateQuestionsParam,
|
updateQuestionsParam,
|
||||||
updateSectionParam
|
updateSectionParam
|
||||||
} from './agent-tool-schemas';
|
} from './agent-tool-schemas';
|
||||||
|
import { attachAssetService } from '../assets/assets';
|
||||||
import { fetchDocumentationUrl } from './fetch-url';
|
import { fetchDocumentationUrl } from './fetch-url';
|
||||||
|
|
||||||
const DURABLE_AGENT_TOOL_NAMES = new Set([
|
const DURABLE_AGENT_TOOL_NAMES = new Set([
|
||||||
@@ -69,6 +71,7 @@ const DURABLE_AGENT_TOOL_NAMES = new Set([
|
|||||||
'create_lesson',
|
'create_lesson',
|
||||||
'update_lesson',
|
'update_lesson',
|
||||||
'update_lesson_content',
|
'update_lesson_content',
|
||||||
|
'attach_document_to_lesson',
|
||||||
'create_exercise',
|
'create_exercise',
|
||||||
'update_exercise',
|
'update_exercise',
|
||||||
'update_exercise_section',
|
'update_exercise_section',
|
||||||
@@ -89,6 +92,8 @@ export interface BuildAgentToolsOptions {
|
|||||||
* premium-only question types.
|
* premium-only question types.
|
||||||
*/
|
*/
|
||||||
isOrgOnPaidPlan?: boolean;
|
isOrgOnPaidPlan?: boolean;
|
||||||
|
/** Documents uploaded in this conversation — used by attach_document_to_lesson. */
|
||||||
|
documentAssets?: { documentId: string; assetId: string | null; fileName: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
|
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
|
||||||
@@ -451,6 +456,7 @@ export function buildAgentTools(
|
|||||||
options: BuildAgentToolsOptions = {}
|
options: BuildAgentToolsOptions = {}
|
||||||
) {
|
) {
|
||||||
const isOrgOnPaidPlan = options.isOrgOnPaidPlan ?? true;
|
const isOrgOnPaidPlan = options.isOrgOnPaidPlan ?? true;
|
||||||
|
const documentAssets = options.documentAssets ?? [];
|
||||||
|
|
||||||
const executeAgentTool = <TArgs, TResult>(
|
const executeAgentTool = <TArgs, TResult>(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
@@ -611,6 +617,44 @@ export function buildAgentTools(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
attach_document_to_lesson: tool({
|
||||||
|
description:
|
||||||
|
'Attach an uploaded document from this conversation to a lesson as a downloadable material. Use this to create the Course Materials lesson — create the lesson first (with no content), then call this tool.',
|
||||||
|
inputSchema: attachDocumentToLessonParam,
|
||||||
|
execute: async (args) => {
|
||||||
|
return executeAgentTool('attach_document_to_lesson', { orgId, userId, courseId, args }, async () => {
|
||||||
|
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||||
|
|
||||||
|
const docAsset = documentAssets.find((d) => d.documentId === args.documentId);
|
||||||
|
|
||||||
|
if (!docAsset) {
|
||||||
|
throw new AppError(
|
||||||
|
`Document "${args.documentId}" not found in this conversation`,
|
||||||
|
'DOCUMENT_NOT_FOUND',
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docAsset.assetId) {
|
||||||
|
throw new AppError(
|
||||||
|
`Document "${args.documentId}" has no associated asset and cannot be attached`,
|
||||||
|
'DOCUMENT_NO_ASSET',
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await attachAssetService(orgId, docAsset.assetId, userId, {
|
||||||
|
targetType: 'lesson',
|
||||||
|
targetId: args.lessonId,
|
||||||
|
slotType: 'lesson_document',
|
||||||
|
position: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return { lessonId: args.lessonId, fileName: docAsset.fileName, attached: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
create_exercise: tool({
|
create_exercise: tool({
|
||||||
description: 'Create a new exercise with questions and answer options in this course.',
|
description: 'Create a new exercise with questions and answer options in this course.',
|
||||||
inputSchema: createExerciseParam,
|
inputSchema: createExerciseParam,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
collectDocumentIds,
|
collectDocumentIds,
|
||||||
getActiveCourseTemplateId,
|
getActiveCourseTemplateId,
|
||||||
getLatestImplementationPlan,
|
getLatestImplementationPlan,
|
||||||
|
loadDocumentsMeta,
|
||||||
loadDocumentsText
|
loadDocumentsText
|
||||||
} from './chat-context';
|
} from './chat-context';
|
||||||
import { buildAgentTools } from './chat-tools';
|
import { buildAgentTools } from './chat-tools';
|
||||||
@@ -145,7 +146,10 @@ export async function runAgentCourseGenerationJob(input: RunAgentCourseGeneratio
|
|||||||
: null;
|
: null;
|
||||||
const visibleMessages = (conversation?.messages ?? []) as Array<Record<string, unknown>>;
|
const visibleMessages = (conversation?.messages ?? []) as Array<Record<string, unknown>>;
|
||||||
const documentIds = collectDocumentIds(visibleMessages);
|
const documentIds = collectDocumentIds(visibleMessages);
|
||||||
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, initialRun.userId) : undefined;
|
const [documentText, documentAssets] = await Promise.all([
|
||||||
|
documentIds.length > 0 ? loadDocumentsText(documentIds, initialRun.userId) : Promise.resolve(undefined),
|
||||||
|
documentIds.length > 0 ? loadDocumentsMeta(documentIds) : Promise.resolve([])
|
||||||
|
]);
|
||||||
const existingSections = await listCourseSections(initialRun.courseId);
|
const existingSections = await listCourseSections(initialRun.courseId);
|
||||||
const executionCursor = initialRun.executionCursor ?? {};
|
const executionCursor = initialRun.executionCursor ?? {};
|
||||||
const modelId = getRunModelId(executionCursor);
|
const modelId = getRunModelId(executionCursor);
|
||||||
@@ -166,6 +170,7 @@ export async function runAgentCourseGenerationJob(input: RunAgentCourseGeneratio
|
|||||||
role: AgentRole.TEACHER,
|
role: AgentRole.TEACHER,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
documentText,
|
documentText,
|
||||||
|
documentAssets,
|
||||||
existingSectionCount: existingSections.length
|
existingSectionCount: existingSections.length
|
||||||
};
|
};
|
||||||
const approvedPlan = initialRun.approvedPlan ?? getLatestImplementationPlan(visibleMessages);
|
const approvedPlan = initialRun.approvedPlan ?? getLatestImplementationPlan(visibleMessages);
|
||||||
@@ -208,7 +213,8 @@ export async function runAgentCourseGenerationJob(input: RunAgentCourseGeneratio
|
|||||||
const isOrgPaid = await isOrgOnPaidPlan(initialRun.orgId);
|
const isOrgPaid = await isOrgOnPaidPlan(initialRun.orgId);
|
||||||
const agentTools = buildAgentTools(initialRun.orgId, initialRun.userId, initialRun.courseId, visibleMessages, {
|
const agentTools = buildAgentTools(initialRun.orgId, initialRun.userId, initialRun.courseId, visibleMessages, {
|
||||||
runId: initialRun.id,
|
runId: initialRun.id,
|
||||||
isOrgOnPaidPlan: isOrgPaid
|
isOrgOnPaidPlan: isOrgPaid,
|
||||||
|
documentAssets
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let roundIndex = 0; roundIndex < MAX_WORKER_ROUNDS; roundIndex += 1) {
|
for (let roundIndex = 0; roundIndex < MAX_WORKER_ROUNDS; roundIndex += 1) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { createAssetFromUploadService } from '../assets/assets';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an uploaded document, store extracted text in Redis (hot cache) and Postgres
|
* Parse an uploaded document, store extracted text in Redis (hot cache) and Postgres
|
||||||
* (durable, scoped to a conversation). Supports PDF, DOCX, and PPTX files.
|
* (durable, scoped to a conversation). Supports PDF, DOCX, PPTX, and ODT files.
|
||||||
*/
|
*/
|
||||||
export async function parseAndStoreDocument(
|
export async function parseAndStoreDocument(
|
||||||
file: File,
|
file: File,
|
||||||
@@ -56,6 +56,9 @@ export async function parseAndStoreDocument(
|
|||||||
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||||
({ text: extractedText, pageCount } = await extractPptxText(buffer));
|
({ text: extractedText, pageCount } = await extractPptxText(buffer));
|
||||||
break;
|
break;
|
||||||
|
case 'application/vnd.oasis.opendocument.text':
|
||||||
|
extractedText = await extractOdtText(buffer);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new AppError('Unsupported file type', 'UNSUPPORTED_FILE_TYPE', 415);
|
throw new AppError('Unsupported file type', 'UNSUPPORTED_FILE_TYPE', 415);
|
||||||
}
|
}
|
||||||
@@ -197,6 +200,32 @@ async function extractDocxText(buffer: Buffer): Promise<string> {
|
|||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractOdtText(buffer: Buffer): Promise<string> {
|
||||||
|
try {
|
||||||
|
const JSZip = (await import('jszip')).default;
|
||||||
|
const zip = await JSZip.loadAsync(buffer);
|
||||||
|
|
||||||
|
const contentFile = zip.files['content.xml'];
|
||||||
|
|
||||||
|
if (!contentFile) {
|
||||||
|
throw new Error('content.xml not found in ODT archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await contentFile.async('text');
|
||||||
|
const text = xml
|
||||||
|
.replace(/<text:line-break[^>]*\/?>/g, '\n')
|
||||||
|
.replace(/<\/text:p>/g, '\n')
|
||||||
|
.replace(/<\/text:h>/g, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return text;
|
||||||
|
} catch {
|
||||||
|
throw new AppError('Failed to parse ODT file', 'ODT_PARSE_ERROR', 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function extractPptxText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
|
async function extractPptxText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
|
||||||
// pptx-parser may not be available yet — use a simpler approach
|
// pptx-parser may not be available yet — use a simpler approach
|
||||||
// For now, try to use officegen or a basic XML extraction
|
// For now, try to use officegen or a basic XML extraction
|
||||||
|
|||||||
Reference in New Issue
Block a user