{{ message.content }}
+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 @@
+ {{ assistantAvailable ? $t('socialnetwork.vocab.courses.languageAssistantCourseReady') : $t('socialnetwork.vocab.courses.languageAssistantCourseSetup') }}{{ $t('socialnetwork.vocab.courses.languageAssistantCourseTitle') }}
+
{{ $t('socialnetwork.vocab.courses.languageAssistantIntro') }}
+{{ $t('socialnetwork.vocab.courses.languageAssistantSetupHint') }}
+{{ assistantError }}
+ +{{ lesson.culturalNotes }}
@@ -609,6 +689,13 @@ export default { recognizedText: {}, // { [exerciseId]: string } recordingStatus: {}, // { [exerciseId]: string } isSpeechRecognitionSupported: false, + assistantLoading: false, + assistantSubmitting: false, + assistantSettings: null, + assistantMessages: [], + assistantInput: '', + assistantError: '', + assistantMode: 'practice', nextLessonId: null, showCompletionDialog: false, showErrorDialog: false, @@ -694,6 +781,21 @@ export default { speakingPrompts: [], practicalTasks: [] }; + }, + assistantAvailable() { + if (!this.assistantSettings) { + return false; + } + const enabled = this.assistantSettings.enabled !== false; + const hasBaseUrl = Boolean(this.assistantSettings.baseUrl); + return enabled && (this.assistantSettings.hasKey || hasBaseUrl); + }, + assistantModes() { + return [ + { value: 'practice', label: this.$t('socialnetwork.vocab.courses.languageAssistantModePractice') }, + { value: 'explain', label: this.$t('socialnetwork.vocab.courses.languageAssistantModeExplain') }, + { value: 'correct', label: this.$t('socialnetwork.vocab.courses.languageAssistantModeCorrect') } + ]; } }, watch: { @@ -865,6 +967,9 @@ export default { // Setze Antworten und Ergebnisse zurück this.exerciseAnswers = {}; this.exerciseResults = {}; + this.assistantMessages = []; + this.assistantInput = ''; + this.assistantError = ''; // Reset Flags this.isCheckingLessonCompletion = false; this.isNavigatingToNext = false; @@ -897,6 +1002,67 @@ export default { this.loading = false; } }, + async loadAssistantSettings() { + this.assistantLoading = true; + try { + const { data } = await apiClient.get('/api/settings/llm'); + this.assistantSettings = data; + } catch (e) { + this.assistantSettings = null; + } finally { + this.assistantLoading = false; + } + }, + openLanguageAssistantSettings() { + this.$router.push('/settings/language-assistant'); + }, + buildAssistantPrompt(preset) { + const lessonTitle = this.lesson?.title || this.$t('socialnetwork.vocab.courses.thisLesson'); + const firstPattern = this.lessonDidactics.corePatterns?.[0]; + const firstGrammar = this.lessonDidactics.grammarFocus?.[0]?.text; + + if (preset === 'explain') { + return `${this.$t('socialnetwork.vocab.courses.languageAssistantPresetExplainStart')} "${lessonTitle}". ${firstPattern ? `${this.$t('socialnetwork.vocab.courses.languageAssistantPatternHint')} ${firstPattern}.` : ''} ${firstGrammar || ''}`.trim(); + } + if (preset === 'correct') { + return this.$t('socialnetwork.vocab.courses.languageAssistantPresetCorrectStart', { lesson: lessonTitle }); + } + return this.$t('socialnetwork.vocab.courses.languageAssistantPresetPracticeStart', { lesson: lessonTitle }); + }, + async sendPresetPrompt(preset) { + this.assistantMode = preset === 'explain' ? 'explain' : (preset === 'correct' ? 'correct' : 'practice'); + await this.sendAssistantMessage(this.buildAssistantPrompt(preset)); + }, + async sendAssistantMessage(customMessage = null) { + const message = String(customMessage || this.assistantInput || '').trim(); + if (!message || this.assistantSubmitting || !this.assistantAvailable) { + return; + } + + this.assistantError = ''; + this.assistantSubmitting = true; + this.assistantMessages.push({ role: 'user', content: message }); + if (!customMessage) { + this.assistantInput = ''; + } + + try { + const history = this.assistantMessages.slice(0, -1).slice(-8); + const { data } = await apiClient.post(`/api/vocab/lessons/${this.lessonId}/assistant`, { + message, + mode: this.assistantMode, + history + }); + this.assistantMessages.push({ + role: 'assistant', + content: data.reply + }); + } catch (e) { + this.assistantError = e.response?.data?.error || e.message || this.$t('socialnetwork.vocab.courses.languageAssistantError'); + } finally { + this.assistantSubmitting = false; + } + }, initializeExercises(exercises) { // Initialisiere Antwort-Arrays für Gap Fill Übungen exercises.forEach(exercise => { @@ -1604,7 +1770,10 @@ export default { async mounted() { // Prüfe Speech Recognition Support this.initSpeechRecognition(); - await this.loadLesson(); + await Promise.all([ + this.loadLesson(), + this.loadAssistantSettings() + ]); }, beforeUnmount() { // Stoppe alle aktiven Recognition-Instanzen @@ -1620,6 +1789,96 @@ export default { padding: 20px; } +.language-assistant-card { + gap: 14px; +} + +.language-assistant-card__header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} + +.language-assistant-card__intro { + margin: 6px 0 0; + color: var(--color-text-secondary); +} + +.language-assistant-card__state { + color: var(--color-text-secondary); +} + +.language-assistant-panel { + display: grid; + gap: 14px; +} + +.language-assistant-panel__modes, +.language-assistant-panel__presets, +.language-assistant-panel__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.assistant-mode-button, +.assistant-preset-button { + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.82); + border-radius: var(--radius-md); + padding: 8px 12px; +} + +.assistant-mode-button.active { + border-color: var(--color-primary-orange); + background: rgba(248, 162, 43, 0.16); +} + +.language-assistant-chat { + display: grid; + gap: 10px; +} + +.assistant-message { + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.assistant-message strong { + display: block; + margin-bottom: 4px; +} + +.assistant-message p { + margin: 0; + white-space: pre-wrap; +} + +.assistant-message--assistant { + background: rgba(248, 162, 43, 0.08); +} + +.assistant-message--user { + background: rgba(58, 117, 196, 0.08); +} + +.language-assistant-panel__input { + display: grid; + gap: 6px; +} + +.language-assistant-panel__input textarea { + width: 100%; + min-height: 112px; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + resize: vertical; +} + .lesson-header { display: flex; align-items: center;