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
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>
421 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|