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

@@ -18,6 +18,26 @@
</span>
</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">
<button @click="showAddLessonDialog = true">{{ $t('socialnetwork.vocab.courses.addLesson') }}</button>
<button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
@@ -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()
]);
},
};
</script>
@@ -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;
}
}
</style>

View File

@@ -101,6 +101,86 @@
</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">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
@@ -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;