Upload 28 files
Browse files- README.md +106 -6
- client/src/components/ChatArea.jsx +212 -186
- client/src/components/ChatSidebar.jsx +3 -2
- client/src/components/LabsArea.jsx +4 -3
- client/src/components/LabsEditor.jsx +1 -1
- client/src/hooks/useChatSession.js +37 -20
- client/src/index.css +60 -0
README.md
CHANGED
|
@@ -1,9 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+

|
| 6 |
+

|
| 7 |
+

|
| 8 |
+

|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 755 |
-
onChange(
|
| 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 &&
|
| 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 |
-
|
| 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 |
-
<
|
| 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" ?
|
| 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">
|
| 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">
|
| 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">
|
| 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:
|
| 1140 |
-
searching: { icon:
|
| 1141 |
-
reasoning: { icon:
|
| 1142 |
-
tool: { icon:
|
| 1143 |
-
reading: { icon:
|
| 1144 |
-
writing: { icon:
|
| 1145 |
-
planning: { icon:
|
| 1146 |
-
executing: { icon:
|
| 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="
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 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 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
}
|
| 494 |
|
| 495 |
-
// Final
|
| 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;
|