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>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
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