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.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 17:31:00 +01:00
parent 850a59a0b5
commit 95c9e7c036
12 changed files with 685 additions and 14 deletions

View File

@@ -55,6 +55,7 @@ class VocabController {
this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId)); 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.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.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 } = {}) { _wrapWithUser(fn, { successStatus = 200 } = {}) {
@@ -77,4 +78,3 @@ class VocabController {
export default VocabController; export default VocabController;

View File

@@ -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);
`);
}
};

View File

@@ -14,7 +14,7 @@ const UserParam = sequelize.define('user_param', {
allowNull: false allowNull: false
}, },
value: { value: {
type: DataTypes.STRING, type: DataTypes.TEXT,
allowNull: false, allowNull: false,
set(value) { set(value) {
if (value) { if (value) {

View File

@@ -48,6 +48,7 @@ router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
// Grammar Exercises // Grammar Exercises
router.get('/grammar/exercise-types', vocabController.getExerciseTypes); router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
router.post('/lessons/:lessonId/assistant', vocabController.sendLessonAssistantMessage);
router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise); router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise);
router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson); router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson);
router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress); router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress);
@@ -58,4 +59,3 @@ router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExe
export default router; export default router;

View File

@@ -7,9 +7,12 @@ import VocabCourseProgress from '../models/community/vocab_course_progress.js';
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js'; import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js'; import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.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 { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js'; import { notifyUser } from '../utils/socket.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { decrypt } from '../utils/encryption.js';
export default class VocabService { export default class VocabService {
async _getUserByHashedId(hashedUserId) { async _getUserByHashedId(hashedUserId) {
@@ -22,6 +25,117 @@ export default class VocabService {
return user; 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) { _normalizeLexeme(text) {
return String(text || '') return String(text || '')
.trim() .trim()
@@ -1019,6 +1133,107 @@ export default class VocabService {
return plainLesson; 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 * Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
*/ */

View File

@@ -0,0 +1,2 @@
ALTER TABLE community.user_param
ALTER COLUMN value TYPE TEXT;

View File

@@ -14,19 +14,32 @@ export const generateIv = () => {
export const encrypt = (text) => { export const encrypt = (text) => {
const cipher = crypto.createCipheriv(algorithm, key, null); const cipher = crypto.createCipheriv(algorithm, key, null);
let encrypted = cipher.update(text, 'utf8', 'hex'); let encrypted = cipher.update(text, 'utf8', 'base64');
encrypted += cipher.final('hex'); encrypted += cipher.final('base64');
return encrypted; return encrypted;
}; };
export const decrypt = (text) => { export const decrypt = (text) => {
try { if (!text) {
const decipher = crypto.createDecipheriv(algorithm, key, null);
let decrypted = decipher.update(text, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.log(error);
return null; return null;
} }
const input = String(text);
try {
const decipher = crypto.createDecipheriv(algorithm, key, null);
let decrypted = decipher.update(input, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} 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;
}
}
}; };

View File

@@ -438,7 +438,34 @@
"recognizedText": "Erkannter Text", "recognizedText": "Erkannter Text",
"speechRecognitionNotSupported": "Speech Recognition wird von diesem Browser nicht unterstützt. Bitte verwende Chrome oder Edge.", "speechRecognitionNotSupported": "Speech Recognition wird von diesem Browser nicht unterstützt. Bitte verwende Chrome oder Edge.",
"keywords": "Schlüsselwörter", "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"
} }
} }
} }

View File

@@ -438,7 +438,34 @@
"recognizedText": "Recognized Text", "recognizedText": "Recognized Text",
"speechRecognitionNotSupported": "Speech Recognition is not supported by this browser. Please use Chrome or Edge.", "speechRecognitionNotSupported": "Speech Recognition is not supported by this browser. Please use Chrome or Edge.",
"keywords": "Keywords", "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"
} }
} }
} }

View File

@@ -435,7 +435,34 @@
"recognizedText": "Texto reconocido", "recognizedText": "Texto reconocido",
"speechRecognitionNotSupported": "El reconocimiento de voz no es compatible con este navegador. Usa Chrome o Edge.", "speechRecognitionNotSupported": "El reconocimiento de voz no es compatible con este navegador. Usa Chrome o Edge.",
"keywords": "Palabras clave", "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"
} }
} }
} }

View File

@@ -18,6 +18,26 @@
</span> </span>
</div> </div>
<section class="surface-card course-assistant">
<div>
<span class="course-assistant__eyebrow">{{ $t('socialnetwork.vocab.courses.languageAssistantEyebrow') }}</span>
<h3>{{ $t('socialnetwork.vocab.courses.languageAssistantCourseTitle') }}</h3>
<p>{{ assistantAvailable ? $t('socialnetwork.vocab.courses.languageAssistantCourseReady') : $t('socialnetwork.vocab.courses.languageAssistantCourseSetup') }}</p>
</div>
<div class="course-assistant__actions">
<button type="button" class="button-secondary" @click="openLanguageAssistantSettings">
{{ $t('socialnetwork.vocab.courses.languageAssistantSettings') }}
</button>
<button
v-if="assistantAvailable && currentLesson"
type="button"
@click="openLesson(currentLesson.id)"
>
{{ $t('socialnetwork.vocab.courses.languageAssistantOpenLesson') }}
</button>
</div>
</section>
<div v-if="isOwner" class="owner-actions"> <div v-if="isOwner" class="owner-actions">
<button @click="showAddLessonDialog = true">{{ $t('socialnetwork.vocab.courses.addLesson') }}</button> <button @click="showAddLessonDialog = true">{{ $t('socialnetwork.vocab.courses.addLesson') }}</button>
<button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button> <button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
@@ -128,6 +148,7 @@ export default {
progress: [], progress: [],
chapters: [], chapters: [],
showAddLessonDialog: false, showAddLessonDialog: false,
assistantSettings: null,
lessonFormTouched: false, lessonFormTouched: false,
newLesson: { newLesson: {
lessonNumber: 1, lessonNumber: 1,
@@ -172,6 +193,14 @@ export default {
}, },
canCreateLesson() { canCreateLesson() {
return this.isLessonNumberValid && this.isLessonTitleValid && this.isLessonChapterValid; 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: { watch: {
@@ -212,6 +241,14 @@ export default {
console.error('Konnte Kapitel nicht laden:', e); 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) { getLessonProgress(lessonId) {
return this.progress.find(p => p.lessonId === lessonId); return this.progress.find(p => p.lessonId === lessonId);
}, },
@@ -285,12 +322,18 @@ export default {
editCourse() { editCourse() {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/edit`); this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/edit`);
}, },
openLanguageAssistantSettings() {
this.$router.push('/settings/language-assistant');
},
editLesson() { editLesson() {
showInfo(this, 'Die Bearbeitung einzelner Lektionen folgt noch.'); showInfo(this, 'Die Bearbeitung einzelner Lektionen folgt noch.');
} }
}, },
async mounted() { async mounted() {
await this.loadCourse(); await Promise.all([
this.loadCourse(),
this.loadAssistantSettings()
]);
}, },
}; };
</script> </script>
@@ -304,6 +347,7 @@ export default {
.course-hero, .course-hero,
.course-info, .course-info,
.course-assistant,
.lessons-list, .lessons-list,
.course-state { .course-state {
margin-bottom: 16px; margin-bottom: 16px;
@@ -340,6 +384,39 @@ export default {
padding: 16px 18px; 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 { .share-code {
font-family: monospace; font-family: monospace;
} }
@@ -614,4 +691,10 @@ export default {
justify-content: flex-end; justify-content: flex-end;
margin-top: 20px; margin-top: 20px;
} }
@media (max-width: 640px) {
.course-assistant {
flex-direction: column;
}
}
</style> </style>

View File

@@ -101,6 +101,86 @@
</div> </div>
</div> </div>
<div class="didactic-card language-assistant-card">
<div class="language-assistant-card__header">
<div>
<h4>{{ $t('socialnetwork.vocab.courses.languageAssistantTitle') }}</h4>
<p class="language-assistant-card__intro">{{ $t('socialnetwork.vocab.courses.languageAssistantIntro') }}</p>
</div>
<button @click="openLanguageAssistantSettings" class="button-secondary language-assistant-card__settings">
{{ $t('socialnetwork.vocab.courses.languageAssistantSettings') }}
</button>
</div>
<div v-if="assistantLoading" class="language-assistant-card__state">
{{ $t('general.loading') }}
</div>
<div v-else-if="!assistantAvailable" class="language-assistant-card__state">
<p>{{ $t('socialnetwork.vocab.courses.languageAssistantSetupHint') }}</p>
</div>
<div v-else class="language-assistant-panel">
<div class="language-assistant-panel__modes">
<button
v-for="mode in assistantModes"
:key="mode.value"
type="button"
class="assistant-mode-button"
:class="{ active: assistantMode === mode.value }"
@click="assistantMode = mode.value"
>
{{ mode.label }}
</button>
</div>
<div class="language-assistant-panel__presets">
<button type="button" class="assistant-preset-button" @click="sendPresetPrompt('explain')">
{{ $t('socialnetwork.vocab.courses.languageAssistantPromptExplain') }}
</button>
<button type="button" class="assistant-preset-button" @click="sendPresetPrompt('practice')">
{{ $t('socialnetwork.vocab.courses.languageAssistantPromptPractice') }}
</button>
<button type="button" class="assistant-preset-button" @click="sendPresetPrompt('correct')">
{{ $t('socialnetwork.vocab.courses.languageAssistantPromptCorrect') }}
</button>
</div>
<div v-if="assistantMessages.length > 0" class="language-assistant-chat">
<article
v-for="(message, index) in assistantMessages"
:key="`${message.role}-${index}`"
class="assistant-message"
:class="`assistant-message--${message.role}`"
>
<strong>{{ message.role === 'assistant' ? $t('socialnetwork.vocab.courses.languageAssistantSpeakerAi') : $t('socialnetwork.vocab.courses.languageAssistantSpeakerYou') }}</strong>
<p>{{ message.content }}</p>
</article>
</div>
<label class="language-assistant-panel__input">
<span>{{ $t('socialnetwork.vocab.courses.languageAssistantInputLabel') }}</span>
<textarea
v-model="assistantInput"
:placeholder="$t('socialnetwork.vocab.courses.languageAssistantInputPlaceholder')"
rows="4"
/>
</label>
<p v-if="assistantError" class="form-error">{{ assistantError }}</p>
<div class="language-assistant-panel__actions">
<button
type="button"
@click="sendAssistantMessage()"
:disabled="assistantSubmitting || !assistantInput.trim()"
>
{{ assistantSubmitting ? $t('socialnetwork.vocab.courses.languageAssistantSending') : $t('socialnetwork.vocab.courses.languageAssistantSend') }}
</button>
</div>
</div>
</div>
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes didactic-card"> <div v-if="lesson && lesson.culturalNotes" class="cultural-notes didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4> <h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p> <p>{{ lesson.culturalNotes }}</p>
@@ -609,6 +689,13 @@ export default {
recognizedText: {}, // { [exerciseId]: string } recognizedText: {}, // { [exerciseId]: string }
recordingStatus: {}, // { [exerciseId]: string } recordingStatus: {}, // { [exerciseId]: string }
isSpeechRecognitionSupported: false, isSpeechRecognitionSupported: false,
assistantLoading: false,
assistantSubmitting: false,
assistantSettings: null,
assistantMessages: [],
assistantInput: '',
assistantError: '',
assistantMode: 'practice',
nextLessonId: null, nextLessonId: null,
showCompletionDialog: false, showCompletionDialog: false,
showErrorDialog: false, showErrorDialog: false,
@@ -694,6 +781,21 @@ export default {
speakingPrompts: [], speakingPrompts: [],
practicalTasks: [] 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: { watch: {
@@ -865,6 +967,9 @@ export default {
// Setze Antworten und Ergebnisse zurück // Setze Antworten und Ergebnisse zurück
this.exerciseAnswers = {}; this.exerciseAnswers = {};
this.exerciseResults = {}; this.exerciseResults = {};
this.assistantMessages = [];
this.assistantInput = '';
this.assistantError = '';
// Reset Flags // Reset Flags
this.isCheckingLessonCompletion = false; this.isCheckingLessonCompletion = false;
this.isNavigatingToNext = false; this.isNavigatingToNext = false;
@@ -897,6 +1002,67 @@ export default {
this.loading = false; 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) { initializeExercises(exercises) {
// Initialisiere Antwort-Arrays für Gap Fill Übungen // Initialisiere Antwort-Arrays für Gap Fill Übungen
exercises.forEach(exercise => { exercises.forEach(exercise => {
@@ -1604,7 +1770,10 @@ export default {
async mounted() { async mounted() {
// Prüfe Speech Recognition Support // Prüfe Speech Recognition Support
this.initSpeechRecognition(); this.initSpeechRecognition();
await this.loadLesson(); await Promise.all([
this.loadLesson(),
this.loadAssistantSettings()
]);
}, },
beforeUnmount() { beforeUnmount() {
// Stoppe alle aktiven Recognition-Instanzen // Stoppe alle aktiven Recognition-Instanzen
@@ -1620,6 +1789,96 @@ export default {
padding: 20px; 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 { .lesson-header {
display: flex; display: flex;
align-items: center; align-items: center;