File size: 7,808 Bytes
3544cb6
2a548b8
 
 
 
 
 
 
3544cb6
2a548b8
 
d29d533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a548b8
 
d29d533
2a548b8
 
 
 
3544cb6
 
 
2a548b8
 
 
 
 
3544cb6
2a548b8
 
3544cb6
 
2a548b8
 
 
 
3544cb6
 
 
 
 
 
 
2a548b8
 
 
 
3544cb6
2a548b8
3544cb6
 
 
 
2a548b8
d29d533
 
3544cb6
 
d29d533
3544cb6
2a548b8
3544cb6
 
 
 
 
2a548b8
 
 
3544cb6
2a548b8
 
 
 
 
 
 
 
 
3544cb6
2a548b8
 
d29d533
2a548b8
 
 
 
 
 
3544cb6
2a548b8
 
 
 
d29d533
 
 
 
 
2a548b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3544cb6
2a548b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3544cb6
d29d533
2a548b8
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import React, { useState, useRef, useEffect, useCallback } from 'react'

const SUGGESTIONS = [
  "What are the top fintech unicorns?",
  "Tell me about SaaS startups in Bangalore",
  "What is DPIIT recognition?",
  "Which sectors are growing fastest?",
  "Compare edtech vs healthtech",
  "Latest news about Indian startups 2025",
]

// ─── XSS Prevention: escape HTML special chars ────────────────────────────────
function escapeHtml(text) {
  if (!text) return ''
  return text
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
}

