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 }}

+
+
+ +