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

@@ -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 }
}