[split-required] [guardrail-change] Enforce 500 LOC budget across all services
Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,101 +3,17 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight, Volume2, VolumeX } from 'lucide-react'
|
||||
import { ChatMessage, Language, SlideId } from '@/lib/types'
|
||||
import { ChatMessage } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
import { matchFAQMultiple, buildFAQContext } from '@/lib/presenter/faq-matcher'
|
||||
|
||||
interface ChatFABProps {
|
||||
lang: Language
|
||||
currentSlide: SlideId
|
||||
currentIndex: number
|
||||
visitedSlides: Set<number>
|
||||
onGoToSlide: (index: number) => void
|
||||
presenterState?: PresenterState
|
||||
onPresenterInterrupt?: () => void
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
text: string
|
||||
followUps: string[]
|
||||
gotos: { index: number; label: string }[]
|
||||
}
|
||||
|
||||
function parseAgentResponse(content: string, lang: Language): ParsedMessage {
|
||||
const followUps: string[] = []
|
||||
const gotos: { index: number; label: string }[] = []
|
||||
|
||||
// Split on the follow-up separator — flexible: "---", "- - -", "___", or multiple dashes
|
||||
const parts = content.split(/\n\s*[-_]{3,}\s*\n/)
|
||||
let text = parts[0]
|
||||
|
||||
// Parse follow-up questions from second part
|
||||
if (parts.length > 1) {
|
||||
const qSection = parts.slice(1).join('\n')
|
||||
// Match [Q], **[Q]**, or numbered/bulleted question patterns
|
||||
const qMatches = qSection.matchAll(/(?:\[Q\]|\*\*\[Q\]\*\*)\s*(.+?)(?:\n|$)/g)
|
||||
for (const m of qMatches) {
|
||||
const q = m[1].trim().replace(/^\*\*|\*\*$/g, '')
|
||||
if (q.length > 5) followUps.push(q)
|
||||
}
|
||||
|
||||
// Fallback: if no [Q] markers found, look for numbered or bulleted questions in the section
|
||||
if (followUps.length === 0) {
|
||||
const lineMatches = qSection.matchAll(/(?:^|\n)\s*(?:\d+[\.\)]\s*|[-•]\s*)(.+?\?)\s*$/gm)
|
||||
for (const m of lineMatches) {
|
||||
const q = m[1].trim()
|
||||
if (q.length > 5 && followUps.length < 3) followUps.push(q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also look for [Q] questions anywhere in the text (sometimes model puts them without ---)
|
||||
if (followUps.length === 0) {
|
||||
const inlineMatches = content.matchAll(/\[Q\]\s*(.+?)(?:\n|$)/g)
|
||||
const inlineQs: string[] = []
|
||||
for (const m of inlineMatches) {
|
||||
inlineQs.push(m[1].trim())
|
||||
}
|
||||
if (inlineQs.length >= 2) {
|
||||
followUps.push(...inlineQs)
|
||||
// Remove [Q] lines from main text
|
||||
text = text.replace(/\n?\s*\[Q\]\s*.+?(?:\n|$)/g, '\n').trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Parse GOTO markers — support both [GOTO:N] (numeric) and [GOTO:slide-id] (string)
|
||||
const gotoRegex = /\[GOTO:([\w-]+)\]/g
|
||||
let gotoMatch
|
||||
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
|
||||
const target = gotoMatch[1]
|
||||
let slideIndex: number
|
||||
|
||||
// Try numeric index first
|
||||
const numericIndex = parseInt(target)
|
||||
if (!isNaN(numericIndex) && numericIndex >= 0 && numericIndex < SLIDE_ORDER.length) {
|
||||
slideIndex = numericIndex
|
||||
} else {
|
||||
// Try slide ID lookup
|
||||
slideIndex = SLIDE_ORDER.indexOf(target as SlideId)
|
||||
}
|
||||
|
||||
if (slideIndex >= 0) {
|
||||
gotos.push({
|
||||
index: slideIndex,
|
||||
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Remove GOTO markers from visible text
|
||||
text = text.replace(/\s*\[GOTO:[\w-]+\]/g, '')
|
||||
|
||||
// Clean up trailing reminder instruction that might leak through
|
||||
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
|
||||
|
||||
return { text: text.trim(), followUps, gotos }
|
||||
}
|
||||
import {
|
||||
ChatFABProps,
|
||||
ParsedMessage,
|
||||
parseAgentResponse,
|
||||
cleanTextForTts,
|
||||
detectTtsLanguage,
|
||||
} from './ChatFAB.helpers'
|
||||
|
||||
export default function ChatFAB({
|
||||
lang,
|
||||
@@ -140,21 +56,14 @@ export default function ChatFAB({
|
||||
if (!chatTtsEnabled) return
|
||||
cancelChatAudio()
|
||||
|
||||
// Clean text for TTS: remove markdown formatting, keep plain speech
|
||||
const cleanText = text
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\[GOTO:[\w-]+\]/g, '')
|
||||
.replace(/\[Q\]\s*/g, '')
|
||||
.replace(/---/g, '')
|
||||
.trim()
|
||||
|
||||
const cleanText = cleanTextForTts(text)
|
||||
if (!cleanText) return
|
||||
|
||||
const controller = new AbortController()
|
||||
ttsAbortRef.current = controller
|
||||
|
||||
try {
|
||||
const textLang = /[äöüÄÖÜß]|(?:^|\s)(?:das|die|der|und|ist|wir|ein|für|mit|auf|von|den|des)\s/i.test(cleanText) ? 'de' : lang
|
||||
const textLang = detectTtsLanguage(cleanText, lang)
|
||||
const res = await fetch('/api/presenter/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -169,14 +78,8 @@ export default function ChatFAB({
|
||||
chatAudioRef.current = audio
|
||||
setIsChatSpeaking(true)
|
||||
|
||||
audio.onended = () => {
|
||||
setIsChatSpeaking(false)
|
||||
chatAudioRef.current = null
|
||||
}
|
||||
audio.onerror = () => {
|
||||
setIsChatSpeaking(false)
|
||||
chatAudioRef.current = null
|
||||
}
|
||||
audio.onended = () => { setIsChatSpeaking(false); chatAudioRef.current = null }
|
||||
audio.onerror = () => { setIsChatSpeaking(false); chatAudioRef.current = null }
|
||||
|
||||
await audio.play()
|
||||
} catch {
|
||||
@@ -196,8 +99,8 @@ export default function ChatFAB({
|
||||
|
||||
// Parse the latest assistant message when streaming ends
|
||||
const lastAssistantIndex = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'assistant') return i
|
||||
for (let idx = messages.length - 1; idx >= 0; idx--) {
|
||||
if (messages[idx].role === 'assistant') return idx
|
||||
}
|
||||
return -1
|
||||
}, [messages])
|
||||
@@ -207,8 +110,6 @@ export default function ChatFAB({
|
||||
const msg = messages[lastAssistantIndex]
|
||||
const parsed = parseAgentResponse(msg.content, lang)
|
||||
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
|
||||
|
||||
// Speak the response via TTS
|
||||
speakResponse(parsed.text)
|
||||
}
|
||||
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
|
||||
@@ -217,7 +118,6 @@ export default function ChatFAB({
|
||||
const message = text || input.trim()
|
||||
if (!message || isStreaming) return
|
||||
|
||||
// Interrupt presenter if it's running
|
||||
if (presenterState === 'presenting' && onPresenterInterrupt) {
|
||||
onPresenterInterrupt()
|
||||
}
|
||||
@@ -228,7 +128,6 @@ export default function ChatFAB({
|
||||
setIsStreaming(true)
|
||||
setIsWaiting(true)
|
||||
|
||||
// Find relevant FAQ entries as context for the LLM
|
||||
const faqMatches = matchFAQMultiple(message, lang, 3)
|
||||
const faqContext = buildFAQContext(faqMatches, lang)
|
||||
|
||||
@@ -240,17 +139,13 @@ export default function ChatFAB({
|
||||
history: messages.slice(-10),
|
||||
lang,
|
||||
slideContext: {
|
||||
currentSlide,
|
||||
currentIndex,
|
||||
currentSlide, currentIndex,
|
||||
visitedSlides: Array.from(visitedSlides),
|
||||
totalSlides: SLIDE_ORDER.length,
|
||||
},
|
||||
}
|
||||
|
||||
// Send FAQ context to LLM (not direct streaming — LLM interprets and combines)
|
||||
if (faqContext) {
|
||||
requestBody.faqContext = faqContext
|
||||
}
|
||||
if (faqContext) requestBody.faqContext = faqContext
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -291,8 +186,7 @@ export default function ChatFAB({
|
||||
if (topMatch?.goto_slide) {
|
||||
const gotoIdx = SLIDE_ORDER.indexOf(topMatch.goto_slide)
|
||||
if (gotoIdx >= 0) {
|
||||
const suffix = `\n\n[GOTO:${topMatch.goto_slide}]`
|
||||
content += suffix
|
||||
content += `\n\n[GOTO:${topMatch.goto_slide}]`
|
||||
setMessages(prev => {
|
||||
const updated = [...prev]
|
||||
updated[updated.length - 1] = { role: 'assistant', content }
|
||||
@@ -319,10 +213,7 @@ export default function ChatFAB({
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort()
|
||||
setIsStreaming(false)
|
||||
}
|
||||
if (abortRef.current) { abortRef.current.abort(); setIsStreaming(false) }
|
||||
}
|
||||
|
||||
const suggestions = i.aiqa.suggestions.slice(0, 3)
|
||||
@@ -338,7 +229,6 @@ export default function ChatFAB({
|
||||
<span className="inline-block w-1.5 h-3.5 bg-indigo-400 animate-pulse ml-0.5" />
|
||||
)}
|
||||
|
||||
{/* GOTO Buttons */}
|
||||
{parsed && parsed.gotos.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{parsed.gotos.map((g, gi) => (
|
||||
@@ -357,7 +247,6 @@ export default function ChatFAB({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Follow-Up Suggestions */}
|
||||
{parsed && parsed.followUps.length > 0 && !isStreaming && (
|
||||
<div className="mt-3 space-y-1.5 border-t border-white/10 pt-2">
|
||||
{parsed.followUps.map((q, qi) => (
|
||||
@@ -380,7 +269,7 @@ export default function ChatFAB({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FAB Button — sits to the left of NavigationFAB */}
|
||||
{/* FAB Button */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.button
|
||||
@@ -402,7 +291,6 @@ export default function ChatFAB({
|
||||
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="10" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
{/* Presenter active indicator */}
|
||||
{presenterState !== 'idle' && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-green-400 border-2 border-black animate-pulse" />
|
||||
)}
|
||||
@@ -445,12 +333,7 @@ export default function ChatFAB({
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setChatTtsEnabled(prev => {
|
||||
if (prev) cancelChatAudio()
|
||||
return !prev
|
||||
})
|
||||
}}
|
||||
onClick={() => { setChatTtsEnabled(prev => { if (prev) cancelChatAudio(); return !prev }) }}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center transition-colors ${
|
||||
isChatSpeaking ? 'bg-indigo-500/30' : 'bg-white/10 hover:bg-white/20'
|
||||
}`}
|
||||
@@ -471,10 +354,7 @@ export default function ChatFAB({
|
||||
{isExpanded ? <Minimize2 className="w-3.5 h-3.5 text-white/60" /> : <Maximize2 className="w-3.5 h-3.5 text-white/60" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
cancelChatAudio()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
onClick={() => { cancelChatAudio(); setIsOpen(false) }}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-white/60" />
|
||||
@@ -521,12 +401,12 @@ export default function ChatFAB({
|
||||
<Bot className="w-3.5 h-3.5 text-indigo-400" />
|
||||
</div>
|
||||
<div className="bg-white/[0.06] rounded-2xl px-3.5 py-3 flex items-center gap-1">
|
||||
{[0, 1, 2].map(i => (
|
||||
{[0, 1, 2].map(dotIdx => (
|
||||
<motion.span
|
||||
key={i}
|
||||
key={dotIdx}
|
||||
className="block w-1.5 h-1.5 rounded-full bg-indigo-400/70"
|
||||
animate={{ opacity: [0.3, 1, 0.3], y: [0, -3, 0] }}
|
||||
transition={{ duration: 0.7, repeat: Infinity, delay: i * 0.15 }}
|
||||
transition={{ duration: 0.7, repeat: Infinity, delay: dotIdx * 0.15 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -535,24 +415,15 @@ export default function ChatFAB({
|
||||
</AnimatePresence>
|
||||
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}
|
||||
>
|
||||
<div key={idx} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Bot className="w-3.5 h-3.5 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed
|
||||
${msg.role === 'user'
|
||||
? 'bg-indigo-500/20 text-white'
|
||||
: 'bg-white/[0.06] text-white/80'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-indigo-500/20 text-white' : 'bg-white/[0.06] text-white/80'
|
||||
}`}>
|
||||
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user