Frontend: Composables useConfirm/useChat/usePolling; Guide-Chat abbrechbar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
team3
2026-06-12 08:12:11 +02:00
parent 601237bbbf
commit 5c35939eab
6 changed files with 178 additions and 94 deletions

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js' import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
import ElementsSidebar from './components/ElementsSidebar.vue' import ElementsSidebar from './components/ElementsSidebar.vue'
@@ -56,7 +57,6 @@ async function loadProviders() {
console.error('Fehler beim Laden der Provider:', e) console.error('Fehler beim Laden der Provider:', e)
} }
} }
let pollTimer = null
function applyTheme() { function applyTheme() {
document.documentElement.classList.toggle('dark', darkMode.value) document.documentElement.classList.toggle('dark', darkMode.value)
@@ -158,7 +158,7 @@ async function loadBausteine() {
bausteine.value = { ...EMPTY_BAUSTEINE } bausteine.value = { ...EMPTY_BAUSTEINE }
fortschritt.value = {} fortschritt.value = {}
} }
if (activeBausteine.value.length && !pollTimer) startPolling() if (activeBausteine.value.length && !polling.running()) startPolling()
} catch (e) { } catch (e) {
console.error('Fehler beim Laden der Bausteine:', e) console.error('Fehler beim Laden der Bausteine:', e)
} }
@@ -276,20 +276,11 @@ async function handleDeleteGuide(guideId, slots = false) {
await loadGuides() await loadGuides()
} }
function startPolling() { const polling = usePolling(
stopPolling() () => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
pollTimer = setInterval(async () => { () => hasActiveGuides.value || activeBausteine.value.length > 0,
await Promise.all([loadGuides(), loadBausteine(), loadTopics()]) )
if (!hasActiveGuides.value && !activeBausteine.value.length) stopPolling() const startPolling = polling.start
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function handleCancel(guideId) { async function handleCancel(guideId) {
await apiCancel(guideId) await apiCancel(guideId)
@@ -311,16 +302,6 @@ async function handleDeleteTopic(topic) {
await loadGuides() await loadGuides()
} }
function onVisibility() {
if (document.hidden) {
stopPolling()
} else {
loadGuides()
loadBausteine()
if (hasActiveGuides.value || activeBausteine.value.length) startPolling()
}
}
onMounted(async () => { onMounted(async () => {
await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()]) await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()])
const savedTopic = localStorage.getItem('lastTopic') const savedTopic = localStorage.getItem('lastTopic')
@@ -333,12 +314,6 @@ onMounted(async () => {
} else if (!selectedTopic.value && topics.value.length) { } else if (!selectedTopic.value && topics.value.length) {
selectTopic(topics.value[0]) selectTopic(topics.value[0])
} }
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
stopPolling()
document.removeEventListener('visibilitychange', onVisibility)
}) })
</script> </script>

View File

