From 80787c06b38ea185146ea22dabb9e4de2e40efc8 Mon Sep 17 00:00:00 2001 From: pixelsama Date: Tue, 28 Oct 2025 11:23:14 +0800 Subject: [PATCH] feat: fullscreen live2d stage with streaming subtitles --- front_end/src/App.vue | 348 ++++++++---------- front_end/src/components/SubtitleBar.vue | 78 ++++ front_end/src/composables/useStreamingChat.js | 213 +++++++++++ 3 files changed, 450 insertions(+), 189 deletions(-) create mode 100644 front_end/src/components/SubtitleBar.vue create mode 100644 front_end/src/composables/useStreamingChat.js diff --git a/front_end/src/App.vue b/front_end/src/App.vue index 110abf9..4d8c923 100644 --- a/front_end/src/App.vue +++ b/front_end/src/App.vue @@ -1,263 +1,233 @@ - diff --git a/front_end/src/components/SubtitleBar.vue b/front_end/src/components/SubtitleBar.vue new file mode 100644 index 0000000..d41901f --- /dev/null +++ b/front_end/src/components/SubtitleBar.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/front_end/src/composables/useStreamingChat.js b/front_end/src/composables/useStreamingChat.js new file mode 100644 index 0000000..8830904 --- /dev/null +++ b/front_end/src/composables/useStreamingChat.js @@ -0,0 +1,213 @@ +import { ref } from 'vue'; + +const deltaHandlers = new Set(); +const doneHandlers = new Set(); +const errorHandlers = new Set(); +const isStreaming = ref(false); +let abortController = null; + +const stripTrailingSlash = (value) => (value.endsWith('/') ? value.slice(0, -1) : value); +const ensurePrefixedSlash = (value) => (value.startsWith('/') ? value : `/${value}`); + +const apiBase = (() => { + const raw = import.meta.env.VITE_API_BASE_URL?.trim(); + if (!raw) return ''; + return stripTrailingSlash(raw); +})(); + +const streamPath = (() => { + const raw = import.meta.env.VITE_STREAM_PATH?.trim() || 'chat/stream'; + return ensurePrefixedSlash(raw); +})(); + +const buildStreamUrl = () => `${apiBase}${streamPath}`; + +const parseSseChunk = (buffer, onEvent) => { + let startIndex = 0; + while (true) { + const endIndex = buffer.indexOf('\n\n', startIndex); + if (endIndex === -1) break; + const rawEvent = buffer.slice(startIndex, endIndex).trim(); + startIndex = endIndex + 2; + if (!rawEvent) continue; + + let eventType = 'message'; + const dataLines = []; + + rawEvent.split(/\n/).forEach((line) => { + if (line.startsWith('event:')) { + eventType = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + } + }); + + const data = dataLines.join('\n'); + onEvent(eventType, data); + } + + return buffer.slice(startIndex); +}; + +const notifyHandlers = (handlers, payload) => { + handlers.forEach((handler) => { + try { + handler(payload); + } catch (error) { + console.error('Streaming handler error:', error); + } + }); +}; + +export function useStreamingChat() { + const startStreaming = async (sessionId, content, extras = {}) => { + if (!content) { + console.warn('startStreaming called without content.'); + return; + } + + if (abortController) { + abortController.abort(); + } + + abortController = new AbortController(); + isStreaming.value = true; + + const payload = { + session_id: sessionId, + content, + ...extras, + }; + + let doneEmitted = false; + + const emitDone = (payload) => { + doneEmitted = true; + notifyHandlers(doneHandlers, payload); + }; + + try { + const response = await fetch(buildStreamUrl(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: abortController.signal, + }); + + if (!response.ok || !response.body) { + throw new Error(`流式接口请求失败: ${response.status}`); + } + + const reader = response.body.getReader(); + const textDecoder = new TextDecoder('utf-8'); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += textDecoder.decode(value, { stream: true }); + buffer = parseSseChunk(buffer, (eventType, data) => { + if (!data) return; + if (eventType === 'text-delta') { + try { + const parsed = JSON.parse(data); + if (parsed?.content) { + notifyHandlers(deltaHandlers, parsed.content); + } + } catch (error) { + console.error('Failed to parse text-delta payload:', error, data); + } + } else if (eventType === 'done') { + try { + const parsed = data ? JSON.parse(data) : null; + emitDone(parsed); + } catch (error) { + console.error('Failed to parse done payload:', error, data); + emitDone(null); + } + } else if (eventType === 'error') { + notifyHandlers(errorHandlers, data); + } + }); + } + + const remaining = textDecoder.decode(); + if (remaining) { + parseSseChunk(buffer + remaining, (eventType, data) => { + if (eventType === 'text-delta' && data) { + try { + const parsed = JSON.parse(data); + if (parsed?.content) { + notifyHandlers(deltaHandlers, parsed.content); + } + } catch (error) { + console.error('Failed to parse trailing delta payload:', error, data); + } + } else if (eventType === 'done') { + try { + const parsed = data ? JSON.parse(data) : null; + emitDone(parsed); + } catch (error) { + console.error('Failed to parse trailing done payload:', error, data); + emitDone(null); + } + } + }); + } + if (!doneEmitted) { + emitDone(null); + } + } catch (error) { + if (error.name === 'AbortError') { + console.info('Streaming aborted by client.'); + } else { + console.error('Streaming request failed:', error); + notifyHandlers(errorHandlers, error); + } + } finally { + if (abortController?.signal.aborted && !doneEmitted) { + emitDone({ aborted: true }); + } + abortController = null; + isStreaming.value = false; + } + }; + + const cancelStreaming = () => { + if (abortController) { + abortController.abort(); + } + }; + + const onDelta = (handler) => { + if (typeof handler === 'function') { + deltaHandlers.add(handler); + } + return () => deltaHandlers.delete(handler); + }; + + const onDone = (handler) => { + if (typeof handler === 'function') { + doneHandlers.add(handler); + } + return () => doneHandlers.delete(handler); + }; + + const onError = (handler) => { + if (typeof handler === 'function') { + errorHandlers.add(handler); + } + return () => errorHandlers.delete(handler); + }; + + return { + isStreaming, + startStreaming, + cancelStreaming, + onDelta, + onDone, + onError, + }; +}