From 95c9e7c036afbc56e3edb58ed0033561e5d64208 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 25 Mar 2026 17:31:00 +0100 Subject: [PATCH] Add language assistant features and improve encryption handling: Implement a new route and controller method for sending messages to the language assistant, enhancing user interaction within lessons. Update the encryption utility to support both base64 and hex formats for better compatibility with existing data. Enhance localization files to include new terms related to the language assistant in English, German, and Spanish, improving user experience across languages. --- backend/controllers/vocabController.js | 2 +- ...010000-expand-user-param-value-to-text.cjs | 18 ++ backend/models/community/user_param.js | 2 +- backend/routers/vocabRouter.js | 2 +- backend/services/vocabService.js | 215 +++++++++++++++ .../sql/expand_user_param_value_to_text.sql | 2 + backend/utils/encryption.js | 25 +- .../src/i18n/locales/de/socialnetwork.json | 29 +- .../src/i18n/locales/en/socialnetwork.json | 29 +- .../src/i18n/locales/es/socialnetwork.json | 29 +- frontend/src/views/social/VocabCourseView.vue | 85 +++++- frontend/src/views/social/VocabLessonView.vue | 261 +++++++++++++++++- 12 files changed, 685 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/20260325010000-expand-user-param-value-to-text.cjs create mode 100644 backend/sql/expand_user_param_value_to_text.sql diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index 3cb6dbc..f6fd579 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -55,6 +55,7 @@ class VocabController { this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId)); this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body)); this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId)); + this.sendLessonAssistantMessage = this._wrapWithUser((userId, req) => this.service.sendLessonAssistantMessage(userId, req.params.lessonId, req.body), { successStatus: 201 }); } _wrapWithUser(fn, { successStatus = 200 } = {}) { @@ -77,4 +78,3 @@ class VocabController { export default VocabController; - diff --git a/backend/migrations/20260325010000-expand-user-param-value-to-text.cjs b/backend/migrations/20260325010000-expand-user-param-value-to-text.cjs new file mode 100644 index 0000000..d300353 --- /dev/null +++ b/backend/migrations/20260325010000-expand-user-param-value-to-text.cjs @@ -0,0 +1,18 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE community.user_param + ALTER COLUMN value TYPE TEXT; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE community.user_param + ALTER COLUMN value TYPE VARCHAR(255); + `); + } +}; diff --git a/backend/models/community/user_param.js b/backend/models/community/user_param.js index 6423a6e..cd360f4 100644 --- a/backend/models/community/user_param.js +++ b/backend/models/community/user_param.js @@ -14,7 +14,7 @@ const UserParam = sequelize.define('user_param', { allowNull: false }, value: { - type: DataTypes.STRING, + type: DataTypes.TEXT, allowNull: false, set(value) { if (value) { diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index 5585ebe..59d81a3 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -48,6 +48,7 @@ router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress); // Grammar Exercises router.get('/grammar/exercise-types', vocabController.getExerciseTypes); +router.post('/lessons/:lessonId/assistant', vocabController.sendLessonAssistantMessage); router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise); router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson); router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress); @@ -58,4 +59,3 @@ router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExe export default router; - diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index bca31b0..cd46739 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -7,9 +7,12 @@ import VocabCourseProgress from '../models/community/vocab_course_progress.js'; import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js'; import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js'; import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js'; +import UserParamType from '../models/type/user_param.js'; +import UserParam from '../models/community/user_param.js'; import { sequelize } from '../utils/sequelize.js'; import { notifyUser } from '../utils/socket.js'; import { Op } from 'sequelize'; +import { decrypt } from '../utils/encryption.js'; export default class VocabService { async _getUserByHashedId(hashedUserId) { @@ -22,6 +25,117 @@ export default class VocabService { return user; } + async _getUserLlmConfig(userId) { + const [settingsType, apiKeyType] = await Promise.all([ + UserParamType.findOne({ where: { description: 'llm_settings' } }), + UserParamType.findOne({ where: { description: 'llm_api_key' } }) + ]); + + if (!settingsType || !apiKeyType) { + return { + enabled: false, + baseUrl: '', + model: 'gpt-4o-mini', + hasKey: false, + apiKey: null, + configured: false + }; + } + + const [settingsRow, keyRow] = await Promise.all([ + UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }), + UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } }) + ]); + + let parsed = {}; + if (settingsRow?.value) { + try { + parsed = JSON.parse(settingsRow.value); + } catch { + parsed = {}; + } + } + + const decryptedKey = keyRow?.value ? decrypt(keyRow.value) : null; + const hasKey = Boolean(decryptedKey && String(decryptedKey).trim()); + const enabled = parsed.enabled !== false; + const baseUrl = String(parsed.baseUrl || '').trim(); + + return { + enabled, + baseUrl, + model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini', + hasKey, + apiKey: hasKey ? decryptedKey : null, + configured: enabled && (hasKey || Boolean(baseUrl)) + }; + } + + _sanitizeAssistantHistory(history) { + if (!Array.isArray(history)) { + return []; + } + + return history + .slice(-8) + .map((entry) => ({ + role: entry?.role === 'assistant' ? 'assistant' : 'user', + content: String(entry?.content || '').trim() + })) + .filter((entry) => entry.content); + } + + _buildLessonAssistantSystemPrompt(lesson, mode = 'practice') { + const didactics = lesson?.didactics || {}; + const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : []; + const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : []; + const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : []; + const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : []; + + const modeDirectives = { + explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.', + practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.', + correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.' + }; + + return [ + 'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.', + 'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.', + modeDirectives[mode] || modeDirectives.practice, + 'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.', + `Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`, + `Lektion: ${lesson?.title || 'Unbekannte Lektion'}`, + lesson?.description ? `Beschreibung: ${lesson.description}` : '', + learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '', + corePatterns.length ? `Kernmuster: ${corePatterns.join(' | ')}` : '', + speakingPrompts.length + ? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}` + : '', + practicalTasks.length + ? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}` + : '', + 'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.' + ].filter(Boolean).join('\n'); + } + + _extractAssistantContent(responseData) { + const rawContent = responseData?.choices?.[0]?.message?.content; + if (typeof rawContent === 'string') { + return rawContent.trim(); + } + if (Array.isArray(rawContent)) { + return rawContent + .map((item) => { + if (typeof item === 'string') return item; + if (item?.type === 'text') return item.text || ''; + return ''; + }) + .join('\n') + .trim(); + } + return ''; + } + _normalizeLexeme(text) { return String(text || '') .trim() @@ -1019,6 +1133,107 @@ export default class VocabService { return plainLesson; } + async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) { + const user = await this._getUserByHashedId(hashedUserId); + const lesson = await this.getLesson(hashedUserId, lessonId); + const config = await this._getUserLlmConfig(user.id); + + if (!config.enabled) { + const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.'); + err.status = 400; + throw err; + } + + if (!config.configured) { + const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.'); + err.status = 400; + throw err; + } + + const message = String(payload?.message || '').trim(); + if (!message) { + const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.'); + err.status = 400; + throw err; + } + + const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice'; + const history = this._sanitizeAssistantHistory(payload?.history); + const baseUrl = config.baseUrl || 'https://api.openai.com/v1'; + const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`; + + const headers = { + 'Content-Type': 'application/json' + }; + if (config.apiKey) { + headers.Authorization = `Bearer ${config.apiKey}`; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + + let response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers, + signal: controller.signal, + body: JSON.stringify({ + model: config.model, + temperature: 0.7, + messages: [ + { + role: 'system', + content: this._buildLessonAssistantSystemPrompt(lesson, mode) + }, + ...history, + { + role: 'user', + content: message + } + ] + }) + }); + } catch (error) { + const err = new Error( + error?.name === 'AbortError' + ? 'Der Sprachassistent hat zu lange für eine Antwort gebraucht.' + : 'Der Sprachassistent konnte nicht erreicht werden.' + ); + err.status = 502; + throw err; + } finally { + clearTimeout(timeout); + } + + let responseData = null; + try { + responseData = await response.json(); + } catch { + responseData = null; + } + + if (!response.ok) { + const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.'; + const err = new Error(messageFromApi); + err.status = response.status || 502; + throw err; + } + + const reply = this._extractAssistantContent(responseData); + if (!reply) { + const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.'); + err.status = 502; + throw err; + } + + return { + reply, + model: responseData?.model || config.model, + mode + }; + } + /** * Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen */ diff --git a/backend/sql/expand_user_param_value_to_text.sql b/backend/sql/expand_user_param_value_to_text.sql new file mode 100644 index 0000000..7c95ebe --- /dev/null +++ b/backend/sql/expand_user_param_value_to_text.sql @@ -0,0 +1,2 @@ +ALTER TABLE community.user_param +ALTER COLUMN value TYPE TEXT; diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index 5258338..c152b1f 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -14,19 +14,32 @@ export const generateIv = () => { export const encrypt = (text) => { const cipher = crypto.createCipheriv(algorithm, key, null); - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); + let encrypted = cipher.update(text, 'utf8', 'base64'); + encrypted += cipher.final('base64'); return encrypted; }; export const decrypt = (text) => { + if (!text) { + return null; + } + + const input = String(text); try { const decipher = crypto.createDecipheriv(algorithm, key, null); - let decrypted = decipher.update(text, 'hex', 'utf8'); + let decrypted = decipher.update(input, 'base64', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; - } catch (error) { - console.log(error); - return null; + } catch (base64Error) { + try { + // Rueckwaertskompatibel fuer bereits gespeicherte Hex-Werte. + const decipher = crypto.createDecipheriv(algorithm, key, null); + let decrypted = decipher.update(input, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (hexError) { + console.log(hexError); + return null; + } } }; diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 4f34674..7a2c674 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -438,7 +438,34 @@ "recognizedText": "Erkannter Text", "speechRecognitionNotSupported": "Speech Recognition wird von diesem Browser nicht unterstützt. Bitte verwende Chrome oder Edge.", "keywords": "Schlüsselwörter", - "switchBackToMultipleChoice": "Zurück zu Multiple Choice" + "switchBackToMultipleChoice": "Zurück zu Multiple Choice", + "languageAssistantEyebrow": "Sprachassistent", + "languageAssistantCourseTitle": "KI-Begleitung für diesen Kurs", + "languageAssistantCourseReady": "Der Sprachassistent ist eingerichtet und steht in den Lektionen für Erklärungen, Korrekturen und kurze Dialogübungen bereit.", + "languageAssistantCourseSetup": "Richte den Sprachassistenten ein, damit du in den Lektionen gezielt Fragen stellen und kleine Dialoge üben kannst.", + "languageAssistantOpenLesson": "In aktueller Lektion öffnen", + "languageAssistantTitle": "Mit dem Sprachassistenten üben", + "languageAssistantIntro": "Nutze die KI direkt zur aktuellen Lektion: Grammatik erklären lassen, kurze Dialoge üben oder eigene Sätze korrigieren.", + "languageAssistantSettings": "Sprachassistent einstellen", + "languageAssistantSetupHint": "Der Sprachassistent ist noch nicht eingerichtet oder derzeit deaktiviert. Hinterlege zuerst Modell und API-Zugang in den Einstellungen.", + "languageAssistantModePractice": "Praxis", + "languageAssistantModeExplain": "Erklären", + "languageAssistantModeCorrect": "Korrigieren", + "languageAssistantPromptExplain": "Grammatik erklären", + "languageAssistantPromptPractice": "Mini-Dialog üben", + "languageAssistantPromptCorrect": "Meinen Satz verbessern", + "languageAssistantSpeakerAi": "Sprachassistent", + "languageAssistantSpeakerYou": "Du", + "languageAssistantInputLabel": "Deine Nachricht", + "languageAssistantInputPlaceholder": "Stelle eine Frage zur Lektion oder schreibe einen eigenen Satz zum Korrigieren.", + "languageAssistantSend": "An Sprachassistent senden", + "languageAssistantSending": "Antwort wird geholt ...", + "languageAssistantError": "Der Sprachassistent konnte gerade nicht antworten.", + "languageAssistantPresetExplainStart": "Erkläre mir bitte die wichtigsten Muster und die Grammatik in der Lektion", + "languageAssistantPatternHint": "Nutze dabei besonders dieses Muster", + "languageAssistantPresetPracticeStart": "Lass uns zur Lektion \"{lesson}\" einen kurzen alltagsnahen Dialog üben. Stelle mir bitte Fragen und warte auf meine Antworten.", + "languageAssistantPresetCorrectStart": "Ich möchte eigene Sätze zur Lektion \"{lesson}\" schreiben. Bitte korrigiere meine Antworten knapp und verständlich.", + "thisLesson": "dieser Lektion" } } } diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index 46c0e92..18c0e70 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -438,7 +438,34 @@ "recognizedText": "Recognized Text", "speechRecognitionNotSupported": "Speech Recognition is not supported by this browser. Please use Chrome or Edge.", "keywords": "Keywords", - "switchBackToMultipleChoice": "Switch back to Multiple Choice" + "switchBackToMultipleChoice": "Switch back to Multiple Choice", + "languageAssistantEyebrow": "Language assistant", + "languageAssistantCourseTitle": "AI support for this course", + "languageAssistantCourseReady": "The language assistant is configured and available inside lessons for explanations, corrections, and short dialogue practice.", + "languageAssistantCourseSetup": "Set up the language assistant so you can ask lesson-specific questions and practice short dialogues.", + "languageAssistantOpenLesson": "Open in current lesson", + "languageAssistantTitle": "Practice with the language assistant", + "languageAssistantIntro": "Use the AI directly inside the current lesson: get grammar explained, practice short dialogues, or have your own sentences corrected.", + "languageAssistantSettings": "Configure assistant", + "languageAssistantSetupHint": "The language assistant is not configured yet or is currently disabled. Please save your model and API access in the settings first.", + "languageAssistantModePractice": "Practice", + "languageAssistantModeExplain": "Explain", + "languageAssistantModeCorrect": "Correct", + "languageAssistantPromptExplain": "Explain grammar", + "languageAssistantPromptPractice": "Practice mini dialogue", + "languageAssistantPromptCorrect": "Improve my sentence", + "languageAssistantSpeakerAi": "Language assistant", + "languageAssistantSpeakerYou": "You", + "languageAssistantInputLabel": "Your message", + "languageAssistantInputPlaceholder": "Ask a lesson question or write your own sentence for correction.", + "languageAssistantSend": "Send to assistant", + "languageAssistantSending": "Getting answer ...", + "languageAssistantError": "The language assistant could not answer right now.", + "languageAssistantPresetExplainStart": "Please explain the main patterns and grammar in the lesson", + "languageAssistantPatternHint": "Focus especially on this pattern", + "languageAssistantPresetPracticeStart": "Let's practice a short everyday dialogue for the lesson \"{lesson}\". Please ask me questions and wait for my answers.", + "languageAssistantPresetCorrectStart": "I want to write my own sentences for the lesson \"{lesson}\". Please correct my answers briefly and clearly.", + "thisLesson": "this lesson" } } } diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index 43ecd58..1829012 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -435,7 +435,34 @@ "recognizedText": "Texto reconocido", "speechRecognitionNotSupported": "El reconocimiento de voz no es compatible con este navegador. Usa Chrome o Edge.", "keywords": "Palabras clave", - "switchBackToMultipleChoice": "Volver a opción múltiple" + "switchBackToMultipleChoice": "Volver a opción múltiple", + "languageAssistantEyebrow": "Asistente de idiomas", + "languageAssistantCourseTitle": "Apoyo de IA para este curso", + "languageAssistantCourseReady": "El asistente está configurado y disponible dentro de las lecciones para explicaciones, correcciones y pequeños diálogos.", + "languageAssistantCourseSetup": "Configura el asistente para poder hacer preguntas sobre la lección y practicar pequeños diálogos.", + "languageAssistantOpenLesson": "Abrir en la lección actual", + "languageAssistantTitle": "Practicar con el asistente", + "languageAssistantIntro": "Usa la IA directamente en la lección actual: pedir explicaciones gramaticales, practicar diálogos cortos o corregir tus propias frases.", + "languageAssistantSettings": "Configurar asistente", + "languageAssistantSetupHint": "El asistente aún no está configurado o está desactivado. Guarda primero el modelo y el acceso API en la configuración.", + "languageAssistantModePractice": "Práctica", + "languageAssistantModeExplain": "Explicar", + "languageAssistantModeCorrect": "Corregir", + "languageAssistantPromptExplain": "Explicar gramática", + "languageAssistantPromptPractice": "Practicar mini diálogo", + "languageAssistantPromptCorrect": "Mejorar mi frase", + "languageAssistantSpeakerAi": "Asistente", + "languageAssistantSpeakerYou": "Tú", + "languageAssistantInputLabel": "Tu mensaje", + "languageAssistantInputPlaceholder": "Haz una pregunta sobre la lección o escribe tu propia frase para corregirla.", + "languageAssistantSend": "Enviar al asistente", + "languageAssistantSending": "Obteniendo respuesta ...", + "languageAssistantError": "El asistente no pudo responder ahora mismo.", + "languageAssistantPresetExplainStart": "Explícame por favor los patrones y la gramática principales de la lección", + "languageAssistantPatternHint": "Concéntrate especialmente en este patrón", + "languageAssistantPresetPracticeStart": "Practiquemos un diálogo cotidiano corto para la lección \"{lesson}\". Hazme preguntas y espera mis respuestas.", + "languageAssistantPresetCorrectStart": "Quiero escribir mis propias frases para la lección \"{lesson}\". Corrige mis respuestas de forma breve y clara.", + "thisLesson": "esta lección" } } } diff --git a/frontend/src/views/social/VocabCourseView.vue b/frontend/src/views/social/VocabCourseView.vue index 4cdac41..97b8646 100644 --- a/frontend/src/views/social/VocabCourseView.vue +++ b/frontend/src/views/social/VocabCourseView.vue @@ -18,6 +18,26 @@ +
+
+ {{ $t('socialnetwork.vocab.courses.languageAssistantEyebrow') }} +

{{ $t('socialnetwork.vocab.courses.languageAssistantCourseTitle') }}

+

{{ assistantAvailable ? $t('socialnetwork.vocab.courses.languageAssistantCourseReady') : $t('socialnetwork.vocab.courses.languageAssistantCourseSetup') }}

+
+
+ + +
+
+
@@ -128,6 +148,7 @@ export default { progress: [], chapters: [], showAddLessonDialog: false, + assistantSettings: null, lessonFormTouched: false, newLesson: { lessonNumber: 1, @@ -172,6 +193,14 @@ export default { }, canCreateLesson() { return this.isLessonNumberValid && this.isLessonTitleValid && this.isLessonChapterValid; + }, + assistantAvailable() { + if (!this.assistantSettings) { + return false; + } + const enabled = this.assistantSettings.enabled !== false; + const hasBaseUrl = Boolean(this.assistantSettings.baseUrl); + return enabled && (this.assistantSettings.hasKey || hasBaseUrl); } }, watch: { @@ -212,6 +241,14 @@ export default { console.error('Konnte Kapitel nicht laden:', e); } }, + async loadAssistantSettings() { + try { + const { data } = await apiClient.get('/api/settings/llm'); + this.assistantSettings = data; + } catch (e) { + this.assistantSettings = null; + } + }, getLessonProgress(lessonId) { return this.progress.find(p => p.lessonId === lessonId); }, @@ -285,12 +322,18 @@ export default { editCourse() { this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/edit`); }, + openLanguageAssistantSettings() { + this.$router.push('/settings/language-assistant'); + }, editLesson() { showInfo(this, 'Die Bearbeitung einzelner Lektionen folgt noch.'); } }, async mounted() { - await this.loadCourse(); + await Promise.all([ + this.loadCourse(), + this.loadAssistantSettings() + ]); }, }; @@ -304,6 +347,7 @@ export default { .course-hero, .course-info, +.course-assistant, .lessons-list, .course-state { margin-bottom: 16px; @@ -340,6 +384,39 @@ export default { padding: 16px 18px; } +.course-assistant { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; +} + +.course-assistant__eyebrow { + display: inline-block; + margin-bottom: 8px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); +} + +.course-assistant h3, +.course-assistant p { + margin: 0; +} + +.course-assistant p { + margin-top: 6px; + color: var(--color-text-secondary); +} + +.course-assistant__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + .share-code { font-family: monospace; } @@ -614,4 +691,10 @@ export default { justify-content: flex-end; margin-top: 20px; } + +@media (max-width: 640px) { + .course-assistant { + flex-direction: column; + } +} diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index ec6ebc8..57d4a08 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -101,6 +101,86 @@
+
+
+
+

{{ $t('socialnetwork.vocab.courses.languageAssistantTitle') }}

+

{{ $t('socialnetwork.vocab.courses.languageAssistantIntro') }}

+
+ +
+ +
+ {{ $t('general.loading') }} +
+ +
+

{{ $t('socialnetwork.vocab.courses.languageAssistantSetupHint') }}

+
+ +
+
+ +
+ +
+ + + +
+ +
+
+ {{ message.role === 'assistant' ? $t('socialnetwork.vocab.courses.languageAssistantSpeakerAi') : $t('socialnetwork.vocab.courses.languageAssistantSpeakerYou') }} +

{{ message.content }}

+
+
+ +