// ─── XSS Prevention: strip script tags and event handlers ─────────────────────
function sanitizeChatContent(text) {
  if (!text) return ''
  let t = text
  // Remove script tags
  t = t.replace(/<script[^>]*>.*?<\/script>/gi, '')
  // Remove iframe tags
  t = t.replace(/<iframe[^>]*>.*?<\/iframe>/gi, '')
  // Remove event handlers (onerror, onclick, etc.)
  t = t.replace(/\son\w+\s*=\s*["']?[^"'>]*["']?/gi, '')
  // Remove javascript: and data: URLs
  t = t.replace(/(javascript|data|vbscript):/gi, '')
  // Remove meta refresh
  t = t.replace(/<meta[^>]*http-equiv\s*=\s*["']?refresh["']?[^>]*>/gi, '')
  return t
}

// ─── Render sanitized text with basic Markdown (no raw HTML) ──────────────────
function SafeMarkdown({ text }) {
  if (!text) return null
  const sanitized = sanitizeChatContent(text)
  // Convert **bold**, *italic*, `code`, and bullet points to HTML safely
  let html = escapeHtml(sanitized)
    // Bold
    .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
    // Italic
    .replace(/\*(.+?)\*/g, '<em>$1</em>')
    // Inline code
    .replace(/`([^`]+)`/g, '<code>$1</code>')
    // Bullet points (simple)
    .replace(/^\s*-\s+/gm, 'β€’ ')
    // Numbered lists
    .replace(/^\s*(\d+)\.\s+/gm, '$1. ')
    // Line breaks
    .replace(/\n/g, '<br>')

  return <span dangerouslySetInnerHTML={{ __html: html }} />
}

export default function ChatWidget({ onClose }) {
  const [messages, setMessages] = useState([
    { role: 'assistant', content: "πŸ‘‹ Hi! I'm Bharat Tech Atlas AI. Ask me about Indian startups, sectors, funding trends, or any company!" }
  ])
  const [input, setInput] = useState('')
  const [loading, setLoading] = useState(false)
  const scrollRef = useRef(null)
  // Use a ref to always read the latest messages without stale closure issues
  const messagesRef = useRef(messages)
  useEffect(() => { messagesRef.current = messages }, [messages])

  useEffect(() => {
    scrollRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  const sendMessage = useCallback(async (text) => {
    if (!text.trim() || loading) return
    const userMsg = { role: 'user', content: text }

    // 1) Optimistically add user message to UI
    setMessages(prev => [...prev, userMsg])
    setInput('')
    setLoading(true)

    // 2) Build request body from the ref (guaranteed up-to-date)
    const currentMessages = [...messagesRef.current, userMsg]
    const payload = {
      messages: currentMessages.map(m => ({ role: m.role, content: m.content })),
      stream: false,
    }

    try {
      const resp = await fetch('/api/chat/completions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      })
      if (!resp.ok) {
        const errText = await resp.text()
        throw new Error(`HTTP ${resp.status}: ${errText}`)
      }
      const data = await resp.json()
      // Sanitize response before displaying
      const safeContent = sanitizeChatContent(data.content) || 'Sorry, I had trouble responding. Try again!'
      setMessages(prev => [...prev, {
        role: 'assistant',
        content: safeContent
      }])
    } catch (err) {
      console.error('Chat error:', err)
      setMessages(prev => [...prev, {
        role: 'assistant',
        content: '⚠️ Network error. Please try again in a moment.'
      }])
    } finally {
      setLoading(false)
    }
  }, [loading])

  return (
    <div className="fixed bottom-4 right-4 z-50 w-[380px] max-w-[calc(100vw-2rem)] h-[520px] max-h-[calc(100vh-2rem)] bg-atlas-bg border border-atlas-border rounded-2xl shadow-2xl flex flex-col overflow-hidden">
      {/* Header */}
      <div className="flex items-center justify-between px-4 py-3 border-b border-atlas-border bg-atlas-surface">
        <div className="flex items-center gap-2">
          <span className="text-lg">πŸ€–</span>
          <div>
            <h3 className="text-sm font-semibold text-atlas-text">Bharat Tech Atlas AI</h3>
            <p className="text-[10px] text-atlas-muted">Powered by Qwen2.5-0.5B + Web Search</p>
          </div>
        </div>
        <button onClick={onClose} className="text-atlas-muted hover:text-atlas-text text-lg" aria-label="Close chat">βœ•</button>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-3 space-y-3">
        {messages.map((m, i) => (
          <div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
            <div className={`max-w-[85%] rounded-xl px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap ${
              m.role === 'user'
                ? 'bg-brand-500/20 text-brand-300 rounded-br-none'
                : 'bg-atlas-surface text-atlas-muted rounded-bl-none'
            }`}>
              {m.role === 'user' ? (
                <span>{m.content}</span>
              ) : (
                <SafeMarkdown text={m.content} />
              )}
            </div>
          </div>
        ))}
        {loading && (
          <div className="flex justify-start">
            <div className="bg-atlas-surface rounded-xl rounded-bl-none px-3 py-2">
              <div className="flex gap-1">
                <span className="w-1.5 h-1.5 bg-atlas-muted rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
                <span className="w-1.5 h-1.5 bg-atlas-muted rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
                <span className="w-1.5 h-1.5 bg-atlas-muted rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
              </div>
            </div>
          </div>
        )}
        <div ref={scrollRef} />
      </div>

      {/* Suggestions */}
      {messages.length < 3 && (
        <div className="px-3 pb-2 flex flex-wrap gap-1.5">
          {SUGGESTIONS.map(s => (
            <button key={s} onClick={() => sendMessage(s)}
              className="text-[10px] px-2 py-1 rounded-full bg-atlas-surface border border-atlas-border text-atlas-muted hover:text-atlas-text hover:border-brand-500/30 transition-colors">
              {s}
            </button>
          ))}
        </div>
      )}

      {/* Input */}
      <div className="px-3 py-2 border-t border-atlas-border flex gap-2">
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && sendMessage(input)}
          placeholder="Ask about startups, sectors, funding, latest news..."
          maxLength={2000}
          className="flex-1 bg-atlas-surface border border-atlas-border rounded-lg px-3 py-2 text-xs text-atlas-text placeholder:text-atlas-muted/50 focus:outline-none focus:border-brand-500/50"
        />
        <button onClick={() => sendMessage(input)} disabled={loading || !input.trim()}
          className="px-3 py-2 bg-brand-500/20 text-brand-400 rounded-lg text-xs font-medium hover:bg-brand-500/30 disabled:opacity-30 transition-colors">
          Send
        </button>
      </div>
    </div>
  )
}