d-ragon commited on
Commit
541216d
·
verified ·
1 Parent(s): b06fd07

Upload 28 files

Browse files
README.md CHANGED
@@ -1,9 +1,109 @@
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Vector
3
- sdk: docker
4
- emoji: 💻
5
- colorFrom: indigo
6
- colorTo: purple
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ---
8
- # Vector AI 🚀
9
 
 
 
 
1
+ # Vector AI 🚀
2
+
3
+ Vector AI is a premium, self-hosted AI workspace that combines the conversational power of ChatGPT with the research capabilities of Perplexity. Built on top of **Flowise**, it provides a stunning, high-performance interface for both casual chat and professional document development.
4
+
5
+ ![Vector AI Preview](https://img.shields.io/badge/Status-Active-brightgreen)
6
+ ![Framework](https://img.shields.io/badge/Framework-React-blue)
7
+ ![Backend](https://img.shields.io/badge/Backend-Node.js-66cc33)
8
+ ![AI-Powered](https://img.shields.io/badge/Powered%20By-Flowise-orange)
9
+
10
  ---
11
+
12
+ ## ✨ Key Features
13
+
14
+ ### 💬 Advanced Chat Interface
15
+ - **SSE Streaming**: Ultra-fast, real-time response streaming for a seamless experience.
16
+ - **Live Activity Indicators**: See exactly what the AI is doing—whether it's "Thinking", "Searching", or using specific tools.
17
+ - **LaTeX & KaTeX**: Full support for mathematical equations, fractions, and scientific notation with beautiful rendering.
18
+ - **File Uploads**: Support for processing PDF and text files directly within the chat.
19
+ - **Speech-to-Text**: Built-in voice input for hands-free interaction.
20
+
21
+ ### 🧪 Vector Labs (Workspaces)
22
+ - **Project-Based Editing**: Create and manage long-form documents in dedicated workspaces.
23
+ - **Surgical AI Edits**: Highlight specific sections of your text and have the AI refine just that selection.
24
+ - **Persistent State**: Projects are saved locally and persist through page refreshes and sessions.
25
+ - **Global Context**: The AI understands the full document while you edit, ensuring consistency.
26
+
27
+ ### 📄 Export & Integration
28
+ - **Modern Word Export**: Export your documents to `.docx` with clean, modern typography (Arial/Inter style).
29
+ - **PDF Generation**: Quick PDF exports for sharing your work.
30
+ - **Model Selector**: Easily switch between different Flowise Chatflows for specialized tasks.
31
+
32
+ ---
33
+
34
+ ## 🚀 Quick Start
35
+
36
+ ### 1. Prerequisites
37
+ - **Node.js** (v18 or higher)
38
+ - A running **Flowise** instance (local, HF Spaces, or Railway)
39
+
40
+ ### 2. Installation
41
+ ```bash
42
+ # Clone the repository
43
+ git clone https://github.com/your-repo/vector-ai.git
44
+ cd vector-ai
45
+
46
+ # Install dependencies
47
+ npm install
48
+ ```
49
+
50
+ ### 3. Configuration
51
+ Create a `.env` file in the root directory:
52
+
53
+ ```env
54
+ PORT=3000
55
+
56
+ # Primary Chat Models
57
+ MODEL_1_NAME="Vector GPT"
58
+ MODEL_1_ID="YOUR_CHATFLOW_ID_1"
59
+ MODEL_1_HOST="https://your-flowise.hf.space"
60
+
61
+ MODEL_2_NAME="Vector Research"
62
+ MODEL_2_ID="YOUR_CHATFLOW_ID_2"
63
+ MODEL_2_HOST="https://your-flowise.hf.space"
64
+
65
+ # Dedicated Labs Model (Optional - falls back to Model 1)
66
+ LABS_MODEL_NAME="Vector Editor"
67
+ LABS_MODEL_ID="YOUR_LABS_CHATFLOW_ID"
68
+ LABS_MODEL_HOST="https://your-flowise.hf.space"
69
+
70
+ # Optional: Flowise Authentication
71
+ # FLOWISE_API_KEY=your_api_key
72
+ ```
73
+
74
+ ### 4. Development & Build
75
+ ```bash
76
+ # Run in development mode
77
+ npm run dev
78
+
79
+ # Build the frontend and run the production server
80
+ npm run build
81
+ npm start
82
+ ```
83
+
84
+ ---
85
+
86
+ ## 🛠 Tech Stack
87
+
88
+ - **Frontend**: [React](https://reactjs.org/) + [Vite](https://vitejs.dev/)
89
+ - **Styling**: [Tailwind CSS](https://tailwindcss.com/) + [Framer Motion](https://www.framer.com/motion/)
90
+ - **Backend**: [Node.js](https://nodejs.org/) + [Express](https://expressjs.com/)
91
+ - **Streaming**: Server-Sent Events (SSE)
92
+ - **AI Orchestration**: [Flowise](https://flowiseai.com/)
93
+ - **Rich Text**: [React Markdown](https://github.com/remarkjs/react-markdown) + [KaTeX](https://katex.org/)
94
+
95
+ ---
96
+
97
+ ## ☁️ Deployment
98
+
99
+ ### Render / Railway / HF Spaces
100
+ This project is designed to be easily deployable as a single service:
101
+
102
+ 1. **Build Command**: `npm install && npm run build`
103
+ 2. **Start Command**: `npm start`
104
+ 3. **Environment Variables**: Add your `MODEL_*` variables in the platform's dashboard.
105
+
106
  ---
 
107
 
108
+ ## 📝 License
109
+ This project is licensed under the MIT License.
client/src/components/ChatArea.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
  import ReactMarkdown from "react-markdown";
3
  import remarkGfm from "remark-gfm";
4
  import remarkMath from "remark-math";
@@ -20,7 +20,15 @@ import {
20
  Mic,
21
  X,
22
  Check,
23
- ChevronDown
 
 
 
 
 
 
 
 
24
  } from "lucide-react";
25
  import { clsx } from "clsx";
26
  import { twMerge } from "tailwind-merge";
@@ -100,11 +108,168 @@ const markdownSchema = {
100
  function MarkdownContent({ content }) {
101
  const [copiedCode, setCopiedCode] = React.useState(null);
102
 
103
- const handleCopyCode = (code, index) => {
104
  navigator.clipboard?.writeText(code);
105
  setCopiedCode(index);
106
  setTimeout(() => setCopiedCode(null), 2000);
107
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  return (
110
  <div className="markdown-content prose prose-invert max-w-none">
@@ -113,162 +278,7 @@ function MarkdownContent({ content }) {
113
  rehypePlugins={[rehypeKatex, [rehypeSanitize, markdownSchema]]}
114
  transformLinkUri={sanitizeLinkUrl}
115
  transformImageUri={sanitizeLinkUrl}
116
- components={{
117
- a: (props) => (
118
- <a
119
- {...props}
120
- target="_blank"
121
- rel="noopener noreferrer"
122
- href={sanitizeLinkUrl(props.href)}
123
- className="text-white underline decoration-border hover:text-white/90 hover:decoration-muted-foreground/40 transition-colors"
124
- />
125
- ),
126
- pre: ({ children, ...props }) => {
127
- const codeContent = React.Children.toArray(children)
128
- .map(child => {
129
- if (React.isValidElement(child) && child.props?.children) {
130
- return typeof child.props.children === 'string'
131
- ? child.props.children
132
- : '';
133
- }
134
- return '';
135
- })
136
- .join('');
137
- const index = Math.random().toString(36).substr(2, 9);
138
-
139
- return (
140
- <div className="relative group my-4 overflow-hidden">
141
- <pre
142
- {...props}
143
- className="bg-popover border border-border rounded-lg p-4 overflow-x-auto text-sm max-w-full"
144
- >
145
- {children}
146
- </pre>
147
- <button
148
- onClick={() => handleCopyCode(codeContent, index)}
149
- className="absolute top-2 right-2 p-1.5 rounded-md bg-foreground/10 hover:bg-foreground/20 text-muted-foreground hover:text-white opacity-0 group-hover:opacity-100 transition-all"
150
- aria-label="Copy code"
151
- >
152
- {copiedCode === index ? (
153
- <Check size={14} className="text-green-400" />
154
- ) : (
155
- <Copy size={14} />
156
- )}
157
- </button>
158
- </div>
159
- );
160
- },
161
- code: ({ inline, className, children, ...props }) => {
162
- if (inline) {
163
- return (
164
- <code
165
- className="px-1.5 py-0.5 rounded-md bg-foreground/10 text-white text-sm font-mono"
166
- {...props}
167
- >
168
- {children}
169
- </code>
170
- );
171
- }
172
- return (
173
- <code className={cn("text-white/90 font-mono text-sm", className)} {...props}>
174
- {children}
175
- </code>
176
- );
177
- },
178
- table: ({ children, ...props }) => (
179
- <div className="my-4 overflow-x-auto rounded-lg border border-border max-w-full">
180
- <table className="min-w-full divide-y divide-border" {...props}>
181
- {children}
182
- </table>
183
- </div>
184
- ),
185
- thead: ({ children, ...props }) => (
186
- <thead className="bg-foreground/5" {...props}>
187
- {children}
188
- </thead>
189
- ),
190
- th: ({ children, ...props }) => (
191
- <th
192
- className="px-3 py-2 md:px-4 md:py-3 text-left text-xs font-semibold text-white uppercase tracking-wider border-r border-border last:border-r-0"
193
- {...props}
194
- >
195
- {children}
196
- </th>
197
- ),
198
- td: ({ children, ...props }) => (
199
- <td
200
- className="px-3 py-2 md:px-4 md:py-3 text-sm text-white/80 border-r border-border last:border-r-0"
201
- {...props}
202
- >
203
- {children}
204
- </td>
205
- ),
206
- tr: ({ children, ...props }) => (
207
- <tr
208
- className="border-b border-border last:border-b-0 hover:bg-foreground/5 transition-colors"
209
- {...props}
210
- >
211
- {children}
212
- </tr>
213
- ),
214
- ul: ({ children, ...props }) => (
215
- <ul className="my-3 ml-1 space-y-2 list-none" {...props}>
216
- {children}
217
- </ul>
218
- ),
219
- ol: ({ children, ...props }) => (
220
- <ol className="my-3 ml-1 space-y-2 list-none counter-reset-item" {...props}>
221
- {children}
222
- </ol>
223
- ),
224
- li: ({ children, ordered, ...props }) => (
225
- <li className="relative pl-6 text-white/90" {...props}>
226
- <span className="absolute left-0 text-muted-foreground">•</span>
227
- {children}
228
- </li>
229
- ),
230
- h1: ({ children, ...props }) => (
231
- <h1 className="text-xl md:text-2xl font-bold text-white mt-6 mb-4 pb-2 border-b border-border" {...props}>
232
- {children}
233
- </h1>
234
- ),
235
- h2: ({ children, ...props }) => (
236
- <h2 className="text-lg md:text-xl font-semibold text-white mt-5 mb-3" {...props}>
237
- {children}
238
- </h2>
239
- ),
240
- h3: ({ children, ...props }) => (
241
- <h3 className="text-base md:text-lg font-semibold text-white mt-4 mb-2" {...props}>
242
- {children}
243
- </h3>
244
- ),
245
- p: ({ children, ...props }) => (
246
- <p className="my-[0.7em] text-white/90 leading-[1.65]" {...props}>
247
- {children}
248
- </p>
249
- ),
250
- strong: ({ children, ...props }) => (
251
- <strong className="font-bold text-white" {...props}>
252
- {children}
253
- </strong>
254
- ),
255
- em: ({ children, ...props }) => (
256
- <em className="italic text-white/90" {...props}>
257
- {children}
258
- </em>
259
- ),
260
- blockquote: ({ children, ...props }) => (
261
- <blockquote
262
- className="my-4 pl-4 border-l-4 border-border bg-foreground/5 py-2 pr-4 rounded-r-lg italic text-white/80"
263
- {...props}
264
- >
265
- {children}
266
- </blockquote>
267
- ),
268
- hr: (props) => (
269
- <hr className="my-6 border-t border-border" {...props} />
270
- )
271
- }}
272
  >
273
  {content || ""}
274
  </ReactMarkdown>
@@ -346,7 +356,7 @@ const MessageRow = React.memo(({
346
  animate={{ opacity: 1, y: 0 }}
347
  transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
348
  className={cn(
349
- "flex w-full group",
350
  msg.role === "user" ? "justify-end" : "justify-start"
351
  )}
352
  >
@@ -381,7 +391,7 @@ const MessageRow = React.memo(({
381
  </div>
382
  )}
383
  {isStreaming && isLastAssistant && msg.role === "assistant" && (
384
- <span className="inline-block w-1.5 h-5 bg-white/70 animate-pulse ml-0.5 align-middle rounded-sm" />
385
  )}
386
  </div>
387
 
@@ -577,14 +587,15 @@ export default function ChatArea({
577
  "How does AI work?",
578
  "Write a Python script",
579
  "Latest tech news"
580
- ].map((suggestion) => (
581
  <button
582
  key={suggestion}
583
  onClick={() => {
584
  onMessageChange(suggestion);
585
  setTimeout(() => onSend(), 100);
586
  }}
587
- className="px-3 py-2 md:px-4 rounded-full border border-border bg-foreground/5 text-xs md:text-sm text-muted-foreground hover:bg-foreground/8 hover:text-white transition-colors font-medium focus-visible:outline-none"
 
588
  >
589
  {suggestion}
590
  </button>
@@ -654,6 +665,8 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
654
  const [audioStream, setAudioStream] = React.useState(null);
655
  const [interimTranscript, setInterimTranscript] = React.useState("");
656
  const recognitionRef = React.useRef(null);
 
 
657
  const maxTextareaHeight = isHero ? 100 : 120;
658
  const minTextareaHeight = 44;
659
 
@@ -714,6 +727,7 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
714
  const handleMicClick = async () => {
715
  if (isRecording) {
716
  // Stop recording
 
717
  if (recognitionRef.current) {
718
  recognitionRef.current.stop();
719
  }
@@ -744,15 +758,16 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
744
  recognition.interimResults = true;
745
  recognition.lang = 'en-US';
746
 
747
- let finalTranscript = value;
 
748
 
749
  recognition.onresult = (event) => {
750
  let interim = '';
751
  for (let i = event.resultIndex; i < event.results.length; i++) {
752
  const transcript = event.results[i][0].transcript;
753
  if (event.results[i].isFinal) {
754
- finalTranscript = (finalTranscript ? finalTranscript + ' ' : '') + transcript;
755
- onChange(finalTranscript);
756
  } else {
757
  interim += transcript;
758
  }
@@ -762,6 +777,7 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
762
 
763
  recognition.onerror = (event) => {
764
  console.error('Speech recognition error:', event.error);
 
765
  if (audioStream) {
766
  audioStream.getTracks().forEach(track => track.stop());
767
  setAudioStream(null);
@@ -771,17 +787,18 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
771
  };
772
 
773
  recognition.onend = () => {
774
- // Restart if still in recording mode
775
- if (recognitionRef.current && isRecording) {
776
  try {
777
  recognitionRef.current.start();
778
  } catch (e) {
779
- // Ignore
780
  }
781
  }
782
  };
783
 
784
  recognition.start();
 
785
  setIsRecording(true);
786
  } catch (err) {
787
  console.error('Microphone access denied:', err);
@@ -862,8 +879,8 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
862
  </div>
863
  )}
864
  {!interimTranscript && value && (
865
- <div className="text-xs text-green-400/70 mt-1 truncate max-w-full">
866
- "{value.slice(-50)}{value.length > 50 ? '...' : ''}"
867
  </div>
868
  )}
869
  {!interimTranscript && !value && (
@@ -876,6 +893,7 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
876
  <button
877
  onClick={() => {
878
  // Cancel: stop recording and clear text
 
879
  if (recognitionRef.current) {
880
  recognitionRef.current.stop();
881
  recognitionRef.current = null;
@@ -886,6 +904,7 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
886
  }
887
  setIsRecording(false);
888
  setInterimTranscript("");
 
889
  onChange(""); // Clear transcribed text
890
  }}
891
  className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors"
@@ -897,6 +916,13 @@ function SearchInput({ value, onChange, onSend, disabled, isHero = false, featur
897
  <button
898
  onClick={() => {
899
  // Done: stop recording but keep the text
 
 
 
 
 
 
 
900
  if (recognitionRef.current) {
901
  recognitionRef.current.stop();
902
  recognitionRef.current = null;
@@ -1031,7 +1057,7 @@ function ActivityPanel({ steps, phase, toolName, isStreaming }) {
1031
  <span className="thinking-dot w-1 h-1 rounded-full bg-muted-foreground/80" style={{ animationDelay: "280ms" }} />
1032
  </span>
1033
  ) : (
1034
- <span className="text-[12px]">✅</span>
1035
  )}
1036
  <span className="truncate">{isStreaming && phase ? summaryText : summaryText}</span>
1037
  </span>
@@ -1061,7 +1087,7 @@ function ActivityPanel({ steps, phase, toolName, isStreaming }) {
1061
  {isStreaming && phase && (
1062
  <div className="activity-step-row">
1063
  <span className="activity-step-icon">
1064
- {phase === "searching" ? "🔍" : phase === "reading" ? "📖" : phase === "tool" ? "🔧" : "💭"}
1065
  </span>
1066
  <span className="text-muted-foreground/70 italic">
1067
  {phase === "searching" ? "Searching…" : phase === "reading" ? `Reading…` : phase === "tool" ? `Using ${toolName || "tool"}…` : "Thinking…"}
@@ -1080,7 +1106,7 @@ function ActivityStepRow({ step }) {
1080
  if (step.type === "search") {
1081
  return (
1082
  <div className="activity-step-row">
1083
- <span className="activity-step-icon">🔍</span>
1084
  <span>Searched: <span className="text-foreground/80 font-medium">"{step.query}"</span></span>
1085
  </div>
1086
  );
@@ -1090,7 +1116,7 @@ function ActivityStepRow({ step }) {
1090
  try { displayUrl = new URL(step.url).hostname; } catch { }
1091
  return (
1092
  <div className="activity-step-row">
1093
- <span className="activity-step-icon">🌐</span>
1094
  <span>Reading: <a href={step.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{displayUrl}</a></span>
1095
  </div>
1096
  );
@@ -1126,7 +1152,7 @@ function ActivityStepRow({ step }) {
1126
  if (step.type === "tool") {
1127
  return (
1128
  <div className="activity-step-row">
1129
- <span className="activity-step-icon">🔧</span>
1130
  <span>Used tool: <span className="font-medium">{step.tool}</span></span>
1131
  </div>
1132
  );
@@ -1136,14 +1162,14 @@ function ActivityStepRow({ step }) {
1136
 
1137
  function StreamingStatus({ phase, toolName }) {
1138
  const config = {
1139
- thinking: { icon: "💭", label: "Thinking" },
1140
- searching: { icon: "🔍", label: "Searching" },
1141
- reasoning: { icon: "🧠", label: "Analyzing" },
1142
- tool: { icon: "🔧", label: toolName ? `Using ${toolName}` : "Using tool" },
1143
- reading: { icon: "📖", label: "Reading sources" },
1144
- writing: { icon: "✍️", label: "Writing" },
1145
- planning: { icon: "📋", label: "Planning" },
1146
- executing: { icon: "⚡", label: "Executing" },
1147
  };
1148
  const { icon, label } = config[phase] || config.thinking;
1149
 
@@ -1163,7 +1189,7 @@ function StreamingStatus({ phase, toolName }) {
1163
  transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
1164
  className="inline-flex items-center gap-1.5"
1165
  >
1166
- <span className="text-[11px]">{icon}</span>
1167
  {label}…
1168
  </motion.span>
1169
  </AnimatePresence>
 
1
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
2
  import ReactMarkdown from "react-markdown";
3
  import remarkGfm from "remark-gfm";
4
  import remarkMath from "remark-math";
 
20
  Mic,
21
  X,
22
  Check,
23
+ ChevronDown,
24
+ Search,
25
+ BookOpen,
26
+ Wrench,
27
+ Brain,
28
+ PenLine,
29
+ ClipboardList,
30
+ Zap,
31
+ CheckCircle2
32
  } from "lucide-react";
33
  import { clsx } from "clsx";
34
  import { twMerge } from "tailwind-merge";
 
108
  function MarkdownContent({ content }) {
109
  const [copiedCode, setCopiedCode] = React.useState(null);
110
 
111
+ const handleCopyCode = useCallback((code, index) => {
112
  navigator.clipboard?.writeText(code);
113
  setCopiedCode(index);
114
  setTimeout(() => setCopiedCode(null), 2000);
115
+ }, []);
116
+
117
+ const components = useMemo(() => ({
118
+ a: (props) => (
119
+ <a
120
+ {...props}
121
+ target="_blank"
122
+ rel="noopener noreferrer"
123
+ href={sanitizeLinkUrl(props.href)}
124
+ className="text-white underline decoration-border hover:text-white/90 hover:decoration-muted-foreground/40 transition-colors"
125
+ />
126
+ ),
127
+ pre: ({ children, ...props }) => {
128
+ const codeContent = React.Children.toArray(children)
129
+ .map(child => {
130
+ if (React.isValidElement(child) && child.props?.children) {
131
+ return typeof child.props.children === 'string'
132
+ ? child.props.children
133
+ : '';
134
+ }
135
+ return '';
136
+ })
137
+ .join('');
138
+ const index = Math.random().toString(36).substr(2, 9);
139
+
140
+ return (
141
+ <div className="relative group my-4 overflow-hidden">
142
+ <pre
143
+ {...props}
144
+ className="bg-popover border border-border rounded-lg p-4 overflow-x-auto text-sm max-w-full"
145
+ >
146
+ {children}
147
+ </pre>
148
+ <button
149
+ onClick={() => handleCopyCode(codeContent, index)}
150
+ className="absolute top-2 right-2 p-1.5 rounded-md bg-foreground/10 hover:bg-foreground/20 text-muted-foreground hover:text-white opacity-0 group-hover:opacity-100 transition-all"
151
+ aria-label="Copy code"
152
+ >
153
+ {copiedCode === index ? (
154
+ <Check size={14} className="text-green-400" />
155
+ ) : (
156
+ <Copy size={14} />
157
+ )}
158
+ </button>
159
+ </div>
160
+ );
161
+ },
162
+ code: ({ inline, className, children, ...props }) => {
163
+ if (inline) {
164
+ return (
165
+ <code
166
+ className="px-1.5 py-0.5 rounded-md bg-foreground/10 text-white text-sm font-mono"
167
+ {...props}
168
+ >
169
+ {children}
170
+ </code>
171
+ );
172
+ }
173
+ return (
174
+ <code className={cn("text-white/90 font-mono text-sm", className)} {...props}>
175
+ {children}
176
+ </code>
177
+ );
178
+ },
179
+ table: ({ children, ...props }) => (
180
+ <div className="my-4 overflow-x-auto rounded-lg border border-border max-w-full">
181
+ <table className="min-w-full divide-y divide-border" {...props}>
182
+ {children}
183
+ </table>
184
+ </div>
185
+ ),
186
+ thead: ({ children, ...props }) => (
187
+ <thead className="bg-foreground/5" {...props}>
188
+ {children}
189
+ </thead>
190
+ ),
191
+ th: ({ children, ...props }) => (
192
+ <th
193
+ className="px-3 py-2 md:px-4 md:py-3 text-left text-xs font-semibold text-white uppercase tracking-wider border-r border-border last:border-r-0"
194
+ {...props}
195
+ >
196
+ {children}
197
+ </th>
198
+ ),
199
+ td: ({ children, ...props }) => (
200
+ <td
201
+ className="px-3 py-2 md:px-4 md:py-3 text-sm text-white/80 border-r border-border last:border-r-0"
202
+ {...props}
203
+ >
204
+ {children}
205
+ </td>
206
+ ),
207
+ tr: ({ children, ...props }) => (
208
+ <tr
209
+ className="border-b border-border last:border-b-0 hover:bg-foreground/5 transition-colors"
210
+ {...props}
211
+ >
212
+ {children}
213
+ </tr>
214
+ ),
215
+ ul: ({ children, ...props }) => (
216
+ <ul className="my-3 ml-1 space-y-2 list-none" {...props}>
217
+ {children}
218
+ </ul>
219
+ ),
220
+ ol: ({ children, ...props }) => (
221
+ <ol className="my-3 ml-1 space-y-2 list-none counter-reset-item" {...props}>
222
+ {children}
223
+ </ol>
224
+ ),
225
+ li: ({ children, ordered, ...props }) => (
226
+ <li className="relative pl-6 text-white/90" {...props}>
227
+ <span className="absolute left-0 text-muted-foreground">•</span>
228
+ {children}
229
+ </li>
230
+ ),
231
+ h1: ({ children, ...props }) => (
232
+ <h1 className="text-xl md:text-2xl font-bold text-white mt-6 mb-4 pb-2 border-b border-border" {...props}>
233
+ {children}
234
+ </h1>
235
+ ),
236
+ h2: ({ children, ...props }) => (
237
+ <h2 className="text-lg md:text-xl font-semibold text-white mt-5 mb-3" {...props}>
238
+ {children}
239
+ </h2>
240
+ ),
241
+ h3: ({ children, ...props }) => (
242
+ <h3 className="text-base md:text-lg font-semibold text-white mt-4 mb-2" {...props}>
243
+ {children}
244
+ </h3>
245
+ ),
246
+ p: ({ children, ...props }) => (
247
+ <p className="my-[0.7em] text-white/90 leading-[1.65]" {...props}>
248
+ {children}
249
+ </p>
250
+ ),
251
+ strong: ({ children, ...props }) => (
252
+ <strong className="font-bold text-white" {...props}>
253
+ {children}
254
+ </strong>
255
+ ),
256
+ em: ({ children, ...props }) => (
257
+ <em className="italic text-white/90" {...props}>
258
+ {children}
259
+ </em>
260
+ ),
261
+ blockquote: ({ children, ...props }) => (
262
+ <blockquote
263
+ className="my-4 pl-4 border-l-4 border-border bg-foreground/5 py-2 pr-4 rounded-r-lg italic text-white/80"
264
+ {...props}
265
+ >
266
+ {children}
267
+ </blockquote>
268
+ ),
269
+ hr: (props) => (
270
+ <hr className="my-6 border-t border-border" {...props} />
271
+ )
272
+ }), [copiedCode, handleCopyCode]);
273
 
274
  return (
275
  <div className="markdown-content prose prose-invert max-w-none">
 
278
  rehypePlugins={[rehypeKatex, [rehypeSanitize, markdownSchema]]}
279
  transformLinkUri={sanitizeLinkUrl}
280
  transformImageUri={sanitizeLinkUrl}
281
+ components={components}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  >
283
  {content || ""}
284
  </ReactMarkdown>
 
356
  animate={{ opacity: 1, y: 0 }}
357
  transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
358
  className={cn(
359
+ "flex w-full group message-row",
360
  msg.role === "user" ? "justify-end" : "justify-start"
361
  )}
362
  >
 
391
  </div>
392
  )}
393
  {isStreaming && isLastAssistant && msg.role === "assistant" && (
394
+ <span className="inline-block w-1.5 h-5 bg-white/70 stream-caret ml-0.5 align-middle rounded-sm" />
395
  )}
396
  </div>
397
 
 
587
  "How does AI work?",
588
  "Write a Python script",
589
  "Latest tech news"
590
+ ].map((suggestion, i) => (
591
  <button
592
  key={suggestion}
593
  onClick={() => {
594
  onMessageChange(suggestion);
595
  setTimeout(() => onSend(), 100);
596
  }}
597
+ className="px-3 py-2 md:px-4 rounded-full border border-border bg-foreground/5 text-xs md:text-sm text-muted-foreground hover:bg-foreground/8 hover:text-white transition-colors font-medium focus-visible:outline-none stagger-item"
598
+ style={{ animationDelay: `${i * 80}ms` }}
599
  >
600
  {suggestion}
601
  </button>
 
665
  const [audioStream, setAudioStream] = React.useState(null);
666
  const [interimTranscript, setInterimTranscript] = React.useState("");
667
  const recognitionRef = React.useRef(null);
668
+ const isRecordingRef = React.useRef(false); // Ref to avoid stale closure in onend
669
+ const finalTranscriptRef = React.useRef(""); // Ref to track accumulated transcript
670
  const maxTextareaHeight = isHero ? 100 : 120;
671
  const minTextareaHeight = 44;
672
 
 
727
  const handleMicClick = async () => {
728
  if (isRecording) {
729
  // Stop recording
730
+ isRecordingRef.current = false;
731
  if (recognitionRef.current) {
732
  recognitionRef.current.stop();
733
  }
 
758
  recognition.interimResults = true;
759
  recognition.lang = 'en-US';
760
 
761
+ // Use ref to track accumulated text (avoids stale closure issues)
762
+ finalTranscriptRef.current = value;
763
 
764
  recognition.onresult = (event) => {
765
  let interim = '';
766
  for (let i = event.resultIndex; i < event.results.length; i++) {
767
  const transcript = event.results[i][0].transcript;
768
  if (event.results[i].isFinal) {
769
+ finalTranscriptRef.current = (finalTranscriptRef.current ? finalTranscriptRef.current + ' ' : '') + transcript;
770
+ onChange(finalTranscriptRef.current);
771
  } else {
772
  interim += transcript;
773
  }
 
777
 
778
  recognition.onerror = (event) => {
779
  console.error('Speech recognition error:', event.error);
780
+ isRecordingRef.current = false;
781
  if (audioStream) {
782
  audioStream.getTracks().forEach(track => track.stop());
783
  setAudioStream(null);
 
787
  };
788
 
789
  recognition.onend = () => {
790
+ // Restart if still in recording mode — use ref to get current value
791
+ if (recognitionRef.current && isRecordingRef.current) {
792
  try {
793
  recognitionRef.current.start();
794
  } catch (e) {
795
+ // Ignore — may fail if already started
796
  }
797
  }
798
  };
799
 
800
  recognition.start();
801
+ isRecordingRef.current = true;
802
  setIsRecording(true);
803
  } catch (err) {
804
  console.error('Microphone access denied:', err);
 
879
  </div>
880
  )}
881
  {!interimTranscript && value && (
882
+ <div className="text-xs text-green-400/70 mt-1 truncate max-w-full flex items-center gap-1">
883
+ <Check size={10} className="shrink-0" /> "{value.slice(-50)}{value.length > 50 ? '...' : ''}"
884
  </div>
885
  )}
886
  {!interimTranscript && !value && (
 
893
  <button
894
  onClick={() => {
895
  // Cancel: stop recording and clear text
896
+ isRecordingRef.current = false;
897
  if (recognitionRef.current) {
898
  recognitionRef.current.stop();
899
  recognitionRef.current = null;
 
904
  }
905
  setIsRecording(false);
906
  setInterimTranscript("");
907
+ finalTranscriptRef.current = "";
908
  onChange(""); // Clear transcribed text
909
  }}
910
  className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors"
 
916
  <button
917
  onClick={() => {
918
  // Done: stop recording but keep the text
919
+ isRecordingRef.current = false;
920
+ // Save any pending interim transcript before stopping
921
+ if (interimTranscript) {
922
+ const updated = (finalTranscriptRef.current ? finalTranscriptRef.current + ' ' : '') + interimTranscript;
923
+ finalTranscriptRef.current = updated;
924
+ onChange(updated);
925
+ }
926
  if (recognitionRef.current) {
927
  recognitionRef.current.stop();
928
  recognitionRef.current = null;
 
1057
  <span className="thinking-dot w-1 h-1 rounded-full bg-muted-foreground/80" style={{ animationDelay: "280ms" }} />
1058
  </span>
1059
  ) : (
1060
+ <CheckCircle2 size={12} className="text-green-400 shrink-0" />
1061
  )}
1062
  <span className="truncate">{isStreaming && phase ? summaryText : summaryText}</span>
1063
  </span>
 
1087
  {isStreaming && phase && (
1088
  <div className="activity-step-row">
1089
  <span className="activity-step-icon">
1090
+ {phase === "searching" ? <Search size={12} /> : phase === "reading" ? <BookOpen size={12} /> : phase === "tool" ? <Wrench size={12} /> : <Brain size={12} />}
1091
  </span>
1092
  <span className="text-muted-foreground/70 italic">
1093
  {phase === "searching" ? "Searching…" : phase === "reading" ? `Reading…` : phase === "tool" ? `Using ${toolName || "tool"}…` : "Thinking…"}
 
1106
  if (step.type === "search") {
1107
  return (
1108
  <div className="activity-step-row">
1109
+ <span className="activity-step-icon"><Search size={12} /></span>
1110
  <span>Searched: <span className="text-foreground/80 font-medium">"{step.query}"</span></span>
1111
  </div>
1112
  );
 
1116
  try { displayUrl = new URL(step.url).hostname; } catch { }
1117
  return (
1118
  <div className="activity-step-row">
1119
+ <span className="activity-step-icon"><Globe size={12} /></span>
1120
  <span>Reading: <a href={step.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{displayUrl}</a></span>
1121
  </div>
1122
  );
 
1152
  if (step.type === "tool") {
1153
  return (
1154
  <div className="activity-step-row">
1155
+ <span className="activity-step-icon"><Wrench size={12} /></span>
1156
  <span>Used tool: <span className="font-medium">{step.tool}</span></span>
1157
  </div>
1158
  );
 
1162
 
1163
  function StreamingStatus({ phase, toolName }) {
1164
  const config = {
1165
+ thinking: { icon: <Brain size={11} />, label: "Thinking" },
1166
+ searching: { icon: <Search size={11} />, label: "Searching" },
1167
+ reasoning: { icon: <Brain size={11} />, label: "Analyzing" },
1168
+ tool: { icon: <Wrench size={11} />, label: toolName ? `Using ${toolName}` : "Using tool" },
1169
+ reading: { icon: <BookOpen size={11} />, label: "Reading sources" },
1170
+ writing: { icon: <PenLine size={11} />, label: "Writing" },
1171
+ planning: { icon: <ClipboardList size={11} />, label: "Planning" },
1172
+ executing: { icon: <Zap size={11} />, label: "Executing" },
1173
  };
1174
  const { icon, label } = config[phase] || config.thinking;
1175
 
 
1189
  transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
1190
  className="inline-flex items-center gap-1.5"
1191
  >
1192
+ <span className="flex items-center">{icon}</span>
1193
  {label}…
1194
  </motion.span>
1195
  </AnimatePresence>
client/src/components/ChatSidebar.jsx CHANGED
@@ -256,16 +256,17 @@ export default function ChatSidebar({
256
  <span className="text-xs">No history yet</span>
257
  </div>
258
  ) : (
259
- historyList.map((session) => (
260
  <button
261
  key={session.id}
262
  onClick={() => onSelectSession(session.id)}
263
  className={cn(
264
- "w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors group flex items-center gap-3 relative overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
265
  activeSessionId === session.id
266
  ? "bg-[#202338] text-foreground"
267
  : "text-muted-foreground hover:bg-[#1A1D29] hover:text-foreground"
268
  )}
 
269
  >
270
  {activeSessionId === session.id && (
271
  <span className="absolute left-0 top-2 bottom-2 w-[2px] bg-[#22D3EE] rounded-full" />
 
256
  <span className="text-xs">No history yet</span>
257
  </div>
258
  ) : (
259
+ historyList.map((session, i) => (
260
  <button
261
  key={session.id}
262
  onClick={() => onSelectSession(session.id)}
263
  className={cn(
264
+ "w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors group flex items-center gap-3 relative overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 stagger-item",
265
  activeSessionId === session.id
266
  ? "bg-[#202338] text-foreground"
267
  : "text-muted-foreground hover:bg-[#1A1D29] hover:text-foreground"
268
  )}
269
+ style={{ animationDelay: `${Math.min(i * 30, 300)}ms` }}
270
  >
271
  {activeSessionId === session.id && (
272
  <span className="absolute left-0 top-2 bottom-2 w-[2px] bg-[#22D3EE] rounded-full" />
client/src/components/LabsArea.jsx CHANGED
@@ -14,7 +14,8 @@ import {
14
  Menu,
15
  ChevronLeft,
16
  Save,
17
- Upload
 
18
  } from "lucide-react";
19
  import { clsx } from "clsx";
20
  import { twMerge } from "tailwind-merge";
@@ -507,13 +508,13 @@ export default function LabsArea({
507
  onClick={handleExportWord}
508
  className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-foreground/10 transition-colors"
509
  >
510
- 📄 Word (.docx)
511
  </button>
512
  <button
513
  onClick={handleExportPdf}
514
  className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-foreground/10 transition-colors"
515
  >
516
- 📕 PDF (.pdf)
517
  </button>
518
  </motion.div>
519
  )}
 
14
  Menu,
15
  ChevronLeft,
16
  Save,
17
+ Upload,
18
+ FileDown
19
  } from "lucide-react";
20
  import { clsx } from "clsx";
21
  import { twMerge } from "tailwind-merge";
 
508
  onClick={handleExportWord}
509
  className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-foreground/10 transition-colors"
510
  >
511
+ <FileText size={14} className="shrink-0" /> Word (.docx)
512
  </button>
513
  <button
514
  onClick={handleExportPdf}
515
  className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-foreground/10 transition-colors"
516
  >
517
+ <FileDown size={14} className="shrink-0" /> PDF (.pdf)
518
  </button>
519
  </motion.div>
520
  )}
client/src/components/LabsEditor.jsx CHANGED
@@ -900,7 +900,7 @@ You can use Markdown formatting:
900
  > Blockquotes
901
  `inline code`
902
 
903
- 💡 Tip: Use the toolbar above or select text for AI editing!"
904
  disabled={isProcessing || isEditing}
905
  className={cn(
906
  "w-full min-h-full p-6 bg-transparent outline-none resize-none text-white font-mono text-sm leading-relaxed",
 
900
  > Blockquotes
901
  `inline code`
902
 
903
+ Tip: Use the toolbar above or select text for AI editing!"
904
  disabled={isProcessing || isEditing}
905
  className={cn(
906
  "w-full min-h-full p-6 bg-transparent outline-none resize-none text-white font-mono text-sm leading-relaxed",
client/src/hooks/useChatSession.js CHANGED
@@ -115,6 +115,8 @@ export function useChatSession() {
115
  const abortRef = useRef(null);
116
  const timeoutRef = useRef(null);
117
  const initialLoadDone = useRef(false);
 
 
118
 
119
  const activeSession = sessions.find((item) => item.id === activeSessionId);
120
 
@@ -413,14 +415,26 @@ export function useChatSession() {
413
  controller.abort();
414
  }, REQUEST_TIMEOUT_MS);
415
  }
416
- updateSession(activeSession.id, (session) => ({
417
- ...session,
418
- messages: session.messages.map((msg) =>
419
- msg.id === assistantMessage.id
420
- ? { ...msg, content: msg.content + (parsed.text || "") }
421
- : msg
422
- )
423
- }));
 
 
 
 
 
 
 
 
 
 
 
 
424
  }
425
 
426
  if (eventName === "activity") {
@@ -472,27 +486,30 @@ export function useChatSession() {
472
  }
473
  });
474
 
475
- // Force React to update on each chunk by using a render trigger
476
- let renderCounter = 0;
477
-
478
  while (true) {
479
  const { value, done } = await reader.read();
480
  if (done) break;
481
 
482
  const chunk = decoder.decode(value, { stream: true });
483
  parser.feed(chunk);
 
484
 
485
- // Batch updates more efficiently - reduce render frequency for better performance
486
- renderCounter++;
487
- if (renderCounter % 6 === 0) {
488
- // Use requestAnimationFrame for smoother streaming updates
489
- requestAnimationFrame(() => {
490
- setSessions(prev => [...prev]);
491
- });
492
- }
 
 
 
 
 
493
  }
494
 
495
- // Final update to ensure everything is saved
496
  setSessions(prev => [...prev]);
497
  } catch (error) {
498
  // Clear timeout on error
 
115
  const abortRef = useRef(null);
116
  const timeoutRef = useRef(null);
117
  const initialLoadDone = useRef(false);
118
+ const tokenBufferRef = useRef("");
119
+ const flushScheduledRef = useRef(false);
120
 
121
  const activeSession = sessions.find((item) => item.id === activeSessionId);
122
 
 
415
  controller.abort();
416
  }, REQUEST_TIMEOUT_MS);
417
  }
418
+ // Buffer tokens and flush via rAF for smooth streaming
419
+ tokenBufferRef.current += (parsed.text || "");
420
+ if (!flushScheduledRef.current) {
421
+ flushScheduledRef.current = true;
422
+ requestAnimationFrame(() => {
423
+ const buffered = tokenBufferRef.current;
424
+ tokenBufferRef.current = "";
425
+ flushScheduledRef.current = false;
426
+ if (buffered) {
427
+ updateSession(activeSession.id, (session) => ({
428
+ ...session,
429
+ messages: session.messages.map((msg) =>
430
+ msg.id === assistantMessage.id
431
+ ? { ...msg, content: msg.content + buffered }
432
+ : msg
433
+ )
434
+ }));
435
+ }
436
+ });
437
+ }
438
  }
439
 
440
  if (eventName === "activity") {
 
486
  }
487
  });
488
 
 
 
 
489
  while (true) {
490
  const { value, done } = await reader.read();
491
  if (done) break;
492
 
493
  const chunk = decoder.decode(value, { stream: true });
494
  parser.feed(chunk);
495
+ }
496
 
497
+ // Final flush of any remaining buffered tokens
498
+ if (tokenBufferRef.current) {
499
+ const remaining = tokenBufferRef.current;
500
+ tokenBufferRef.current = "";
501
+ flushScheduledRef.current = false;
502
+ updateSession(activeSession.id, (session) => ({
503
+ ...session,
504
+ messages: session.messages.map((msg) =>
505
+ msg.id === assistantMessage.id
506
+ ? { ...msg, content: msg.content + remaining }
507
+ : msg
508
+ )
509
+ }));
510
  }
511
 
512
+ // Final save
513
  setSessions(prev => [...prev]);
514
  } catch (error) {
515
  // Clear timeout on error
client/src/index.css CHANGED
@@ -103,6 +103,66 @@
103
  }
104
  }
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  /* Refined Input Container - Floating, Calm, Precise */
107
  .refined-input-container {
108
  position: relative;
 
103
  }
104
  }
105
 
106
+ /* ── Professional Micro-Interactions ── */
107
+
108
+ /* Subtle button press-down on click */
109
+ button:not(:disabled),
110
+ [role="button"]:not(:disabled) {
111
+ transition: transform 100ms ease, opacity 100ms ease,
112
+ background-color 150ms ease, color 150ms ease,
113
+ border-color 150ms ease, box-shadow 150ms ease;
114
+ }
115
+
116
+ button:active:not(:disabled),
117
+ [role="button"]:active:not(:disabled) {
118
+ transform: scale(0.97);
119
+ }
120
+
121
+ /* Smooth streaming caret (replaces harsh animate-pulse) */
122
+ @keyframes streamCaret {
123
+
124
+ 0%,
125
+ 100% {
126
+ opacity: 1;
127
+ }
128
+
129
+ 50% {
130
+ opacity: 0.2;
131
+ }
132
+ }
133
+
134
+ .stream-caret {
135
+ animation: streamCaret 0.8s ease-in-out infinite;
136
+ }
137
+
138
+ /* Performance: content-visibility for off-screen messages */
139
+ .message-row {
140
+ content-visibility: auto;
141
+ contain-intrinsic-size: auto 120px;
142
+ }
143
+
144
+ /* GPU-accelerated sidebar transition */
145
+ aside {
146
+ will-change: transform;
147
+ }
148
+
149
+ /* Stagger animation utility */
150
+ @keyframes slideUp {
151
+ from {
152
+ opacity: 0;
153
+ transform: translateY(8px);
154
+ }
155
+
156
+ to {
157
+ opacity: 1;
158
+ transform: translateY(0);
159
+ }
160
+ }
161
+
162
+ .stagger-item {
163
+ animation: slideUp 0.3s ease-out both;
164
+ }
165
+
166
  /* Refined Input Container - Floating, Calm, Precise */
167
  .refined-input-container {
168
  position: relative;