Add language assistant settings and related features: Introduce new routes and controller methods for managing language assistant settings, including retrieval and saving of LLM configurations. Update navigation structure to include language assistant options. Enhance vocab course model to support additional learning attributes such as learning goals and core patterns. Update SQL scripts to reflect new database schema changes for vocab courses. Improve localization for language assistant settings in German and English.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 15:53:49 +01:00
parent 8af726c65a
commit d50d3c4016
40 changed files with 3145 additions and 56 deletions

View File

@@ -0,0 +1,224 @@
<template>
<div class="language-assistant-settings">
<section class="language-assistant-settings__hero surface-card">
<span class="language-assistant-settings__eyebrow">{{ $t('settings.languageAssistant.eyebrow') }}</span>
<h2>{{ $t('settings.languageAssistant.title') }}</h2>
<p class="language-assistant-settings__intro">{{ $t('settings.languageAssistant.intro') }}</p>
<ul class="language-assistant-settings__links">
<li>
<a href="https://platform.openai.com/signup" target="_blank" rel="noopener noreferrer">
{{ $t('settings.languageAssistant.linkSignup') }}
</a>
</li>
<li>
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
{{ $t('settings.languageAssistant.linkApiKeys') }}
</a>
</li>
</ul>
</section>
<section v-if="loadError" class="surface-card language-assistant-settings__panel">
<p class="form-error">{{ loadError }}</p>
</section>
<section v-else class="language-assistant-settings__panel surface-card">
<label class="language-assistant-settings__toggle">
<input type="checkbox" v-model="form.enabled" />
<span>{{ $t('settings.languageAssistant.enabled') }}</span>
</label>
<div class="language-assistant-settings__grid">
<label class="language-assistant-settings__field">
<span>{{ $t('settings.languageAssistant.baseUrl') }}</span>
<input
v-model="form.baseUrl"
type="url"
autocomplete="off"
:placeholder="$t('settings.languageAssistant.baseUrlPlaceholder')"
/>
</label>
<label class="language-assistant-settings__field">
<span>{{ $t('settings.languageAssistant.model') }}</span>
<input v-model="form.model" type="text" autocomplete="off" placeholder="gpt-4o-mini" />
</label>
<label class="language-assistant-settings__field language-assistant-settings__field--full">
<span>{{ $t('settings.languageAssistant.apiKey') }}</span>
<input
v-model="form.apiKey"
type="password"
autocomplete="new-password"
:placeholder="apiKeyPlaceholder"
/>
<span class="language-assistant-settings__hint">{{ $t('settings.languageAssistant.apiKeyHint') }}</span>
</label>
</div>
<label class="language-assistant-settings__toggle">
<input type="checkbox" v-model="form.clearKey" />
<span>{{ $t('settings.languageAssistant.clearKey') }}</span>
</label>
<div class="language-assistant-settings__actions">
<button type="button" :disabled="saving" @click="save">{{ $t('settings.languageAssistant.save') }}</button>
</div>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'LanguageAssistantSettingsView',
data() {
return {
form: {
enabled: true,
baseUrl: '',
model: 'gpt-4o-mini',
apiKey: '',
clearKey: false
},
hasKey: false,
keyLast4: null,
saving: false,
loadError: null
};
},
computed: {
...mapGetters(['user']),
apiKeyPlaceholder() {
if (this.form.clearKey) {
return this.$t('settings.languageAssistant.apiKeyPlaceholderClear');
}
if (this.hasKey) {
return this.$t('settings.languageAssistant.apiKeyPlaceholderHasKey', {
last4: this.keyLast4 || '••••'
});
}
return this.$t('settings.languageAssistant.apiKeyPlaceholderNew');
}
},
async mounted() {
await this.load();
},
methods: {
async load() {
this.loadError = null;
try {
const { data } = await apiClient.get('/api/settings/llm');
this.form.enabled = data.enabled !== false;
this.form.baseUrl = data.baseUrl || '';
this.form.model = data.model || 'gpt-4o-mini';
this.hasKey = data.hasKey;
this.keyLast4 = data.keyLast4;
this.form.apiKey = '';
this.form.clearKey = false;
} catch (e) {
this.loadError = e.response?.data?.error || e.message || 'Error';
}
},
async save() {
if (this.form.clearKey && !window.confirm(this.$t('settings.languageAssistant.confirmClear'))) {
return;
}
this.saving = true;
try {
await apiClient.post('/api/settings/llm', {
enabled: this.form.enabled,
baseUrl: this.form.baseUrl,
model: this.form.model,
apiKey: this.form.apiKey,
clearKey: this.form.clearKey
});
showSuccess(this, this.$t('settings.languageAssistant.saved'));
this.form.apiKey = '';
this.form.clearKey = false;
await this.load();
} catch (e) {
showApiError(this, e, () => showError(this, this.$t('settings.languageAssistant.saveError')));
} finally {
this.saving = false;
}
}
}
};
</script>
<style scoped>
.language-assistant-settings__hero {
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
}
.language-assistant-settings__eyebrow {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.75;
margin-bottom: 0.35rem;
}
.language-assistant-settings__intro {
margin: 0.5rem 0 1rem;
line-height: 1.5;
}
.language-assistant-settings__links {
margin: 0;
padding-left: 1.25rem;
}
.language-assistant-settings__links a {
color: var(--color-link, #3b82f6);
}
.language-assistant-settings__panel {
padding: 1.25rem 1.5rem;
}
.language-assistant-settings__grid {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
@media (min-width: 640px) {
.language-assistant-settings__grid {
grid-template-columns: 1fr 1fr;
}
.language-assistant-settings__field--full {
grid-column: 1 / -1;
}
}
.language-assistant-settings__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.language-assistant-settings__field span:first-child {
font-weight: 600;
font-size: 0.9rem;
}
.language-assistant-settings__field input {
padding: 0.5rem 0.65rem;
border-radius: 6px;
border: 1px solid var(--color-border, #ccc);
background: var(--color-input-bg, #fff);
color: inherit;
}
.language-assistant-settings__hint {
font-size: 0.8rem;
opacity: 0.8;
}
.language-assistant-settings__toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
cursor: pointer;
}
.language-assistant-settings__actions {
margin-top: 1.25rem;
}
.form-error {
color: #c62828;
}
</style>

View File

@@ -30,26 +30,80 @@
<!-- Lernen-Tab -->
<div v-if="activeTab === 'learn'" class="learn-section">
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<!-- Lektions-Beschreibung -->
<div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p>
<div class="lesson-overview-card">
<div>
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<p class="lesson-overview-text">
{{ $t('socialnetwork.vocab.courses.lessonOverviewText') }}
</p>
</div>
<div class="lesson-meta-grid">
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonTypeLabel') }}</span>
<strong>{{ getLessonTypeLabel(lesson.lessonType) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.recommendedDuration') }}</span>
<strong>{{ formatTargetMinutes(lesson.targetMinutes) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.exerciseLoad') }}</span>
<strong>{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}</strong>
</div>
</div>
</div>
<!-- Kulturelle Notizen -->
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
<div class="learn-grid">
<div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p>
</div>
<!-- Grammatik-Erklärungen -->
<div v-if="grammarExplanations && grammarExplanations.length > 0" class="grammar-explanations">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in grammarExplanations" :key="index" class="grammar-explanation-item">
<strong>{{ explanation.title }}</strong>
<p>{{ explanation.text }}</p>
<div v-if="lessonDidactics.learningGoals.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.learningGoals') }}</h4>
<ul class="didactic-list">
<li v-for="(goal, index) in lessonDidactics.learningGoals" :key="'goal-' + index">{{ goal }}</li>
</ul>
</div>
<div v-if="lessonDidactics.corePatterns.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.corePatterns') }}</h4>
<div class="pattern-list">
<div v-for="(pattern, index) in lessonDidactics.corePatterns" :key="'pattern-' + index" class="pattern-item">
{{ pattern }}
</div>
</div>
</div>
<div v-if="lessonDidactics.grammarFocus.length > 0" class="grammar-explanations didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in lessonDidactics.grammarFocus" :key="'grammar-' + index" class="grammar-explanation-item">
<strong>{{ explanation.title || $t('socialnetwork.vocab.courses.grammarImpulse') }}</strong>
<p>{{ explanation.text }}</p>
<p v-if="explanation.example" class="grammar-example">{{ explanation.example }}</p>
</div>
</div>
<div v-if="lessonDidactics.speakingPrompts.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.speakingTasks') }}</h4>
<div v-for="(prompt, index) in lessonDidactics.speakingPrompts" :key="'speaking-' + index" class="speaking-prompt-item">
<strong>{{ prompt.title || $t('socialnetwork.vocab.courses.speakingPrompt') }}</strong>
<p>{{ prompt.prompt }}</p>
<p v-if="prompt.cue" class="speaking-cue">{{ prompt.cue }}</p>
</div>
</div>
<div v-if="lessonDidactics.practicalTasks.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.practicalTasks') }}</h4>
<div v-for="(task, index) in lessonDidactics.practicalTasks" :key="'task-' + index" class="practical-task-item">
<strong>{{ task.title }}</strong>
<p>{{ task.text }}</p>
</div>
</div>
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
</div>
@@ -253,6 +307,94 @@
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'sentence_building'" class="sentence-building-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.tokens?.length" class="token-list">
<span v-for="(token, index) in getQuestionData(exercise).tokens" :key="index" class="token-chip">{{ token }}</span>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.buildSentencePlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelSentence') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'dialog_completion'" class="dialog-completion-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.dialog" class="dialog-snippet">
<p v-for="(line, index) in getQuestionData(exercise).dialog" :key="index">{{ line }}</p>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.completeDialogPlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelDialogLine') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'situational_response'" class="situational-response-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<textarea
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.situationalResponsePlaceholder')"
class="response-textarea"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelResponse') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.keywords') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'pattern_drill'" class="pattern-drill-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<p v-if="getQuestionData(exercise)?.pattern" class="pattern-drill-hint">
{{ $t('socialnetwork.vocab.courses.patternPrompt') }}: {{ getQuestionData(exercise).pattern }}
</p>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.patternDrillPlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelPattern') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<!-- Reading Aloud Übung -->
<div v-else-if="getExerciseType(exercise) === 'reading_aloud'" class="reading-aloud-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
@@ -543,6 +685,15 @@ export default {
console.error('Fehler in importantVocab computed property:', e);
return [];
}
},
lessonDidactics() {
return this.lesson?.didactics || {
learningGoals: [],
corePatterns: [],
grammarFocus: [],
speakingPrompts: [],
practicalTasks: []
};
}
},
watch: {
@@ -787,10 +938,31 @@ export default {
5: 'conjugation',
6: 'declension',
7: 'reading_aloud',
8: 'speaking_from_memory'
8: 'speaking_from_memory',
9: 'dialog_completion',
10: 'situational_response',
11: 'pattern_drill'
};
return typeMap[exercise.exerciseTypeId] || 'unknown';
},
getLessonTypeLabel(lessonType) {
const labels = {
vocab: this.$t('socialnetwork.vocab.courses.lessonTypeVocab'),
grammar: this.$t('socialnetwork.vocab.courses.lessonTypeGrammar'),
conversation: this.$t('socialnetwork.vocab.courses.lessonTypeConversation'),
culture: this.$t('socialnetwork.vocab.courses.lessonTypeCulture'),
review: this.$t('socialnetwork.vocab.courses.lessonTypeReview'),
vocab_review: this.$t('socialnetwork.vocab.courses.lessonTypeReview')
};
return labels[lessonType] || lessonType || this.$t('socialnetwork.vocab.courses.lessonTypeVocab');
},
formatTargetMinutes(targetMinutes) {
const minutes = Number(targetMinutes);
if (!minutes) {
return this.$t('socialnetwork.vocab.courses.durationFlexible');
}
return this.$t('socialnetwork.vocab.courses.durationMinutes', { minutes });
},
getQuestionData(exercise) {
if (!exercise.questionData) return null;
return typeof exercise.questionData === 'string'
@@ -861,9 +1033,11 @@ export default {
} else if (exerciseType === 'multiple_choice') {
// Multiple Choice: Index als Zahl
answer = Number(answer);
} else if (exerciseType === 'transformation') {
} else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') {
// Transformation: String
answer = String(answer || '').trim();
} else if (exerciseType === 'situational_response') {
answer = String(answer || '').trim();
} else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') {
// Reading Aloud / Speaking From Memory: Verwende erkannten Text
answer = this.recognizedText[exerciseId] || String(answer || '').trim();
@@ -1453,6 +1627,126 @@ export default {
margin-bottom: 20px;
}
.lesson-overview-card {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, #fff8eb 0%, #f7efe2 100%);
border: 1px solid rgba(160, 120, 40, 0.18);
border-radius: 12px;
}
.lesson-overview-text {
margin: 8px 0 0;
color: #5b4b2f;
}
.lesson-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr));
gap: 12px;
min-width: 360px;
}
.lesson-meta-item {
padding: 12px 14px;
background: rgba(255, 255, 255, 0.72);
border-radius: 10px;
}
.lesson-meta-label {
display: block;
margin-bottom: 6px;
font-size: 0.82rem;
color: #7a6848;
}
.learn-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.didactic-card,
.lesson-description-box,
.cultural-notes {
padding: 18px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e7e7e7;
}
.didactic-list {
margin: 0;
padding-left: 20px;
}
.didactic-list li + li {
margin-top: 8px;
}
.pattern-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.pattern-item {
padding: 12px 14px;
border-left: 4px solid #d2831f;
background: #fff;
border-radius: 8px;
}
.grammar-example,
.speaking-cue,
.pattern-drill-hint {
margin-top: 8px;
color: #66553a;
font-style: italic;
}
.speaking-prompt-item + .speaking-prompt-item,
.practical-task-item + .practical-task-item {
margin-top: 14px;
}
.token-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0;
}
.token-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: #eef3ff;
border: 1px solid #cfdbff;
border-radius: 999px;
font-size: 0.95rem;
}
.dialog-snippet {
margin: 14px 0;
padding: 14px;
background: #fff;
border-radius: 10px;
border: 1px solid #e6e6e6;
}
.response-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 8px;
resize: vertical;
}
.btn-back {
padding: 8px 16px;
border: 1px solid #ddd;
@@ -2023,7 +2317,11 @@ export default {
/* Reading Aloud & Speaking From Memory Styles */
.reading-aloud-exercise,
.speaking-from-memory-exercise {
.speaking-from-memory-exercise,
.sentence-building-exercise,
.dialog-completion-exercise,
.situational-response-exercise,
.pattern-drill-exercise {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
@@ -2229,4 +2527,15 @@ export default {
.dialog-button:hover {
background: #0056b3;
}
@media (max-width: 900px) {
.lesson-overview-card {
flex-direction: column;
}
.lesson-meta-grid {
grid-template-columns: 1fr;
min-width: 0;
}
}
</style>