This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BreakPilot Dev b464366341
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
feat: Add staged funding model, financial compute engine, annex slides and UI enhancements
Restructure financial plan from single 200k SAFE to realistic staged funding
(25k Stammkapital, 25k Angel, 200k Wandeldarlehen, 1M Series A = 1.25M total).
Add 60-month compute engine with CAPEX/OPEX accounting, cash constraints,
hardware financing (30% upfront / 70% leasing), and revenue-based hiring caps.
Rebuild TheAskSlide with 4-event funding timeline, update i18n (DE/EN),
chat agent core messages, and add 15 new annex/technology slides with
supporting UI components (KPICard, RunwayGauge, WaterfallChart, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:20:02 +01:00

421 lines
16 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight } from 'lucide-react'
import { ChatMessage, Language, SlideId } from '@/lib/types'
import { t } from '@/lib/i18n'
interface ChatFABProps {
lang: Language
currentSlide: SlideId
currentIndex: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => 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 from the text
const gotoRegex = /\[GOTO:(\d+)\]/g
let gotoMatch
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
const slideIndex = parseInt(gotoMatch[1])
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:\d+\]/g, '')
// Clean up trailing reminder instruction that might leak through
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
return { text: text.trim(), followUps, gotos }
}
export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlides, onGoToSlide }: ChatFABProps) {
const i = t(lang)
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map())
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, parsedResponses])
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 200)
}
}, [isOpen])
// 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
}
return -1
}, [messages])
useEffect(() => {
if (!isStreaming && lastAssistantIndex >= 0 && !parsedResponses.has(lastAssistantIndex)) {
const msg = messages[lastAssistantIndex]
const parsed = parseAgentResponse(msg.content, lang)
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
}
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
async function sendMessage(text?: string) {
const message = text || input.trim()
if (!message || isStreaming) return
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
abortRef.current = new AbortController()
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
history: messages.slice(-10),
lang,
slideContext: {
currentSlide,
currentIndex,
visitedSlides: Array.from(visitedSlides),
totalSlides: 13,
},
}),
signal: abortRef.current.signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
const currentText = content
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: currentText }
return updated
})
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('Chat error:', err)
setMessages(prev => [
...prev,
{ role: 'assistant', content: lang === 'de'
? 'Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'
: 'Connection failed. Please try again.'
},
])
} finally {
setIsStreaming(false)
abortRef.current = null
}
}
function stopGeneration() {
if (abortRef.current) {
abortRef.current.abort()
setIsStreaming(false)
}
}
const suggestions = i.aiqa.suggestions.slice(0, 3)
function renderMessageContent(msg: ChatMessage, idx: number) {
const parsed = parsedResponses.get(idx)
const displayText = parsed ? parsed.text : msg.content
return (
<>
<div className="whitespace-pre-wrap">{displayText}</div>
{isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && (
<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) => (
<button
key={gi}
onClick={() => onGoToSlide(g.index)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg
bg-indigo-500/20 border border-indigo-500/30
hover:bg-indigo-500/30 transition-colors
text-xs text-indigo-300"
>
<ArrowRight className="w-3 h-3" />
{g.label}
</button>
))}
</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) => (
<button
key={qi}
onClick={() => sendMessage(q)}
className="block w-full text-left px-2.5 py-2 rounded-lg
bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors
text-xs text-white/60 hover:text-white/90"
>
{q}
</button>
))}
</div>
)}
</>
)
}
return (
<>
{/* FAB Button — sits to the left of NavigationFAB */}
<AnimatePresence>
{!isOpen && (
<motion.button
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] z-50 w-14 h-14 rounded-full
bg-indigo-600 hover:bg-indigo-500 text-white
flex items-center justify-center shadow-lg shadow-indigo-600/30
transition-colors"
aria-label={lang === 'de' ? 'Investor Agent oeffnen' : 'Open Investor Agent'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
<circle cx="9" cy="10" r="1" fill="currentColor" />
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
</svg>
</motion.button>
)}
</AnimatePresence>
{/* Chat Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ duration: 0.2 }}
className={`fixed bottom-6 right-6 z-50
${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[520px]'}
rounded-2xl overflow-hidden
bg-black/90 backdrop-blur-xl border border-white/10
shadow-2xl shadow-black/50 flex flex-col
transition-all duration-200`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 shrink-0">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
<div>
<span className="text-sm font-semibold text-white">Investor Agent</span>
<span className="text-xs text-white/30 ml-2">
{isStreaming
? (lang === 'de' ? 'antwortet...' : 'responding...')
: (lang === 'de' ? 'online' : 'online')
}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(prev => !prev)}
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
>
{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={() => 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" />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-white/40 text-xs mb-3">
<Sparkles className="w-3.5 h-3.5" />
<span>{lang === 'de' ? 'Fragen Sie den Investor Agent:' : 'Ask the Investor Agent:'}</span>
</div>
{suggestions.map((q, idx) => (
<motion.button
key={idx}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + idx * 0.08 }}
onClick={() => sendMessage(q)}
className="block w-full text-left px-3 py-2.5 rounded-xl
bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors
text-xs text-white/70 hover:text-white"
>
{q}
</motion.button>
))}
</div>
)}
{messages.map((msg, idx) => (
<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'
}
`}
>
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
</div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center shrink-0 mt-0.5">
<User className="w-3.5 h-3.5 text-white/60" />
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-white/10 px-4 py-3 shrink-0">
{isStreaming && (
<button
onClick={stopGeneration}
className="w-full mb-2 px-3 py-1.5 rounded-lg bg-white/[0.06] hover:bg-white/[0.1]
text-xs text-white/50 transition-colors"
>
{lang === 'de' ? 'Antwort stoppen' : 'Stop response'}
</button>
)}
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder={lang === 'de' ? 'Frage stellen...' : 'Ask a question...'}
disabled={isStreaming}
className="flex-1 bg-white/[0.06] border border-white/10 rounded-xl px-3.5 py-2.5
text-xs text-white placeholder-white/30 outline-none
focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20
disabled:opacity-50 transition-all"
/>
<button
onClick={() => sendMessage()}
disabled={isStreaming || !input.trim()}
className="px-3.5 py-2.5 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-30
rounded-xl transition-all text-white"
>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)
}