@@ -2,6 +2,7 @@
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js' import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
import { useChat } from '../composables/useChat.js'
const props = defineProps({ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
@@ -72,13 +73,16 @@ async function toggleChapter(title) {
} }
} }
// --- Chat --- // --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
const chat = useChat((msgs) => {
const { section, outline } = extractContext()
return chatGuide(props.previewGuide.id, {
section, outline, messages: msgs, provider: props.provider,
})
})
const { messages, input, loading, messagesEl, inputEl, send } = chat
const autoGrow = () => chat.autoGrow()
const chatOpen = ref(false) const chatOpen = ref(false)
const messages = ref([])
const input = ref('')
const loading = ref(false)
const messagesEl = ref(null)
const inputEl = ref(null)
const panelEl = ref(null) const panelEl = ref(null)
function openChat() { function openChat() {
@@ -88,8 +92,7 @@ function openChat() {
function closeChat() { function closeChat() {
chatOpen.value = false chatOpen.value = false
messages.value = [] chat.reset()
input.value = ''
} }
function onDocMouseDown(e) { function onDocMouseDown(e) {
@@ -141,43 +144,6 @@ function extractContext() {
return { section, outline } return { section, outline }
} }
async function scrollToBottom() {
await nextTick()
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
function autoGrow() {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 140) + 'px'
}
async function send() {
const text = input.value.trim()
if (!text || loading.value || !props.previewGuide) return
messages.value.push({ role: 'user', content: text })
input.value = ''
nextTick(autoGrow)
loading.value = true
scrollToBottom()
try {
const { section, outline } = extractContext()
const res = await chatGuide(props.previewGuide.id, {
section,
outline,
messages: messages.value,
provider: props.provider,
})
messages.value.push({ role: 'assistant', content: res.reply || '…' })
} catch {
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
} finally {
loading.value = false
scrollToBottom()
nextTick(() => inputEl.value?.focus())
}
}
</script> </script>
<template> <template>
@@ -250,7 +216,12 @@ async function send() {
@input="autoGrow" @input="autoGrow"
@keydown.enter.exact.prevent="send" @keydown.enter.exact.prevent="send"
></textarea> ></textarea>
<button :disabled="!input.trim() || loading" @click="send"></button> <button
:disabled="!input.trim() && !loading"
:class="{ cancel: loading }"
:title="loading ? 'Abbrechen' : 'Senden'"
@click="send"
>{{ loading ? '✕' : '➤' }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -698,4 +669,8 @@ async function send() {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.chat-input button.cancel {
background: var(--danger);
}
</style> </style>

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({ const props = defineProps({
topics: { type: Array, required: true }, topics: { type: Array, required: true },
@@ -63,21 +64,7 @@ const activeGenerations = computed(() => {
return [...bausteinLines, ...guideLines] return [...bausteinLines, ...guideLines]
}) })
// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"), const { pending: pendingConfirm, armOrRun } = useConfirm()
// zweiter Klick führt aus. Browser-Dialoge können unterdrückt sein (Firefox).
const pendingConfirm = ref(null)
let confirmTimer = null
function armOrRun(key, action) {
clearTimeout(confirmTimer)
if (pendingConfirm.value === key) {
pendingConfirm.value = null
action()
} else {
pendingConfirm.value = key
confirmTimer = setTimeout(() => { pendingConfirm.value = null }, 3000)
}
}
function confirmCancelBausteine() { function confirmCancelBausteine() {
armOrRun('bausteine', () => emit('cancelBausteine')) armOrRun('bausteine', () => emit('cancelBausteine'))

View File

@@ -0,0 +1,71 @@
import { ref, nextTick } from 'vue'
// Gemeinsame Chat-Mechanik: senden, abbrechen (Run-Counter), scrollen, Fokus.
// performRequest(messages) → Promise<{ reply, … }>; send() gibt die Antwort
// zurück, damit der Aufrufer Extras (z. B. changes) auswerten kann.
export function useChat(performRequest) {
const messages = ref([])
const input = ref('')
const loading = ref(false)
const messagesEl = ref(null) // Template-Ref: Nachrichten-Container
const inputEl = ref(null) // Template-Ref: Textarea
let run = 0 // laufende Anfrage identifizieren; Abbruch ignoriert ihr Ergebnis
async function scrollToBottom() {
await nextTick()
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
function autoGrow(max = 140) {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, max) + 'px'
}
function cancel() {
run++
loading.value = false
messages.value.push({ role: 'assistant', content: 'Abgebrochen.' })
}
function reset() {
run++
loading.value = false
messages.value = []
input.value = ''
}
async function send() {
if (loading.value) { // zweiter Klick = abbrechen
cancel()
return null
}
const text = input.value.trim()
if (!text) return null
const current = ++run
messages.value.push({ role: 'user', content: text })
input.value = ''
nextTick(() => autoGrow())
loading.value = true
scrollToBottom()
try {
const res = await performRequest(messages.value)
if (current !== run) return null
messages.value.push({ role: 'assistant', content: res.reply || '…' })
return res
} catch {
if (current !== run) return null
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
return null
} finally {
if (current === run) {
loading.value = false
scrollToBottom()
nextTick(() => inputEl.value?.focus())
}
}
}
return { messages, input, loading, messagesEl, inputEl, send, cancel, reset, scrollToBottom, autoGrow }
}

View File

@@ -0,0 +1,32 @@
import { ref, onUnmounted } from 'vue'
// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"),
// zweiter Klick führt aus. Browser-Dialoge können unterdrückt sein (Firefox).
export function useConfirm(timeoutMs = 3000) {
const pending = ref(null) // aktuell scharfgeschalteter Key
let timer = null
function armOrRun(key, action) {
clearTimeout(timer)
if (pending.value === key) {
pending.value = null
action()
} else {
pending.value = key
timer = setTimeout(() => { pending.value = null }, timeoutMs)
}
}
function isArmed(key) {
return pending.value === key
}
function reset() {
clearTimeout(timer)
pending.value = null
}
onUnmounted(() => clearTimeout(timer))
return { pending, isArmed, armOrRun, reset }
}

View File

@@ -0,0 +1,44 @@
import { onUnmounted } from 'vue'
// Polling mit Visibility-Pause: Tab unsichtbar → stoppen; wieder sichtbar →
// sofortiger Tick, dann weiter, falls isActive(). Stoppt selbst, sobald
// isActive() nach einem Tick false liefert.
export function usePolling(tick, isActive, interval = 3000) {
let timer = null
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function start() {
stop()
timer = setInterval(async () => {
await tick()
if (!isActive()) stop()
}, interval)
}
function running() {
return !!timer
}
async function onVisibility() {
if (document.hidden) {
stop()
} else {
await tick()
if (isActive()) start()
}
}
document.addEventListener('visibilitychange', onVisibility)
onUnmounted(() => {
stop()
document.removeEventListener('visibilitychange', onVisibility)
})
return { start, stop, running }
}