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:
@@ -1,6 +1,7 @@
|
||||
<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 { usePolling } from './composables/usePolling.js'
|
||||
import TopicSidebar from './components/TopicSidebar.vue'
|
||||
import TopicDetail from './components/TopicDetail.vue'
|
||||
import ElementsSidebar from './components/ElementsSidebar.vue'
|
||||
@@ -56,7 +57,6 @@ async function loadProviders() {
|
||||
console.error('Fehler beim Laden der Provider:', e)
|
||||
}
|
||||
}
|
||||
let pollTimer = null
|
||||
|
||||
function applyTheme() {
|
||||
document.documentElement.classList.toggle('dark', darkMode.value)
|
||||
@@ -158,7 +158,7 @@ async function loadBausteine() {
|
||||
bausteine.value = { ...EMPTY_BAUSTEINE }
|
||||
fortschritt.value = {}
|
||||
}
|
||||
if (activeBausteine.value.length && !pollTimer) startPolling()
|
||||
if (activeBausteine.value.length && !polling.running()) startPolling()
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Bausteine:', e)
|
||||
}
|
||||
@@ -276,20 +276,11 @@ async function handleDeleteGuide(guideId, slots = false) {
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(async () => {
|
||||
await Promise.all([loadGuides(), loadBausteine(), loadTopics()])
|
||||
if (!hasActiveGuides.value && !activeBausteine.value.length) stopPolling()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
const polling = usePolling(
|
||||
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
|
||||
() => hasActiveGuides.value || activeBausteine.value.length > 0,
|
||||
)
|
||||
const startPolling = polling.start
|
||||
|
||||
async function handleCancel(guideId) {
|
||||
await apiCancel(guideId)
|
||||
@@ -311,16 +302,6 @@ async function handleDeleteTopic(topic) {
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
if (document.hidden) {
|
||||
stopPolling()
|
||||
} else {
|
||||
loadGuides()
|
||||
loadBausteine()
|
||||
if (hasActiveGuides.value || activeBausteine.value.length) startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()])
|
||||
const savedTopic = localStorage.getItem('lastTopic')
|
||||
@@ -333,12 +314,6 @@ onMounted(async () => {
|
||||
} else if (!selectedTopic.value && topics.value.length) {
|
||||
selectTopic(topics.value[0])
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
|
||||
const props = defineProps({
|
||||
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 messages = ref([])
|
||||
const input = ref('')
|
||||
const loading = ref(false)
|
||||
const messagesEl = ref(null)
|
||||
const inputEl = ref(null)
|
||||
const panelEl = ref(null)
|
||||
|
||||
function openChat() {
|
||||
@@ -88,8 +92,7 @@ function openChat() {
|
||||
|
||||
function closeChat() {
|
||||
chatOpen.value = false
|
||||
messages.value = []
|
||||
input.value = ''
|
||||
chat.reset()
|
||||
}
|
||||
|
||||
function onDocMouseDown(e) {
|
||||
@@ -141,43 +144,6 @@ function extractContext() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -250,7 +216,12 @@ async function send() {
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></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>
|
||||
@@ -698,4 +669,8 @@ async function send() {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-input button.cancel {
|
||||
background: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
@@ -63,21 +64,7 @@ const activeGenerations = computed(() => {
|
||||
return [...bausteinLines, ...guideLines]
|
||||
})
|
||||
|
||||
// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"),
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
const { pending: pendingConfirm, armOrRun } = useConfirm()
|
||||
|
||||
function confirmCancelBausteine() {
|
||||
armOrRun('bausteine', () => emit('cancelBausteine'))
|
||||
|
||||
71
frontend/src/composables/useChat.js
Normal file
71
frontend/src/composables/useChat.js
Normal 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 }
|
||||
}
|
||||
32
frontend/src/composables/useConfirm.js
Normal file
32
frontend/src/composables/useConfirm.js
Normal 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 }
|
||||
}
|
||||
44
frontend/src/composables/usePolling.js
Normal file
44
frontend/src/composables/usePolling.js
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user