d-ragon commited on
Commit
59bb0ce
·
verified ·
1 Parent(s): 719cede

Upload 28 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ client/node_modules
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+ pnpm-debug.log*
7
+
8
+ dist
9
+ client/dist
10
+ server/public
11
+
12
+ .git
13
+ .gitignore
14
+ .dockerignore
15
+ .DS_Store
16
+ Thumbs.db
17
+ .idea
18
+ .vscode
19
+
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ client/node_modules/
3
+
4
+ # Build outputs
5
+ dist/
6
+ client/dist/
7
+ server/public/
8
+
9
+ # Vite and other caches
10
+ .vite/
11
+ client/.vite/
12
+ .cache/
13
+
14
+ # Logs
15
+ npm-debug.log*
16
+ yarn-debug.log*
17
+ yarn-error.log*
18
+ pnpm-debug.log*
19
+
20
+ # Env files
21
+ .env
22
+ .env.*
23
+ !.env.example
24
+
25
+ # OS / editor junk
26
+ .DS_Store
27
+ Thumbs.db
28
+ .idea/
29
+ .vscode/
30
+
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Node.js 18 (LTS)
2
+ FROM node:18-alpine
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Copy root package files
8
+ COPY package.json package-lock.json ./
9
+
10
+ # Copy client package files
11
+ COPY client/package.json ./client/
12
+
13
+ # Install dependencies for both backend and frontend
14
+ RUN npm install
15
+ RUN cd client && npm install
16
+
17
+ # Copy source code
18
+ COPY . .
19
+
20
+ # Build the React frontend
21
+ RUN npm run build
22
+
23
+ # Expose Hugging Face Space port
24
+ ENV PORT=7860
25
+ EXPOSE 7860
26
+
27
+ # Start the server
28
+ CMD ["npm", "start"]
README.md CHANGED
@@ -1,10 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Vector
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
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/index.html ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
+ <meta name="apple-mobile-web-app-capable" content="yes" />
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
8
+ <meta name="theme-color" content="#0F1117" />
9
+ <title>Vector</title>
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link
13
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
14
+ rel="stylesheet"
15
+ />
16
+ </head>
17
+ <body class="bg-slate-950 text-slate-100">
18
+ <div id="root"></div>
19
+ <script type="module" src="/src/main.jsx"></script>
20
+ </body>
21
+ </html>
client/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "flowise-chat-client",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "clsx": "^2.1.1",
13
+ "docx": "^9.5.1",
14
+ "eventsource-parser": "^1.1.2",
15
+ "framer-motion": "^12.27.1",
16
+ "html2canvas": "^1.4.1",
17
+ "jspdf": "^4.0.0",
18
+ "katex": "^0.16.27",
19
+ "lucide-react": "^0.562.0",
20
+ "mammoth": "^1.11.0",
21
+ "pdf-parse": "^2.4.5",
22
+ "react": "^18.3.1",
23
+ "react-dom": "^18.3.1",
24
+ "react-markdown": "^9.0.1",
25
+ "rehype-katex": "^7.0.1",
26
+ "rehype-sanitize": "^6.0.0",
27
+ "remark-gfm": "^4.0.0",
28
+ "remark-math": "^6.0.0",
29
+ "tailwind-merge": "^3.4.0"
30
+ },
31
+ "devDependencies": {
32
+ "@vitejs/plugin-react": "^4.3.1",
33
+ "autoprefixer": "^10.4.20",
34
+ "postcss": "^8.4.41",
35
+ "tailwindcss": "^3.4.10",
36
+ "vite": "^5.4.2"
37
+ }
38
+ }
client/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {}
5
+ }
6
+ };
client/src/App.jsx ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import ChatSidebar from "./components/ChatSidebar.jsx";
3
+ import ChatArea from "./components/ChatArea.jsx";
4
+ import LabsArea from "./components/LabsArea.jsx";
5
+ import { useChatSession } from "./hooks/useChatSession.js";
6
+ import { clsx } from "clsx";
7
+ import { twMerge } from "tailwind-merge";
8
+
9
+ function cn(...inputs) {
10
+ return twMerge(clsx(inputs));
11
+ }
12
+
13
+ export default function App() {
14
+ const [theme, setTheme] = useState(
15
+ () => {
16
+ const stored = localStorage.getItem("flowise_theme");
17
+ if (stored === "light" || stored === "dark") return stored;
18
+ const prefersLight =
19
+ typeof window !== "undefined" &&
20
+ window.matchMedia?.("(prefers-color-scheme: light)")?.matches;
21
+ return prefersLight ? "light" : "dark";
22
+ }
23
+ );
24
+ const [sidebarOpen, setSidebarOpen] = useState(true);
25
+ const [activeView, setActiveView] = useState("chat"); // "chat" | "labs"
26
+ const [labsProjectLocked, setLabsProjectLocked] = useState(false); // Track labs project lock
27
+
28
+ const {
29
+ models,
30
+ selectedModelId,
31
+ setSelectedModelId,
32
+ selectedModel,
33
+ modelsIssues,
34
+ isModelsLoading,
35
+ modelsError,
36
+ reloadModels,
37
+ mode,
38
+ activeSession,
39
+ activeSessionId,
40
+ handleSelectSession,
41
+ isSessionLocked,
42
+ message,
43
+ setMessage,
44
+ isStreaming,
45
+ handleNewChat,
46
+ handleClearHistory,
47
+ handleModeChange,
48
+ handleSend,
49
+ historyList,
50
+ MODES,
51
+ ACTIVITY_LABELS
52
+ } = useChatSession();
53
+
54
+ const handleNewChatRef = useRef(handleNewChat);
55
+ useEffect(() => {
56
+ handleNewChatRef.current = handleNewChat;
57
+ }, [handleNewChat]);
58
+
59
+ useEffect(() => {
60
+ document.documentElement.classList.toggle("light", theme === "light");
61
+ localStorage.setItem("flowise_theme", theme);
62
+ }, [theme]);
63
+
64
+ // Fix mobile viewport height - reliable cross-browser approach
65
+ useEffect(() => {
66
+ const setAppHeight = () => {
67
+ const vh = window.innerHeight;
68
+ document.documentElement.style.setProperty('--app-height', `${vh}px`);
69
+ document.documentElement.classList.add('has-app-height');
70
+ };
71
+ setAppHeight();
72
+ window.addEventListener('resize', setAppHeight);
73
+ if (window.visualViewport) {
74
+ window.visualViewport.addEventListener('resize', setAppHeight);
75
+ }
76
+ return () => {
77
+ window.removeEventListener('resize', setAppHeight);
78
+ if (window.visualViewport) {
79
+ window.visualViewport.removeEventListener('resize', setAppHeight);
80
+ }
81
+ };
82
+ }, []);
83
+
84
+ useEffect(() => {
85
+ const onKeyDown = (e) => {
86
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "n") {
87
+ e.preventDefault();
88
+ if (activeView === "chat") {
89
+ handleNewChatRef.current?.();
90
+ }
91
+ }
92
+ };
93
+ window.addEventListener("keydown", onKeyDown);
94
+ return () => window.removeEventListener("keydown", onKeyDown);
95
+ }, [activeView]);
96
+
97
+ // Handle responsive sidebar
98
+ useEffect(() => {
99
+ const handleResize = () => {
100
+ if (window.innerWidth < 768) {
101
+ setSidebarOpen(false);
102
+ } else {
103
+ setSidebarOpen(true);
104
+ }
105
+ };
106
+ window.addEventListener('resize', handleResize);
107
+ handleResize();
108
+ return () => window.removeEventListener('resize', handleResize);
109
+ }, []);
110
+
111
+ return (
112
+ <div className="relative flex h-screen w-full overflow-hidden bg-background">
113
+ {/* Background Layer - Pure Neutral */}
114
+ <div className="pointer-events-none absolute inset-0 bg-background" />
115
+
116
+ <ChatSidebar
117
+ open={sidebarOpen}
118
+ setOpen={setSidebarOpen}
119
+ models={models}
120
+ selectedModelId={selectedModelId}
121
+ onSelectModel={setSelectedModelId}
122
+ isModelsLoading={isModelsLoading}
123
+ modelsError={modelsError}
124
+ modelsIssues={modelsIssues}
125
+ onReloadModels={reloadModels}
126
+ mode={mode}
127
+ modes={MODES}
128
+ onModeChange={handleModeChange}
129
+ onNewChat={handleNewChat}
130
+ onClearHistory={handleClearHistory}
131
+ historyList={historyList}
132
+ activeSessionId={activeSessionId}
133
+ onSelectSession={handleSelectSession}
134
+ isSessionLocked={activeView === "labs" ? false : isSessionLocked}
135
+ theme={theme}
136
+ onToggleTheme={() =>
137
+ setTheme((prev) => (prev === "dark" ? "light" : "dark"))
138
+ }
139
+ activeView={activeView}
140
+ onNavigateToLabs={() => setActiveView("labs")}
141
+ onNavigateToChat={() => setActiveView("chat")}
142
+ />
143
+
144
+ <main className={cn(
145
+ "relative flex-1 flex flex-col transition-all duration-300 ease-in-out h-full",
146
+ sidebarOpen ? "md:ml-[280px]" : "ml-0"
147
+ )}>
148
+ {activeView === "chat" ? (
149
+ <ChatArea
150
+ activeSession={activeSession}
151
+ isStreaming={isStreaming}
152
+ message={message}
153
+ onMessageChange={setMessage}
154
+ onSend={handleSend}
155
+ onSelectFollowUp={setMessage}
156
+ activityLabels={ACTIVITY_LABELS}
157
+ toggleSidebar={() => setSidebarOpen(!sidebarOpen)}
158
+ sidebarOpen={sidebarOpen}
159
+ features={selectedModel?.features || { uploads: false, stt: false }}
160
+ />
161
+ ) : (
162
+ <LabsArea
163
+ toggleSidebar={() => setSidebarOpen(!sidebarOpen)}
164
+ sidebarOpen={sidebarOpen}
165
+ onProjectLockChange={setLabsProjectLocked}
166
+ />
167
+ )}
168
+ </main>
169
+ </div>
170
+ );
171
+ }
client/src/components/AudioVisualizer.jsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * AudioVisualizer - Gemini-style organic fluid waveform
5
+ * Uses Web Audio API to visualize microphone input in real-time
6
+ */
7
+ export default function AudioVisualizer({ stream, isActive = true }) {
8
+ const canvasRef = useRef(null);
9
+ const animationRef = useRef(null);
10
+ const analyserRef = useRef(null);
11
+ const audioContextRef = useRef(null);
12
+
13
+ useEffect(() => {
14
+ if (!stream || !isActive) {
15
+ // Cleanup when not active
16
+ if (animationRef.current) {
17
+ cancelAnimationFrame(animationRef.current);
18
+ }
19
+ return;
20
+ }
21
+
22
+ const canvas = canvasRef.current;
23
+ if (!canvas) return;
24
+
25
+ const ctx = canvas.getContext("2d");
26
+
27
+ // Set up high-DPI canvas
28
+ const dpr = window.devicePixelRatio || 1;
29
+ const rect = canvas.getBoundingClientRect();
30
+ canvas.width = rect.width * dpr;
31
+ canvas.height = rect.height * dpr;
32
+ ctx.scale(dpr, dpr);
33
+
34
+ // Create audio context and analyser
35
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
36
+ audioContextRef.current = audioContext;
37
+
38
+ const analyser = audioContext.createAnalyser();
39
+ analyser.fftSize = 256;
40
+ analyser.smoothingTimeConstant = 0.8;
41
+ analyserRef.current = analyser;
42
+
43
+ // Connect microphone stream to analyser
44
+ const source = audioContext.createMediaStreamSource(stream);
45
+ source.connect(analyser);
46
+
47
+ const bufferLength = analyser.frequencyBinCount;
48
+ const dataArray = new Uint8Array(bufferLength);
49
+
50
+ // Animation variables for organic movement
51
+ let phase = 0;
52
+ const baseAmplitude = rect.height * 0.3;
53
+
54
+ const draw = () => {
55
+ if (!isActive) return;
56
+
57
+ animationRef.current = requestAnimationFrame(draw);
58
+ analyser.getByteFrequencyData(dataArray);
59
+
60
+ // Clear canvas with fade effect for trails
61
+ ctx.fillStyle = "rgba(15, 15, 15, 0.15)";
62
+ ctx.fillRect(0, 0, rect.width, rect.height);
63
+
64
+ // Calculate average volume for responsiveness
65
+ const average = dataArray.reduce((a, b) => a + b, 0) / bufferLength;
66
+ const normalizedVolume = average / 255;
67
+
68
+ // Draw multiple organic waves
69
+ const waves = 3;
70
+ for (let w = 0; w < waves; w++) {
71
+ ctx.beginPath();
72
+
73
+ const waveOffset = (w / waves) * Math.PI * 0.5;
74
+ const opacity = 0.4 + (w / waves) * 0.4;
75
+
76
+ // Gradient color based on wave index
77
+ const hue = 187 + w * 15; // Cyan to teal range
78
+ ctx.strokeStyle = `hsla(${hue}, 85%, 55%, ${opacity})`;
79
+ ctx.lineWidth = 2 + (waves - w);
80
+ ctx.lineCap = "round";
81
+ ctx.lineJoin = "round";
82
+
83
+ const centerY = rect.height / 2;
84
+ const points = 50;
85
+
86
+ for (let i = 0; i <= points; i++) {
87
+ const x = (i / points) * rect.width;
88
+
89
+ // Sample frequency data
90
+ const dataIndex = Math.floor((i / points) * bufferLength);
91
+ const frequency = dataArray[dataIndex] / 255;
92
+
93
+ // Combine multiple sine waves for organic effect
94
+ const wave1 = Math.sin((i / points) * Math.PI * 4 + phase + waveOffset) * 0.6;
95
+ const wave2 = Math.sin((i / points) * Math.PI * 2 + phase * 0.7) * 0.3;
96
+ const wave3 = Math.sin((i / points) * Math.PI * 6 + phase * 1.3) * 0.1;
97
+
98
+ const combinedWave = wave1 + wave2 + wave3;
99
+ const amplitude = baseAmplitude * (0.2 + normalizedVolume * 0.8 + frequency * 0.5);
100
+
101
+ const y = centerY + combinedWave * amplitude;
102
+
103
+ if (i === 0) {
104
+ ctx.moveTo(x, y);
105
+ } else {
106
+ // Use quadratic curves for smoother lines
107
+ const prevX = ((i - 1) / points) * rect.width;
108
+ const cpX = (prevX + x) / 2;
109
+ ctx.quadraticCurveTo(prevX, ctx.currentY || y, cpX, y);
110
+ }
111
+ ctx.currentY = y;
112
+ }
113
+
114
+ ctx.stroke();
115
+ }
116
+
117
+ // Draw center glow based on volume
118
+ const glowRadius = 30 + normalizedVolume * 40;
119
+ const gradient = ctx.createRadialGradient(
120
+ rect.width / 2, rect.height / 2, 0,
121
+ rect.width / 2, rect.height / 2, glowRadius
122
+ );
123
+ gradient.addColorStop(0, `rgba(34, 211, 238, ${0.3 * normalizedVolume})`);
124
+ gradient.addColorStop(1, "rgba(34, 211, 238, 0)");
125
+
126
+ ctx.fillStyle = gradient;
127
+ ctx.beginPath();
128
+ ctx.arc(rect.width / 2, rect.height / 2, glowRadius, 0, Math.PI * 2);
129
+ ctx.fill();
130
+
131
+ // Update phase for animation
132
+ phase += 0.05 + normalizedVolume * 0.1;
133
+ };
134
+
135
+ draw();
136
+
137
+ return () => {
138
+ if (animationRef.current) {
139
+ cancelAnimationFrame(animationRef.current);
140
+ }
141
+ if (audioContextRef.current && audioContextRef.current.state !== "closed") {
142
+ audioContextRef.current.close();
143
+ }
144
+ };
145
+ }, [stream, isActive]);
146
+
147
+ return (
148
+ <canvas
149
+ ref={canvasRef}
150
+ className="w-full h-12 rounded-lg"
151
+ style={{
152
+ background: "linear-gradient(180deg, rgba(15,15,15,0.9) 0%, rgba(20,20,20,0.95) 100%)"
153
+ }}
154
+ />
155
+ );
156
+ }
client/src/components/ChatArea.jsx ADDED
@@ -0,0 +1,1172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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";
5
+ import rehypeKatex from "rehype-katex";
6
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
7
+ import { motion, AnimatePresence } from "framer-motion";
8
+ import "katex/dist/katex.min.css";
9
+ import {
10
+ SendHorizontal,
11
+ Sparkles,
12
+ Globe,
13
+ ArrowRight,
14
+ ArrowUp,
15
+ Plus,
16
+ Menu,
17
+ Copy,
18
+ RefreshCw,
19
+ Paperclip,
20
+ Mic,
21
+ X,
22
+ Check,
23
+ ChevronDown
24
+ } from "lucide-react";
25
+ import { clsx } from "clsx";
26
+ import { twMerge } from "tailwind-merge";
27
+ import ErrorBoundary from "./ErrorBoundary.jsx";
28
+ import AudioVisualizer from "./AudioVisualizer.jsx";
29
+
30
+ function cn(...inputs) {
31
+ return twMerge(clsx(inputs));
32
+ }
33
+
34
+ const allowedLinkProtocols = new Set(["http:", "https:", "mailto:", "tel:"]);
35
+
36
+ function sanitizeLinkUrl(url) {
37
+ if (typeof url !== "string" || !url) return "";
38
+ const trimmed = url.trim();
39
+ if (trimmed.startsWith("#")) return trimmed;
40
+ try {
41
+ const parsed = new URL(trimmed, "https://local.invalid");
42
+ if (allowedLinkProtocols.has(parsed.protocol)) return trimmed;
43
+ return "";
44
+ } catch (error) {
45
+ return "";
46
+ }
47
+ }
48
+
49
+ const markdownSchema = {
50
+ ...defaultSchema,
51
+ attributes: {
52
+ ...defaultSchema.attributes,
53
+ a: [
54
+ ...(defaultSchema.attributes?.a || []),
55
+ ["target", "_blank"],
56
+ ["rel", "noopener noreferrer"]
57
+ ],
58
+ code: [...(defaultSchema.attributes?.code || []), "className"],
59
+ // Allow KaTeX elements and attributes
60
+ span: [
61
+ ...(defaultSchema.attributes?.span || []),
62
+ "className",
63
+ "style",
64
+ "aria-hidden"
65
+ ],
66
+ annotation: ["encoding"],
67
+ semantics: []
68
+ },
69
+ tagNames: [
70
+ ...(defaultSchema.tagNames || []),
71
+ "math",
72
+ "annotation",
73
+ "semantics",
74
+ "mtext",
75
+ "mn",
76
+ "mo",
77
+ "mi",
78
+ "mspace",
79
+ "mover",
80
+ "munder",
81
+ "munderover",
82
+ "msup",
83
+ "msub",
84
+ "msubsup",
85
+ "mfrac",
86
+ "mroot",
87
+ "msqrt",
88
+ "mtable",
89
+ "mtr",
90
+ "mtd",
91
+ "mlabeledtr",
92
+ "mrow",
93
+ "menclose",
94
+ "mstyle",
95
+ "mpadded",
96
+ "mphantom"
97
+ ]
98
+ };
99
+
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">
111
+ <ReactMarkdown
112
+ remarkPlugins={[remarkGfm, remarkMath]}
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>
275
+ </div>
276
+ );
277
+ }
278
+
279
+ function extractSources(text) {
280
+ const urls = Array.from(
281
+ new Set(
282
+ (text.match(/https?:\/\/[^\s)]+/g) || []).map((item) =>
283
+ item.replace(/[.,)\]]$/, "")
284
+ )
285
+ )
286
+ );
287
+ return urls;
288
+ }
289
+
290
+ // Memoized Message Row Component
291
+ const MessageRow = React.memo(({
292
+ msg,
293
+ index,
294
+ isLastAssistant,
295
+ isStreaming,
296
+ showSearching,
297
+ activityLabels,
298
+ onMessageChange,
299
+ onSend,
300
+ activeSession,
301
+ onCopy
302
+ }) => {
303
+ const sources = msg.content ? extractSources(msg.content) : [];
304
+ const hasActivities = Array.isArray(msg.activities) && msg.activities.length > 0;
305
+ const [copied, setCopied] = React.useState(false);
306
+
307
+ // Only show phase animation for the LAST assistant message AND when streaming
308
+ let phase = null;
309
+ let activeToolName = null;
310
+ const toolActivities = hasActivities
311
+ ? msg.activities.filter(a => a.startsWith("tool:")).map(a => a.slice(5))
312
+ : [];
313
+
314
+ if (isStreaming && isLastAssistant && msg.role === "assistant") {
315
+ if (toolActivities.length > 0) {
316
+ phase = "tool";
317
+ activeToolName = toolActivities[toolActivities.length - 1];
318
+ } else if (!hasActivities) {
319
+ phase = showSearching ? "searching" : "thinking";
320
+ } else if (msg.activities.includes("reasoning")) {
321
+ phase = "reasoning";
322
+ } else if (msg.activities.includes("searching")) {
323
+ phase = "searching";
324
+ } else if (msg.activities.includes("reading")) {
325
+ phase = "reading";
326
+ } else if (msg.activities.includes("planning")) {
327
+ phase = "planning";
328
+ } else if (msg.activities.includes("executing")) {
329
+ phase = "executing";
330
+ } else if (msg.activities.includes("writing")) {
331
+ phase = "writing";
332
+ } else {
333
+ phase = "thinking";
334
+ }
335
+ }
336
+
337
+ const handleCopy = () => {
338
+ navigator.clipboard?.writeText(msg.content);
339
+ setCopied(true);
340
+ setTimeout(() => setCopied(false), 2000);
341
+ };
342
+
343
+ return (
344
+ <motion.div
345
+ initial={{ opacity: 0, y: 6 }}
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
+ >
353
+ <div className={cn(
354
+ "flex flex-col gap-2.5 min-w-0",
355
+ msg.role === "user"
356
+ ? "items-end max-w-[85%] md:max-w-[75%]"
357
+ : "items-start max-w-full"
358
+ )}>
359
+ <div className="font-semibold text-[13px] text-white/90 mb-0.5 px-0.5">
360
+ {msg.role === "user" ? "You" : "Vector"}
361
+ </div>
362
+
363
+ {/* Activity panel — shows while streaming OR persists after streaming for completed steps */}
364
+ {msg.role === "assistant" && (msg.agentSteps?.length > 0 || phase) && (
365
+ <ActivityPanel
366
+ steps={msg.agentSteps || []}
367
+ phase={phase}
368
+ toolName={activeToolName}
369
+ isStreaming={isStreaming && isLastAssistant}
370
+ />
371
+ )}
372
+
373
+ <div className={cn("text-[15px] leading-[1.6] w-full", msg.role === "user" && "text-right")}>
374
+ {msg.role === "assistant" ? (
375
+ <div className="text-white/95">
376
+ <MarkdownContent content={msg.content} />
377
+ </div>
378
+ ) : (
379
+ <div className="bg-[#2F2F2F] rounded-2xl px-4 py-3 text-white inline-block max-w-full break-words">
380
+ {msg.content}
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
+
388
+ {msg.role === "assistant" && msg.content && !isStreaming && (
389
+ <div className="mt-2 w-full">
390
+ {sources.length > 0 && (
391
+ <div className="flex gap-2 overflow-x-auto pb-2 mb-3 custom-scrollbar">
392
+ {sources.map((item, idx) => (
393
+ <a
394
+ key={idx}
395
+ href={item}
396
+ target="_blank"
397
+ rel="noopener noreferrer"
398
+ className="flex-shrink-0 flex items-center gap-2 px-3 py-2 rounded-lg bg-[#2A2A2A] border border-border/40 text-xs text-muted-foreground hover:text-white hover:border-border/60 transition-all"
399
+ >
400
+ <Globe size={13} />
401
+ <span className="truncate max-w-[120px]">{new URL(item).hostname.replace('www.', '')}</span>
402
+ </a>
403
+ ))}
404
+ </div>
405
+ )}
406
+
407
+ <div className="flex items-center gap-1.5">
408
+ <button
409
+ onClick={handleCopy}
410
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5 transition-all"
411
+ aria-label={copied ? "Copied" : "Copy"}
412
+ >
413
+ {copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
414
+ <span className="hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
415
+ </button>
416
+ <button
417
+ onClick={() => {
418
+ const lastUserMsg = [...(activeSession?.messages || [])]
419
+ .slice(0, index)
420
+ .reverse()
421
+ .find(m => m.role === "user");
422
+ if (lastUserMsg) {
423
+ onMessageChange(lastUserMsg.content);
424
+ setTimeout(() => onSend(), 50);
425
+ }
426
+ }}
427
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5 transition-all"
428
+ aria-label="Retry"
429
+ >
430
+ <RefreshCw size={14} />
431
+ <span className="hidden sm:inline">Retry</span>
432
+ </button>
433
+ </div>
434
+ </div>
435
+ )}
436
+ </div>
437
+ </motion.div>
438
+ );
439
+ });
440
+
441
+ export default function ChatArea({
442
+ activeSession,
443
+ isStreaming,
444
+ message,
445
+ onMessageChange,
446
+ onSend,
447
+ onSelectFollowUp,
448
+ activityLabels,
449
+ toggleSidebar,
450
+ sidebarOpen,
451
+ features = { uploads: false, stt: false }
452
+ }) {
453
+ const scrollRef = useRef(null);
454
+ const messagesEndRef = useRef(null);
455
+ const userScrolledRef = useRef(false);
456
+ const lastScrollTopRef = useRef(0);
457
+
458
+ useEffect(() => {
459
+ const el = scrollRef.current;
460
+ if (!el) return;
461
+
462
+ let timeoutId = null;
463
+ const onScroll = () => {
464
+ el.classList.add("scrolling");
465
+ if (timeoutId) clearTimeout(timeoutId);
466
+ timeoutId = setTimeout(() => el.classList.remove("scrolling"), 150);
467
+
468
+ // Detect if user manually scrolled up
469
+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
470
+ const scrolledUp = el.scrollTop < lastScrollTopRef.current;
471
+
472
+ if (scrolledUp && !isAtBottom) {
473
+ userScrolledRef.current = true;
474
+ } else if (isAtBottom) {
475
+ userScrolledRef.current = false;
476
+ }
477
+
478
+ lastScrollTopRef.current = el.scrollTop;
479
+ };
480
+
481
+ el.addEventListener("scroll", onScroll, { passive: true });
482
+ return () => {
483
+ el.removeEventListener("scroll", onScroll);
484
+ if (timeoutId) clearTimeout(timeoutId);
485
+ };
486
+ }, []);
487
+
488
+ // Track streaming time for "Searching..." status
489
+ const [showSearching, setShowSearching] = React.useState(false);
490
+ const statusTimerRef = React.useRef(null);
491
+
492
+ useEffect(() => {
493
+ if (isStreaming) {
494
+ setShowSearching(false);
495
+ if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
496
+ statusTimerRef.current = setTimeout(() => setShowSearching(true), 2000);
497
+ return () => {
498
+ if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
499
+ };
500
+ } else {
501
+ setShowSearching(false);
502
+ }
503
+ }, [isStreaming]);
504
+
505
+ // Smart auto-scroll: only scroll if user hasn't manually scrolled up
506
+ useEffect(() => {
507
+ const el = scrollRef.current;
508
+ if (!el) return;
509
+
510
+ // Don't auto-scroll if user has scrolled up
511
+ if (userScrolledRef.current) return;
512
+
513
+ // Instant scroll to bottom during streaming to prevent jarring motion
514
+ // Use smooth scroll only when not streaming
515
+ const scrollToBottom = () => {
516
+ el.scrollTo({
517
+ top: el.scrollHeight,
518
+ behavior: isStreaming ? 'instant' : 'smooth'
519
+ });
520
+ };
521
+
522
+ // Use requestAnimationFrame for smoother scrolling during streaming
523
+ const rafId = requestAnimationFrame(scrollToBottom);
524
+
525
+ return () => cancelAnimationFrame(rafId);
526
+ }, [activeSession?.messages, isStreaming]);
527
+
528
+ // Reset user scroll flag when new message starts
529
+ useEffect(() => {
530
+ if (isStreaming) {
531
+ userScrolledRef.current = false;
532
+ }
533
+ }, [isStreaming]);
534
+
535
+ const isEmpty = !activeSession?.messages || activeSession.messages.length === 0;
536
+
537
+ return (
538
+ <div className="flex-1 flex flex-col h-full relative bg-card">
539
+ {/* Mobile Header */}
540
+ <div className="md:hidden flex items-center p-3 border-b border-border/30 bg-card sticky top-0 z-10">
541
+ <button
542
+ onClick={toggleSidebar}
543
+ className="p-2 -ml-2 text-muted-foreground hover:text-white focus-visible:outline-none rounded-lg"
544
+ aria-label="Open sidebar"
545
+ >
546
+ <Menu size={20} />
547
+ </button>
548
+ <span className="font-semibold text-sm ml-2 text-white">
549
+ Vector
550
+ </span>
551
+ </div>
552
+
553
+ {/* Main Content Area */}
554
+ <div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar bg-card" ref={scrollRef}>
555
+ {isEmpty ? (
556
+ /* Empty State / Hero Section */
557
+ <div className="flex flex-col items-center justify-center min-h-[80vh] px-4">
558
+ <div className="w-14 h-14 md:w-16 md:h-16 rounded-2xl bg-foreground/5 border border-border flex items-center justify-center mb-6">
559
+ <Sparkles className="text-muted-foreground w-7 h-7 md:w-8 md:h-8" />
560
+ </div>
561
+ <h1 className="text-2xl md:text-4xl font-display font-medium text-center mb-8 md:mb-12 text-white">
562
+ Where knowledge begins
563
+ </h1>
564
+
565
+ <div className="w-full max-w-xl px-4">
566
+ <SearchInput
567
+ value={message}
568
+ onChange={onMessageChange}
569
+ onSend={onSend}
570
+ disabled={isStreaming}
571
+ isHero={true}
572
+ features={features}
573
+ />
574
+
575
+ <div className="mt-6 md:mt-8 flex flex-wrap justify-center gap-2">
576
+ {[
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>
591
+ ))}
592
+ </div>
593
+ </div>
594
+ </div>
595
+ ) : (
596
+ /* Chat Messages */
597
+ <div className="mx-auto max-w-3xl w-full px-3 md:px-6 py-8 md:py-12 space-y-8 md:space-y-10">
598
+ {activeSession?.messages.map((msg, index) => {
599
+ const isLastAssistant =
600
+ msg.role === "assistant" &&
601
+ index === (activeSession?.messages?.length || 0) - 1;
602
+
603
+ return (
604
+ <ErrorBoundary key={msg.id || index} minimal>
605
+ <MessageRow
606
+ msg={msg}
607
+ index={index}
608
+ isLastAssistant={isLastAssistant}
609
+ isStreaming={isStreaming}
610
+ showSearching={showSearching}
611
+ activityLabels={activityLabels}
612
+ activeSession={activeSession}
613
+ onMessageChange={onMessageChange}
614
+ onSend={onSend}
615
+ />
616
+ </ErrorBoundary>
617
+ );
618
+ })}
619
+
620
+ <div ref={messagesEndRef} className="h-4" />
621
+ </div>
622
+ )}
623
+ </div>
624
+
625
+ {/* Footer Input Area */}
626
+ {!isEmpty && (
627
+ <div
628
+ className="p-3 md:p-4 bg-card/80 backdrop-blur-sm z-20"
629
+ style={{
630
+ paddingBottom: 'max(12px, env(safe-area-inset-bottom, 12px))'
631
+ }}
632
+ >
633
+ <div className="mx-auto max-w-xl">
634
+ <SearchInput
635
+ value={message}
636
+ onChange={onMessageChange}
637
+ onSend={onSend}
638
+ disabled={isStreaming}
639
+ features={features}
640
+ />
641
+ </div>
642
+ </div>
643
+ )}
644
+ </div>
645
+ );
646
+ }
647
+
648
+ function SearchInput({ value, onChange, onSend, disabled, isHero = false, features = {} }) {
649
+ const fileInputRef = React.useRef(null);
650
+ const textareaRef = React.useRef(null);
651
+ const textareaScrollTimeoutRef = React.useRef(null);
652
+ const [isRecording, setIsRecording] = React.useState(false);
653
+ const [selectedFiles, setSelectedFiles] = React.useState([]);
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
+
660
+ // Check for Speech Recognition support
661
+ const SpeechRecognition = typeof window !== 'undefined'
662
+ ? (window.SpeechRecognition || window.webkitSpeechRecognition)
663
+ : null;
664
+
665
+ useLayoutEffect(() => {
666
+ const el = textareaRef.current;
667
+ if (!el) return;
668
+
669
+ el.style.height = "0px";
670
+ const unclamped = el.scrollHeight;
671
+ const next = Math.max(minTextareaHeight, Math.min(unclamped, maxTextareaHeight));
672
+ el.style.height = `${next}px`;
673
+ el.style.overflowY = unclamped > maxTextareaHeight ? "auto" : "hidden";
674
+ }, [value, isHero, maxTextareaHeight, minTextareaHeight]);
675
+
676
+ const handleSend = () => {
677
+ if ((value.trim() || selectedFiles.length > 0) && !disabled) {
678
+ onSend(selectedFiles);
679
+ setSelectedFiles([]);
680
+ if (fileInputRef.current) fileInputRef.current.value = '';
681
+ }
682
+ };
683
+
684
+ const handleKeyDown = (e) => {
685
+ if (e.key === 'Enter' && !e.shiftKey) {
686
+ e.preventDefault();
687
+ handleSend();
688
+ }
689
+ };
690
+
691
+ const handleTextareaScroll = (e) => {
692
+ const el = e.currentTarget;
693
+ el.classList.add("scrolling");
694
+ if (textareaScrollTimeoutRef.current) clearTimeout(textareaScrollTimeoutRef.current);
695
+ textareaScrollTimeoutRef.current = setTimeout(() => {
696
+ el.classList.remove("scrolling");
697
+ }, 150);
698
+ };
699
+
700
+ const handleFileSelect = (e) => {
701
+ const files = Array.from(e.target.files || []);
702
+ if (files.length > 0) {
703
+ setSelectedFiles(prev => [...prev, ...files]);
704
+ }
705
+ if (fileInputRef.current) {
706
+ fileInputRef.current.value = '';
707
+ }
708
+ };
709
+
710
+ const removeFile = (index) => {
711
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index));
712
+ };
713
+
714
+ const handleMicClick = async () => {
715
+ if (isRecording) {
716
+ // Stop recording
717
+ if (recognitionRef.current) {
718
+ recognitionRef.current.stop();
719
+ }
720
+ if (audioStream) {
721
+ audioStream.getTracks().forEach(track => track.stop());
722
+ setAudioStream(null);
723
+ }
724
+ setIsRecording(false);
725
+ setInterimTranscript("");
726
+ } else {
727
+ // Check browser support
728
+ if (!SpeechRecognition) {
729
+ console.error('Speech Recognition not supported in this browser');
730
+ alert('Speech-to-text is not supported in this browser. Please use Chrome, Edge, or Safari.');
731
+ return;
732
+ }
733
+
734
+ try {
735
+ // Get audio stream for visualizer
736
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
737
+ setAudioStream(stream);
738
+
739
+ // Set up Speech Recognition
740
+ const recognition = new SpeechRecognition();
741
+ recognitionRef.current = recognition;
742
+
743
+ recognition.continuous = true;
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
+ }
759
+ }
760
+ setInterimTranscript(interim);
761
+ };
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);
768
+ }
769
+ setIsRecording(false);
770
+ setInterimTranscript("");
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);
788
+ }
789
+ }
790
+ };
791
+
792
+ // Cleanup on unmount
793
+ React.useEffect(() => {
794
+ return () => {
795
+ if (recognitionRef.current) {
796
+ try { recognitionRef.current.stop(); } catch (e) { }
797
+ }
798
+ if (audioStream) {
799
+ audioStream.getTracks().forEach(track => track.stop());
800
+ }
801
+ };
802
+ }, [audioStream]);
803
+
804
+
805
+ return (
806
+ <div className="flex flex-col w-full">
807
+ {/* File attachments preview */}
808
+ {selectedFiles.length > 0 && (
809
+ <div className="flex flex-wrap gap-2 mb-2">
810
+ {selectedFiles.map((file, index) => (
811
+ <div
812
+ key={index}
813
+ className="flex items-center gap-2 bg-[#3A3A3A] rounded-full px-3 py-1 text-xs text-white/80"
814
+ >
815
+ <span className="truncate max-w-[100px] md:max-w-[150px]">{file.name}</span>
816
+ <button
817
+ onClick={() => removeFile(index)}
818
+ className="p-0.5 hover:bg-white/10 rounded-full"
819
+ aria-label={`Remove ${file.name}`}
820
+ >
821
+ <X size={12} />
822
+ </button>
823
+ </div>
824
+ ))}
825
+ </div>
826
+ )}
827
+
828
+ {/* Main floating input bar */}
829
+ <div className="floating-input-bar">
830
+ {/* Plus/Attach button */}
831
+ <input
832
+ type="file"
833
+ ref={fileInputRef}
834
+ onChange={handleFileSelect}
835
+ className="hidden"
836
+ multiple
837
+ accept="image/*,.pdf,.doc,.docx,.txt,.csv,.json"
838
+ />
839
+ <button
840
+ onClick={() => fileInputRef.current?.click()}
841
+ disabled={disabled}
842
+ className={cn(
843
+ "w-9 h-9 rounded-full flex items-center justify-center transition-colors shrink-0",
844
+ disabled
845
+ ? "text-muted-foreground/40 cursor-not-allowed"
846
+ : "text-muted-foreground hover:text-white hover:bg-white/5"
847
+ )}
848
+ aria-label="Attach"
849
+ title="Attach file"
850
+ >
851
+ <Plus size={20} strokeWidth={1.5} />
852
+ </button>
853
+
854
+ {/* Audio Visualizer or Text input */}
855
+ {isRecording && audioStream ? (
856
+ <div className="flex-1 flex flex-col items-center justify-center px-2 min-h-[44px]">
857
+ <AudioVisualizer stream={audioStream} isActive={isRecording} />
858
+ {/* Show interim transcript as user speaks */}
859
+ {interimTranscript && (
860
+ <div className="text-xs text-muted-foreground/70 italic mt-1 truncate max-w-full">
861
+ {interimTranscript}...
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 && (
870
+ <div className="text-xs text-muted-foreground/50 mt-1">
871
+ Listening... speak now
872
+ </div>
873
+ )}
874
+ {/* Cancel and Done buttons */}
875
+ <div className="flex items-center gap-3 mt-2">
876
+ <button
877
+ onClick={() => {
878
+ // Cancel: stop recording and clear text
879
+ if (recognitionRef.current) {
880
+ recognitionRef.current.stop();
881
+ recognitionRef.current = null;
882
+ }
883
+ if (audioStream) {
884
+ audioStream.getTracks().forEach(track => track.stop());
885
+ setAudioStream(null);
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"
892
+ aria-label="Cancel recording"
893
+ >
894
+ <X size={14} />
895
+ <span>Cancel</span>
896
+ </button>
897
+ <button
898
+ onClick={() => {
899
+ // Done: stop recording but keep the text
900
+ if (recognitionRef.current) {
901
+ recognitionRef.current.stop();
902
+ recognitionRef.current = null;
903
+ }
904
+ if (audioStream) {
905
+ audioStream.getTracks().forEach(track => track.stop());
906
+ setAudioStream(null);
907
+ }
908
+ setIsRecording(false);
909
+ setInterimTranscript("");
910
+ // Focus textarea after a brief delay
911
+ setTimeout(() => textareaRef.current?.focus(), 100);
912
+ }}
913
+ className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium text-green-400 bg-green-500/10 hover:bg-green-500/20 transition-colors"
914
+ aria-label="Done recording"
915
+ >
916
+ <Check size={14} />
917
+ <span>Done</span>
918
+ </button>
919
+ </div>
920
+ </div>
921
+ ) : (
922
+ <textarea
923
+ ref={textareaRef}
924
+ value={value}
925
+ onChange={(e) => onChange(e.target.value)}
926
+ onKeyDown={handleKeyDown}
927
+ onScroll={handleTextareaScroll}
928
+ placeholder="Ask anything"
929
+ aria-label="Ask anything"
930
+ inputMode="text"
931
+ autoComplete="off"
932
+ autoCorrect="off"
933
+ className="flex-1 bg-transparent border-none focus:ring-0 focus:outline-none text-white resize-none custom-scrollbar py-2.5 px-1 text-[15px] leading-6"
934
+ rows={1}
935
+ style={{ minHeight: `${minTextareaHeight}px`, maxHeight: `${maxTextareaHeight}px` }}
936
+ />
937
+ )}
938
+
939
+ {/* Right side buttons */}
940
+ <div className="flex items-center gap-0.5 shrink-0">
941
+ {/* Mic button */}
942
+ <button
943
+ onClick={handleMicClick}
944
+ disabled={disabled}
945
+ className={cn(
946
+ "w-9 h-9 rounded-full flex items-center justify-center transition-colors",
947
+ isRecording
948
+ ? "text-red-500 bg-red-500/10 animate-pulse"
949
+ : disabled
950
+ ? "text-muted-foreground/40 cursor-not-allowed"
951
+ : "text-muted-foreground hover:text-white hover:bg-white/5"
952
+ )}
953
+ aria-label={isRecording ? "Stop recording" : "Voice input"}
954
+ title={isRecording ? "Stop recording" : "Voice input"}
955
+ >
956
+ <Mic size={18} />
957
+ </button>
958
+
959
+ {/* Send button */}
960
+ <button
961
+ onClick={handleSend}
962
+ disabled={(!value.trim() && selectedFiles.length === 0) || disabled}
963
+ className={cn(
964
+ "w-9 h-9 rounded-full flex items-center justify-center transition-colors",
965
+ (value.trim() || selectedFiles.length > 0) && !disabled
966
+ ? "bg-[#4A4A4A] text-white hover:bg-[#5A5A5A]"
967
+ : "bg-[#3A3A3A] text-muted-foreground/40 cursor-not-allowed"
968
+ )}
969
+ aria-label="Send"
970
+ title="Send message"
971
+ >
972
+ {disabled ? (
973
+ <div className="w-4 h-4 border-2 border-muted-foreground/20 border-t-muted-foreground/60 rounded-full animate-spin" />
974
+ ) : (
975
+ <ArrowUp size={18} strokeWidth={2} />
976
+ )}
977
+ </button>
978
+ </div>
979
+ </div>
980
+ </div>
981
+ );
982
+ }
983
+
984
+
985
+ function ActionBtn({ icon, label, onClick }) {
986
+ return (
987
+ <button
988
+ onClick={onClick}
989
+ className="flex items-center gap-1.5 px-2 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-white hover:bg-foreground/5 transition-colors focus-visible:outline-none"
990
+ aria-label={label}
991
+ >
992
+ {icon}
993
+ <span className="hidden sm:inline">{label}</span>
994
+ </button>
995
+ )
996
+ }
997
+
998
+ /* ── Collapsible Activity Panel (Gemini/Perplexity-style) ── */
999
+ function ActivityPanel({ steps, phase, toolName, isStreaming }) {
1000
+ const [expanded, setExpanded] = React.useState(false);
1001
+
1002
+ const searchCount = steps.filter(s => s.type === "search").length;
1003
+ const sourceCount = steps.filter(s => s.type === "sources").reduce((n, s) => n + (s.items?.length || 0), 0);
1004
+ const browseCount = steps.filter(s => s.type === "browse").length;
1005
+
1006
+ // Auto-expand when first step arrives during streaming
1007
+ React.useEffect(() => {
1008
+ if (steps.length === 1 && isStreaming) setExpanded(true);
1009
+ }, [steps.length, isStreaming]);
1010
+
1011
+ // Build summary text
1012
+ const summaryParts = [];
1013
+ if (searchCount > 0) summaryParts.push(`Searched ${searchCount} ${searchCount === 1 ? "query" : "queries"}`);
1014
+ if (sourceCount > 0) summaryParts.push(`Found ${sourceCount} ${sourceCount === 1 ? "source" : "sources"}`);
1015
+ if (browseCount > 0) summaryParts.push(`Read ${browseCount} ${browseCount === 1 ? "page" : "pages"}`);
1016
+ const summaryText = summaryParts.join(" · ") || "Working…";
1017
+
1018
+ return (
1019
+ <div className="activity-panel mb-2 w-full max-w-[540px]">
1020
+ {/* Header / toggle */}
1021
+ <button
1022
+ onClick={() => setExpanded(e => !e)}
1023
+ className="activity-panel-header"
1024
+ aria-expanded={expanded}
1025
+ >
1026
+ <span className="flex items-center gap-2 min-w-0">
1027
+ {isStreaming && phase ? (
1028
+ <span className="flex items-center gap-1">
1029
+ <span className="thinking-dot w-1 h-1 rounded-full bg-muted-foreground/80" style={{ animationDelay: "0ms" }} />
1030
+ <span className="thinking-dot w-1 h-1 rounded-full bg-muted-foreground/80" style={{ animationDelay: "140ms" }} />
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>
1038
+ <ChevronDown
1039
+ size={14}
1040
+ className={cn(
1041
+ "shrink-0 transition-transform duration-200",
1042
+ expanded ? "rotate-180" : ""
1043
+ )}
1044
+ />
1045
+ </button>
1046
+
1047
+ {/* Expanded step list */}
1048
+ <AnimatePresence initial={false}>
1049
+ {expanded && (
1050
+ <motion.div
1051
+ initial={{ height: 0, opacity: 0 }}
1052
+ animate={{ height: "auto", opacity: 1 }}
1053
+ exit={{ height: 0, opacity: 0 }}
1054
+ transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
1055
+ className="overflow-hidden"
1056
+ >
1057
+ <div className="activity-panel-body">
1058
+ {steps.map((step, i) => (
1059
+ <ActivityStepRow key={i} step={step} />
1060
+ ))}
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…"}
1068
+ </span>
1069
+ </div>
1070
+ )}
1071
+ </div>
1072
+ </motion.div>
1073
+ )}
1074
+ </AnimatePresence>
1075
+ </div>
1076
+ );
1077
+ }
1078
+
1079
+ 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
+ );
1087
+ }
1088
+ if (step.type === "browse") {
1089
+ let displayUrl = step.url;
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
+ );
1097
+ }
1098
+ if (step.type === "sources") {
1099
+ return (
1100
+ <div className="activity-step-sources">
1101
+ {step.items?.map((src, j) => {
1102
+ let domain = src.url;
1103
+ try { domain = new URL(src.url).hostname.replace("www.", ""); } catch { }
1104
+ return (
1105
+ <a
1106
+ key={j}
1107
+ href={src.url}
1108
+ target="_blank"
1109
+ rel="noopener noreferrer"
1110
+ className="activity-source-chip"
1111
+ title={src.title}
1112
+ >
1113
+ <img
1114
+ src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
1115
+ alt=""
1116
+ className="w-3.5 h-3.5 rounded-sm"
1117
+ onError={(e) => { e.target.style.display = 'none'; }}
1118
+ />
1119
+ <span className="truncate">{src.title || domain}</span>
1120
+ </a>
1121
+ );
1122
+ })}
1123
+ </div>
1124
+ );
1125
+ }
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
+ );
1133
+ }
1134
+ return null;
1135
+ }
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
+
1150
+ return (
1151
+ <div className="inline-flex items-center gap-2 rounded-full border border-border bg-foreground/5 px-3 py-1.5 text-[12px] font-medium text-muted-foreground">
1152
+ <span className="flex items-center gap-1">
1153
+ <span className="thinking-dot w-1 h-1 rounded-full bg-muted-foreground/80" style={{ animationDelay: "0ms" }} />
1154
+ <span className="thinking-dot w-1 h-1 rounded-full bg-muted-foreground/80" style={{ animationDelay: "140ms" }} />
1155
+ <span className="thinking-dot w-1 h-1 rounded-full bg-muted-foreground/80" style={{ animationDelay: "280ms" }} />
1156
+ </span>
1157
+ <AnimatePresence mode="wait" initial={false}>
1158
+ <motion.span
1159
+ key={label}
1160
+ initial={{ opacity: 0, y: 4 }}
1161
+ animate={{ opacity: 1, y: 0 }}
1162
+ exit={{ opacity: 0, y: -4 }}
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>
1170
+ </div>
1171
+ );
1172
+ }
client/src/components/ChatSidebar.jsx ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import {
4
+ MessageSquare,
5
+ Plus,
6
+ Trash2,
7
+ Moon,
8
+ Sun,
9
+ Bot,
10
+ History,
11
+ FlaskConical
12
+ } from "lucide-react";
13
+ import { clsx } from "clsx";
14
+ import { twMerge } from "tailwind-merge";
15
+
16
+ function cn(...inputs) {
17
+ return twMerge(clsx(inputs));
18
+ }
19
+
20
+ export default function ChatSidebar({
21
+ open,
22
+ setOpen,
23
+ models,
24
+ selectedModelId,
25
+ onSelectModel,
26
+ isModelsLoading,
27
+ modelsError,
28
+ modelsIssues,
29
+ onReloadModels,
30
+ mode,
31
+ modes,
32
+ onModeChange,
33
+ onNewChat,
34
+ onClearHistory,
35
+ historyList,
36
+ activeSessionId,
37
+ onSelectSession,
38
+ isSessionLocked = false,
39
+ theme,
40
+ onToggleTheme,
41
+ activeView = "chat",
42
+ onNavigateToLabs,
43
+ onNavigateToChat,
44
+ }) {
45
+ const selectedModel = models?.find((item) => item.id === selectedModelId);
46
+ const uploadsStatus = selectedModel?.features?.status || "unknown";
47
+ const uploadsEnabled = Boolean(selectedModel?.features?.uploads);
48
+ const historyScrollRef = React.useRef(null);
49
+
50
+ React.useEffect(() => {
51
+ const el = historyScrollRef.current;
52
+ if (!el) return;
53
+
54
+ let timeoutId = null;
55
+ const onScroll = () => {
56
+ el.classList.add("scrolling");
57
+ if (timeoutId) clearTimeout(timeoutId);
58
+ timeoutId = setTimeout(() => el.classList.remove("scrolling"), 150);
59
+ };
60
+
61
+ el.addEventListener("scroll", onScroll, { passive: true });
62
+ return () => {
63
+ el.removeEventListener("scroll", onScroll);
64
+ if (timeoutId) clearTimeout(timeoutId);
65
+ };
66
+ }, []);
67
+
68
+ return (
69
+ <AnimatePresence mode="wait">
70
+ {open && (
71
+ <>
72
+ <motion.button
73
+ type="button"
74
+ initial={{ opacity: 0 }}
75
+ animate={{ opacity: 1 }}
76
+ exit={{ opacity: 0 }}
77
+ transition={{ duration: 0.2 }}
78
+ className="fixed inset-0 z-40 bg-[rgba(15,17,23,0.75)] md:hidden"
79
+ onClick={() => setOpen?.(false)}
80
+ aria-label="Close sidebar overlay"
81
+ />
82
+ <motion.aside
83
+ initial={{ x: -300, opacity: 0 }}
84
+ animate={{ x: 0, opacity: 1 }}
85
+ exit={{ x: -300, opacity: 0 }}
86
+ transition={{ duration: 0.3, ease: "easeInOut" }}
87
+ className="fixed left-0 top-0 bottom-0 z-50 w-[280px] border-r border-[rgba(255,255,255,0.04)] bg-popover flex flex-col p-4"
88
+ >
89
+ {/* Header & Logo */}
90
+ <div className="flex items-center justify-between mb-6 px-2">
91
+ <div className="flex items-center gap-2">
92
+ <div className="w-8 h-8 rounded-xl bg-foreground/5 border border-border flex items-center justify-center text-foreground">
93
+ <Bot size={18} />
94
+ </div>
95
+ <h1 className="text-xl font-display font-bold text-foreground">
96
+ Vector
97
+ </h1>
98
+ </div>
99
+ <button
100
+ onClick={() => setOpen?.(false)}
101
+ className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
102
+ aria-label="Close sidebar"
103
+ >
104
+ <span className="text-lg leading-none">×</span>
105
+ </button>
106
+ </div>
107
+
108
+ {/* View Navigation */}
109
+ <div className="space-y-2 mb-6">
110
+ <div className="px-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
111
+ Navigate
112
+ </div>
113
+ <div className="space-y-1">
114
+ <button
115
+ onClick={onNavigateToChat}
116
+ className={cn(
117
+ "flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none",
118
+ activeView === "chat"
119
+ ? "bg-foreground/10 text-foreground"
120
+ : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
121
+ )}
122
+ >
123
+ <MessageSquare size={16} />
124
+ <span>Chat</span>
125
+ </button>
126
+ <button
127
+ onClick={onNavigateToLabs}
128
+ className={cn(
129
+ "flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none",
130
+ activeView === "labs"
131
+ ? "bg-foreground/10 text-foreground"
132
+ : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
133
+ )}
134
+ >
135
+ <FlaskConical size={16} />
136
+ <span>Labs</span>
137
+ <span className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-primary/20 text-primary font-medium">New</span>
138
+ </button>
139
+ </div>
140
+ </div>
141
+
142
+ {/* New Chat Button - only in chat view */}
143
+ {activeView === "chat" && (
144
+ <button
145
+ onClick={onNewChat}
146
+ className="group flex items-center gap-3 w-full px-4 py-3 rounded-xl bg-transparent border border-border transition-[background-color,border-color] duration-[120ms] ease-out mb-6 text-sm font-medium text-foreground hover:bg-foreground/5 hover:border-[var(--input-border-focus)] active:bg-foreground/8 focus-visible:outline-none"
147
+ >
148
+ <div className="bg-foreground/5 p-1.5 rounded-lg text-[#22D3EE] transition-colors group-hover:bg-foreground/8">
149
+ <Plus size={18} />
150
+ </div>
151
+ <span>New Thread</span>
152
+ <div className="ml-auto text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
153
+ Ctrl+N
154
+ </div>
155
+ </button>
156
+ )}
157
+
158
+ {/* Models Selection - only in chat view */}
159
+ {activeView === "chat" && (
160
+ <div className="mb-6 space-y-2">
161
+ <div className="px-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
162
+ <span>{isSessionLocked ? "Model (Locked)" : "Model"}</span>
163
+ <button
164
+ type="button"
165
+ onClick={onReloadModels}
166
+ className="text-[11px] font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 rounded px-1"
167
+ >
168
+ Refresh
169
+ </button>
170
+ </div>
171
+ <div className="relative">
172
+ <select
173
+ className={cn(
174
+ "w-full appearance-none bg-foreground/5 text-foreground text-sm rounded-lg pl-3 pr-8 py-2.5 border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-all hover:bg-foreground/8 disabled:opacity-60",
175
+ isSessionLocked && "cursor-not-allowed opacity-70"
176
+ )}
177
+ value={selectedModelId}
178
+ onChange={(e) => onSelectModel(e.target.value)}
179
+ disabled={isModelsLoading || !models?.length || isSessionLocked}
180
+ title={isSessionLocked ? "Model is locked to this chat session. Start a new chat to change models." : ""}
181
+ >
182
+ {isModelsLoading && (
183
+ <option value="" className="bg-background">
184
+ Loading models…
185
+ </option>
186
+ )}
187
+ {!isModelsLoading && models?.length > 0 && (
188
+ <>
189
+ <option value="" disabled className="bg-background">
190
+ Select a model
191
+ </option>
192
+ {models.map((m) => (
193
+ <option key={m.id} value={m.id} className="bg-background">
194
+ {m.name}
195
+ </option>
196
+ ))}
197
+ </>
198
+ )}
199
+ {!isModelsLoading && !models?.length && (
200
+ <option value="" className="bg-background">
201
+ No models configured
202
+ </option>
203
+ )}
204
+ </select>
205
+ <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
206
+ <svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
207
+ <path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
208
+ </svg>
209
+ </div>
210
+ </div>
211
+ {selectedModel && (
212
+ <div className="px-2 flex items-center gap-2 text-[11px]">
213
+ <span className={cn(
214
+ "inline-flex items-center rounded-full border px-2 py-0.5 font-medium",
215
+ uploadsStatus === "ok"
216
+ ? uploadsEnabled
217
+ ? "border-emerald-500/40 text-emerald-400 bg-emerald-500/10"
218
+ : "border-border text-muted-foreground bg-foreground/5"
219
+ : "border-amber-500/40 text-amber-400 bg-amber-500/10"
220
+ )}>
221
+ {uploadsStatus === "ok"
222
+ ? uploadsEnabled
223
+ ? "Uploads enabled"
224
+ : "Uploads off"
225
+ : "Uploads unknown"}
226
+ </span>
227
+ </div>
228
+ )}
229
+ {(modelsError || (modelsIssues && modelsIssues.length > 0) || (!isModelsLoading && !models?.length)) && (
230
+ <div className="px-2 text-xs text-muted-foreground space-y-1">
231
+ {modelsError && <div className="text-destructive">{modelsError}</div>}
232
+ {!modelsError && !isModelsLoading && !models?.length && (
233
+ <div>
234
+ Add MODEL_1_NAME / MODEL_1_ID / MODEL_1_HOST to your environment.
235
+ </div>
236
+ )}
237
+ {modelsIssues?.slice(0, 3).map((issue) => (
238
+ <div key={issue}>{issue}</div>
239
+ ))}
240
+ </div>
241
+ )}
242
+ </div>
243
+ )}
244
+
245
+ {/* History List - only in chat view */}
246
+ {activeView === "chat" && (
247
+ <div ref={historyScrollRef} className="flex-1 overflow-y-auto -mx-2 px-2 custom-scrollbar">
248
+ <div className="mb-2 px-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
249
+ <History size={12} />
250
+ Recent
251
+ </div>
252
+ <div className="space-y-1">
253
+ {historyList.length === 0 ? (
254
+ <div className="flex flex-col items-center justify-center py-8 text-muted-foreground/70 gap-2">
255
+ <MessageSquare size={24} className="opacity-20" />
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" />
272
+ )}
273
+ <MessageSquare size={16} className={cn(
274
+ "shrink-0 transition-colors",
275
+ activeSessionId === session.id ? "text-foreground" : "text-muted-foreground/70 group-hover:text-muted-foreground"
276
+ )} />
277
+ <span className="truncate flex-1 z-10 relative">{session.title || "New Thread"}</span>
278
+ </button>
279
+ ))
280
+ )}
281
+ </div>
282
+ </div>
283
+ )}
284
+
285
+ {/* Spacer for Labs view */}
286
+ {activeView === "labs" && <div className="flex-1" />}
287
+
288
+ {/* Footer Actions */}
289
+ <div className="pt-4 mt-4 border-t border-[rgba(255,255,255,0.04)] space-y-2">
290
+ {activeView === "chat" && (
291
+ <button
292
+ onClick={onClearHistory}
293
+ className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-all group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
294
+ >
295
+ <Trash2 size={16} />
296
+ <span>Clear History</span>
297
+ </button>
298
+ )}
299
+ <button
300
+ onClick={onToggleTheme}
301
+ className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-foreground/5 hover:text-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
302
+ >
303
+ {theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
304
+ <span>{theme === "dark" ? "Light Mode" : "Dark Mode"}</span>
305
+ </button>
306
+ </div>
307
+ </motion.aside>
308
+ </>
309
+ )}
310
+ </AnimatePresence>
311
+ );
312
+ }
client/src/components/ErrorBoundary.jsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { AlertTriangle, RefreshCw } from "lucide-react";
3
+
4
+ /**
5
+ * ErrorBoundary - Prevents crashes from malformed AI responses or render errors
6
+ * Used to wrap individual message components so one bad message doesn't crash the whole chat
7
+ */
8
+ export default class ErrorBoundary extends React.Component {
9
+ constructor(props) {
10
+ super(props);
11
+ this.state = { hasError: false, error: null };
12
+ }
13
+
14
+ static getDerivedStateFromError(error) {
15
+ return { hasError: true, error };
16
+ }
17
+
18
+ componentDidCatch(error, errorInfo) {
19
+ // Log error for debugging
20
+ console.error("[ErrorBoundary] Caught error:", error, errorInfo);
21
+ }
22
+
23
+ handleRetry = () => {
24
+ this.setState({ hasError: false, error: null });
25
+ };
26
+
27
+ render() {
28
+ if (this.state.hasError) {
29
+ // Minimal fallback UI
30
+ if (this.props.minimal) {
31
+ return (
32
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-sm">
33
+ <AlertTriangle size={14} />
34
+ <span>Failed to display content</span>
35
+ <button
36
+ onClick={this.handleRetry}
37
+ className="ml-auto p-1 hover:bg-destructive/20 rounded"
38
+ title="Retry"
39
+ >
40
+ <RefreshCw size={12} />
41
+ </button>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ // Full fallback UI
47
+ return (
48
+ <div className="flex flex-col items-center justify-center gap-3 p-6 rounded-xl bg-destructive/5 border border-destructive/20">
49
+ <div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
50
+ <AlertTriangle className="text-destructive" size={24} />
51
+ </div>
52
+ <div className="text-center">
53
+ <h3 className="text-sm font-medium text-destructive mb-1">
54
+ Something went wrong
55
+ </h3>
56
+ <p className="text-xs text-muted-foreground max-w-xs">
57
+ {this.props.fallbackMessage || "This content couldn't be displayed. Try refreshing or continue your conversation."}
58
+ </p>
59
+ </div>
60
+ <button
61
+ onClick={this.handleRetry}
62
+ className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-destructive/10 text-destructive text-xs font-medium hover:bg-destructive/20 transition-colors"
63
+ >
64
+ <RefreshCw size={12} />
65
+ Try Again
66
+ </button>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ return this.props.children;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Hook-friendly wrapper for functional components
77
+ */
78
+ export function withErrorBoundary(Component, options = {}) {
79
+ return function WrappedComponent(props) {
80
+ return (
81
+ <ErrorBoundary minimal={options.minimal} fallbackMessage={options.fallbackMessage}>
82
+ <Component {...props} />
83
+ </ErrorBoundary>
84
+ );
85
+ };
86
+ }
client/src/components/LabsArea.jsx ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef, useCallback } from "react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import {
4
+ Plus,
5
+ Trash2,
6
+ FileText,
7
+ Pencil,
8
+ Download,
9
+ Sparkles,
10
+ Send,
11
+ Loader2,
12
+ Check,
13
+ X,
14
+ Menu,
15
+ ChevronLeft,
16
+ Save,
17
+ Upload
18
+ } from "lucide-react";
19
+ import { clsx } from "clsx";
20
+ import { twMerge } from "tailwind-merge";
21
+ import { useLabsProjects } from "../hooks/useLabsProjects.js";
22
+ import LabsEditor from "./LabsEditor.jsx";
23
+ import { exportToWord } from "../utils/exportToWord.js";
24
+ import { exportToPdf } from "../utils/exportToPdf.js";
25
+ import { stripMetadata } from "../utils/contentUtils.js";
26
+
27
+ function cn(...inputs) {
28
+ return twMerge(clsx(inputs));
29
+ }
30
+
31
+ /**
32
+ * Project item in the sidebar
33
+ */
34
+ function ProjectItem({ project, isActive, onSelect, onRename, onDelete, modelName }) {
35
+ const [isEditing, setIsEditing] = useState(false);
36
+ const [editName, setEditName] = useState(project.name);
37
+ const inputRef = useRef(null);
38
+
39
+ useEffect(() => {
40
+ if (isEditing && inputRef.current) {
41
+ inputRef.current.focus();
42
+ inputRef.current.select();
43
+ }
44
+ }, [isEditing]);
45
+
46
+ const handleSave = () => {
47
+ if (editName.trim()) {
48
+ onRename(project.id, editName.trim());
49
+ } else {
50
+ setEditName(project.name);
51
+ }
52
+ setIsEditing(false);
53
+ };
54
+
55
+ const handleKeyDown = (e) => {
56
+ if (e.key === "Enter") {
57
+ handleSave();
58
+ } else if (e.key === "Escape") {
59
+ setEditName(project.name);
60
+ setIsEditing(false);
61
+ }
62
+ };
63
+
64
+ return (
65
+ <motion.div
66
+ initial={{ opacity: 0, x: -10 }}
67
+ animate={{ opacity: 1, x: 0 }}
68
+ exit={{ opacity: 0, x: -10 }}
69
+ className={cn(
70
+ "group flex items-start gap-2 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
71
+ isActive
72
+ ? "bg-foreground/10 text-white"
73
+ : "hover:bg-foreground/5 text-muted-foreground hover:text-white"
74
+ )}
75
+ onClick={() => !isEditing && onSelect(project.id)}
76
+ >
77
+ <FileText size={16} className="shrink-0 mt-0.5" />
78
+
79
+ <div className="flex-1 min-w-0">
80
+ {isEditing ? (
81
+ <input
82
+ ref={inputRef}
83
+ type="text"
84
+ value={editName}
85
+ onChange={(e) => setEditName(e.target.value)}
86
+ onBlur={handleSave}
87
+ onKeyDown={handleKeyDown}
88
+ onClick={(e) => e.stopPropagation()}
89
+ className="w-full bg-transparent border-b border-foreground/30 outline-none text-sm py-0.5 text-white"
90
+ />
91
+ ) : (
92
+ <>
93
+ <span className="block truncate text-sm">{project.name}</span>
94
+ {modelName && (
95
+ <span className="block text-[10px] text-muted-foreground/60 truncate mt-0.5">
96
+ {modelName}
97
+ </span>
98
+ )}
99
+ </>
100
+ )}
101
+ </div>
102
+
103
+ <div className={cn(
104
+ "flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0",
105
+ isEditing && "opacity-100"
106
+ )}>
107
+ {!isEditing && (
108
+ <>
109
+ <button
110
+ onClick={(e) => {
111
+ e.stopPropagation();
112
+ setIsEditing(true);
113
+ }}
114
+ className="p-1 hover:bg-foreground/10 rounded"
115
+ title="Rename"
116
+ >
117
+ <Pencil size={12} />
118
+ </button>
119
+ <button
120
+ onClick={(e) => {
121
+ e.stopPropagation();
122
+ onDelete(project.id);
123
+ }}
124
+ className="p-1 hover:bg-destructive/20 hover:text-destructive rounded"
125
+ title="Delete"
126
+ >
127
+ <Trash2 size={12} />
128
+ </button>
129
+ </>
130
+ )}
131
+ </div>
132
+ </motion.div>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Main Labs workspace component
138
+ */
139
+ export default function LabsArea({
140
+ toggleSidebar,
141
+ sidebarOpen,
142
+ onProjectLockChange
143
+ }) {
144
+ const {
145
+ projects,
146
+ activeProject,
147
+ activeProjectId,
148
+ setActiveProjectId,
149
+ isProjectLocked,
150
+ isProcessing,
151
+ handleNewProject,
152
+ handleImportDocument,
153
+ handleDeleteProject,
154
+ handleRenameProject,
155
+ handleUpdateDocument,
156
+ handleAIEdit,
157
+ forceSync,
158
+ getModelName
159
+ } = useLabsProjects();
160
+
161
+ const [instruction, setInstruction] = useState("");
162
+ const [error, setError] = useState("");
163
+ const [showProjectList, setShowProjectList] = useState(true);
164
+ const [saveStatus, setSaveStatus] = useState("saved");
165
+ const [isImporting, setIsImporting] = useState(false);
166
+ const [showExportMenu, setShowExportMenu] = useState(false);
167
+ const instructionRef = useRef(null);
168
+ const importInputRef = useRef(null);
169
+ const exportMenuRef = useRef(null);
170
+
171
+ // Notify parent when project lock state changes
172
+ useEffect(() => {
173
+ if (onProjectLockChange) {
174
+ onProjectLockChange(isProjectLocked);
175
+ }
176
+ }, [isProjectLocked, onProjectLockChange]);
177
+
178
+ // Handle document changes with save status
179
+ const handleDocumentChange = useCallback((newContent) => {
180
+ setSaveStatus("unsaved");
181
+ handleUpdateDocument(newContent);
182
+
183
+ setTimeout(() => {
184
+ setSaveStatus("saved");
185
+ }, 500);
186
+ }, [handleUpdateDocument]);
187
+
188
+ // Manual save function
189
+ const handleManualSave = () => {
190
+ setSaveStatus("saving");
191
+ forceSync?.();
192
+ setTimeout(() => {
193
+ setSaveStatus("saved");
194
+ }, 300);
195
+ };
196
+
197
+ // Handle file import
198
+ const handleFileImport = async (e) => {
199
+ const file = e.target.files?.[0];
200
+ if (!file) return;
201
+
202
+ setIsImporting(true);
203
+ setError("");
204
+
205
+ try {
206
+ await handleImportDocument(file);
207
+ } catch (err) {
208
+ setError("Failed to import document: " + (err.message || "Unknown error"));
209
+ } finally {
210
+ setIsImporting(false);
211
+ if (importInputRef.current) {
212
+ importInputRef.current.value = "";
213
+ }
214
+ }
215
+ };
216
+
217
+ // Handle AI instruction submission
218
+ const handleSubmitInstruction = async () => {
219
+ if (!instruction.trim() || isProcessing) return;
220
+
221
+ setError("");
222
+
223
+ try {
224
+ await handleAIEdit(instruction.trim());
225
+ setInstruction("");
226
+ } catch (err) {
227
+ setError(err.message || "Failed to process instruction");
228
+ }
229
+ };
230
+
231
+ // Handle selection-based AI editing
232
+ const handleSelectionEdit = async ({ selectedText, instruction, contextBefore, contextAfter }) => {
233
+ try {
234
+ const response = await fetch("/labs-edit-selection", {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({
238
+ selectedText,
239
+ instruction,
240
+ contextBefore,
241
+ contextAfter,
242
+ sessionId: activeProject?.sessionId
243
+ })
244
+ });
245
+
246
+ if (!response.ok) {
247
+ const errorText = await response.text().catch(() => "");
248
+ throw new Error(errorText || `Request failed (${response.status})`);
249
+ }
250
+
251
+ // Parse SSE response
252
+ if (!response.body) {
253
+ throw new Error("No response body");
254
+ }
255
+
256
+ const reader = response.body.getReader();
257
+ const decoder = new TextDecoder();
258
+ let fullContent = "";
259
+ let buffer = "";
260
+
261
+ while (true) {
262
+ const { value, done } = await reader.read();
263
+ if (done) break;
264
+
265
+ buffer += decoder.decode(value, { stream: true });
266
+
267
+ const lines = buffer.split("\n");
268
+ buffer = lines.pop() || "";
269
+
270
+ for (const line of lines) {
271
+ if (line.startsWith("data: ")) {
272
+ try {
273
+ const data = JSON.parse(line.slice(6));
274
+ if (data.text) {
275
+ fullContent += data.text;
276
+ }
277
+ } catch {
278
+ fullContent += line.slice(6);
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ return stripMetadata(fullContent);
285
+ } catch (error) {
286
+ console.error("[Labs] Selection edit failed:", error);
287
+ setError(error.message || "Failed to edit selection");
288
+ throw error;
289
+ }
290
+ };
291
+
292
+ const handleKeyDown = (e) => {
293
+ if (e.key === "Enter" && !e.shiftKey) {
294
+ e.preventDefault();
295
+ handleSubmitInstruction();
296
+ }
297
+ };
298
+
299
+ // Handle Word export
300
+ const handleExportWord = async () => {
301
+ if (!activeProject?.document) return;
302
+ setShowExportMenu(false);
303
+
304
+ try {
305
+ await exportToWord(activeProject.document, activeProject.name);
306
+ } catch (err) {
307
+ setError("Failed to export document");
308
+ console.error("[Labs] Export error:", err);
309
+ }
310
+ };
311
+
312
+ // Handle PDF export
313
+ const handleExportPdf = async () => {
314
+ if (!activeProject?.document) return;
315
+ setShowExportMenu(false);
316
+
317
+ try {
318
+ await exportToPdf(activeProject.document, activeProject.name);
319
+ } catch (err) {
320
+ setError("Failed to export PDF");
321
+ console.error("[Labs] PDF Export error:", err);
322
+ }
323
+ };
324
+
325
+ // Close export menu when clicking outside
326
+ useEffect(() => {
327
+ const handleClickOutside = (e) => {
328
+ if (exportMenuRef.current && !exportMenuRef.current.contains(e.target)) {
329
+ setShowExportMenu(false);
330
+ }
331
+ };
332
+ document.addEventListener("mousedown", handleClickOutside);
333
+ return () => document.removeEventListener("mousedown", handleClickOutside);
334
+ }, []);
335
+
336
+ // Hide project list on mobile by default
337
+ useEffect(() => {
338
+ const handleResize = () => {
339
+ if (window.innerWidth < 768) {
340
+ setShowProjectList(false);
341
+ }
342
+ };
343
+ handleResize();
344
+ window.addEventListener('resize', handleResize);
345
+ return () => window.removeEventListener('resize', handleResize);
346
+ }, []);
347
+
348
+ return (
349
+ <div className="flex-1 flex flex-col h-full bg-card">
350
+ {/* Mobile Header */}
351
+ <div className="md:hidden flex items-center p-3 border-b border-border/30 bg-card sticky top-0 z-10">
352
+ <button
353
+ onClick={toggleSidebar}
354
+ className="p-2 -ml-2 text-muted-foreground hover:text-white rounded-lg"
355
+ aria-label="Open sidebar"
356
+ >
357
+ <Menu size={20} />
358
+ </button>
359
+ <span className="font-semibold text-sm ml-2 text-white">Labs</span>
360
+
361
+ <button
362
+ onClick={() => setShowProjectList(!showProjectList)}
363
+ className="ml-auto p-2 text-muted-foreground hover:text-white rounded-lg md:hidden"
364
+ >
365
+ <FileText size={18} />
366
+ </button>
367
+ </div>
368
+
369
+ <div className="flex-1 flex overflow-hidden">
370
+ {/* Project Sidebar */}
371
+ <AnimatePresence>
372
+ {showProjectList && (
373
+ <motion.div
374
+ initial={{ width: 0, opacity: 0 }}
375
+ animate={{ width: 240, opacity: 1 }}
376
+ exit={{ width: 0, opacity: 0 }}
377
+ transition={{ duration: 0.2 }}
378
+ className="h-full border-r border-border bg-background flex flex-col overflow-hidden"
379
+ >
380
+ {/* Sidebar Header */}
381
+ <div className="p-3 border-b border-border flex items-center justify-between gap-2">
382
+ <h2 className="font-semibold text-white text-sm">Projects</h2>
383
+ <div className="flex items-center gap-1">
384
+ {/* Import Button */}
385
+ <input
386
+ type="file"
387
+ ref={importInputRef}
388
+ onChange={handleFileImport}
389
+ accept=".txt,.md,.docx"
390
+ className="hidden"
391
+ />
392
+ <button
393
+ onClick={() => importInputRef.current?.click()}
394
+ disabled={isImporting}
395
+ className="p-1.5 hover:bg-foreground/10 rounded-lg text-muted-foreground hover:text-white transition-colors"
396
+ title="Import Document"
397
+ >
398
+ {isImporting ? (
399
+ <Loader2 size={16} className="animate-spin" />
400
+ ) : (
401
+ <Upload size={16} />
402
+ )}
403
+ </button>
404
+ <button
405
+ onClick={() => handleNewProject()}
406
+ className="p-1.5 hover:bg-foreground/10 rounded-lg text-muted-foreground hover:text-white transition-colors"
407
+ title="New Project"
408
+ >
409
+ <Plus size={16} />
410
+ </button>
411
+ </div>
412
+ </div>
413
+
414
+ {/* Project List */}
415
+ <div className="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar">
416
+ <AnimatePresence>
417
+ {projects.map((project) => (
418
+ <ProjectItem
419
+ key={project.id}
420
+ project={project}
421
+ isActive={project.id === activeProjectId}
422
+ onSelect={(id) => {
423
+ setActiveProjectId(id);
424
+ if (window.innerWidth < 768) {
425
+ setShowProjectList(false);
426
+ }
427
+ }}
428
+ onRename={handleRenameProject}
429
+ onDelete={handleDeleteProject}
430
+ modelName={getModelName()}
431
+ />
432
+ ))}
433
+ </AnimatePresence>
434
+ </div>
435
+ </motion.div>
436
+ )}
437
+ </AnimatePresence>
438
+
439
+ {/* Main Editor Area */}
440
+ <div className="flex-1 flex flex-col min-w-0">
441
+ {activeProject ? (
442
+ <>
443
+ {/* Editor Header */}
444
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border bg-card gap-2">
445
+ <div className="flex items-center gap-2 min-w-0 flex-1">
446
+ <button
447
+ onClick={() => setShowProjectList(!showProjectList)}
448
+ className="p-1.5 hover:bg-foreground/10 rounded-lg text-muted-foreground hover:text-white hidden md:flex shrink-0"
449
+ title={showProjectList ? "Hide projects" : "Show projects"}
450
+ >
451
+ <ChevronLeft size={16} className={cn(
452
+ "transition-transform",
453
+ !showProjectList && "rotate-180"
454
+ )} />
455
+ </button>
456
+ <h1 className="font-medium text-white truncate text-sm">
457
+ {activeProject.name}
458
+ </h1>
459
+
460
+ <span className={cn(
461
+ "text-xs px-2 py-0.5 rounded-full shrink-0",
462
+ saveStatus === "saved" && "text-green-400/70 bg-green-400/10",
463
+ saveStatus === "saving" && "text-yellow-400/70 bg-yellow-400/10",
464
+ saveStatus === "unsaved" && "text-muted-foreground bg-foreground/5"
465
+ )}>
466
+ {saveStatus === "saved" && "Saved"}
467
+ {saveStatus === "saving" && "Saving..."}
468
+ {saveStatus === "unsaved" && "Unsaved"}
469
+ </span>
470
+ </div>
471
+
472
+ <div className="flex items-center gap-2 shrink-0">
473
+ <button
474
+ onClick={handleManualSave}
475
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium bg-foreground/5 text-muted-foreground hover:text-white hover:bg-foreground/10 transition-colors"
476
+ title="Save (auto-saves)"
477
+ >
478
+ <Save size={14} />
479
+ <span className="hidden sm:inline">Save</span>
480
+ </button>
481
+
482
+ {/* Export Dropdown */}
483
+ <div className="relative" ref={exportMenuRef}>
484
+ <button
485
+ onClick={() => setShowExportMenu(!showExportMenu)}
486
+ disabled={!activeProject.document}
487
+ className={cn(
488
+ "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors",
489
+ activeProject.document
490
+ ? "bg-primary/10 text-primary hover:bg-primary/20"
491
+ : "bg-foreground/5 text-muted-foreground/50 cursor-not-allowed"
492
+ )}
493
+ >
494
+ <Download size={14} />
495
+ <span className="hidden sm:inline">Export</span>
496
+ </button>
497
+
498
+ <AnimatePresence>
499
+ {showExportMenu && (
500
+ <motion.div
501
+ initial={{ opacity: 0, y: -5 }}
502
+ animate={{ opacity: 1, y: 0 }}
503
+ exit={{ opacity: 0, y: -5 }}
504
+ className="absolute right-0 top-full mt-1 bg-[#1E1E1E] border border-border rounded-lg shadow-xl z-50 min-w-[120px] overflow-hidden"
505
+ >
506
+ <button
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
+ )}
520
+ </AnimatePresence>
521
+ </div>
522
+ </div>
523
+ </div>
524
+
525
+ {/* Document Editor */}
526
+ <div className="flex-1 overflow-hidden">
527
+ <LabsEditor
528
+ content={activeProject.document}
529
+ onChange={handleDocumentChange}
530
+ isProcessing={isProcessing}
531
+ onSelectionEdit={handleSelectionEdit}
532
+ />
533
+ </div>
534
+
535
+ {/* AI Instruction Bar */}
536
+ <div className="p-3 border-t border-border bg-card">
537
+ <div className="max-w-3xl mx-auto">
538
+ {error && (
539
+ <div className="mb-2 px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-xs flex items-center gap-2">
540
+ <X size={12} />
541
+ {error}
542
+ </div>
543
+ )}
544
+
545
+ <div className="relative bg-[var(--input-surface)] border border-[var(--input-border)] rounded-xl transition-all focus-within:border-[var(--input-border-focus)]">
546
+ <div className="flex items-center gap-2 px-3 py-2.5">
547
+ <Sparkles size={16} className="text-primary shrink-0" />
548
+ <input
549
+ ref={instructionRef}
550
+ type="text"
551
+ value={instruction}
552
+ onChange={(e) => setInstruction(e.target.value)}
553
+ onKeyDown={handleKeyDown}
554
+ placeholder={activeProject.document
555
+ ? "Give an instruction to edit..."
556
+ : "Describe what to create..."
557
+ }
558
+ disabled={isProcessing}
559
+ className="flex-1 bg-transparent outline-none text-white placeholder:text-muted-foreground text-sm"
560
+ />
561
+ <button
562
+ onClick={handleSubmitInstruction}
563
+ disabled={!instruction.trim() || isProcessing}
564
+ className={cn(
565
+ "p-1.5 rounded-lg transition-colors",
566
+ instruction.trim() && !isProcessing
567
+ ? "bg-primary text-primary-foreground hover:bg-primary/90"
568
+ : "bg-foreground/10 text-muted-foreground/50 cursor-not-allowed"
569
+ )}
570
+ >
571
+ {isProcessing ? (
572
+ <Loader2 size={14} className="animate-spin" />
573
+ ) : (
574
+ <Send size={14} />
575
+ )}
576
+ </button>
577
+ </div>
578
+ </div>
579
+ </div>
580
+ </div>
581
+ </>
582
+ ) : (
583
+ /* Empty State */
584
+ <div className="flex-1 flex items-center justify-center p-4">
585
+ <div className="text-center">
586
+ <div className="w-14 h-14 rounded-2xl bg-foreground/5 border border-border flex items-center justify-center mx-auto mb-4">
587
+ <FileText className="text-muted-foreground w-7 h-7" />
588
+ </div>
589
+ <h2 className="text-lg font-semibold text-white mb-2">No Project Selected</h2>
590
+ <p className="text-muted-foreground text-sm mb-4">Create a new project or import a document</p>
591
+ <div className="flex items-center justify-center gap-2">
592
+ <button
593
+ onClick={() => handleNewProject()}
594
+ className="px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
595
+ >
596
+ Create Project
597
+ </button>
598
+ <button
599
+ onClick={() => importInputRef.current?.click()}
600
+ className="px-4 py-2 bg-foreground/10 text-white rounded-lg text-sm font-medium hover:bg-foreground/15 transition-colors"
601
+ >
602
+ <Upload size={14} className="inline mr-1.5" />
603
+ Import
604
+ </button>
605
+ </div>
606
+ </div>
607
+ </div>
608
+ )}
609
+ </div>
610
+ </div>
611
+ </div>
612
+ );
613
+ }
client/src/components/LabsEditor.jsx ADDED
@@ -0,0 +1,931 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkMath from "remark-math";
5
+ import rehypeKatex from "rehype-katex";
6
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
7
+ import { motion, AnimatePresence } from "framer-motion";
8
+ import "katex/dist/katex.min.css";
9
+ import {
10
+ Eye,
11
+ Edit3,
12
+ Copy,
13
+ Check,
14
+ Sparkles,
15
+ X,
16
+ Loader2,
17
+ Send,
18
+ Bold,
19
+ Italic,
20
+ Heading1,
21
+ Heading2,
22
+ List,
23
+ ListOrdered,
24
+ Code,
25
+ Link,
26
+ Quote,
27
+ Undo2,
28
+ Redo2
29
+ } from "lucide-react";
30
+ import { clsx } from "clsx";
31
+ import { twMerge } from "tailwind-merge";
32
+
33
+ function cn(...inputs) {
34
+ return twMerge(clsx(inputs));
35
+ }
36
+
37
+ const markdownSchema = {
38
+ ...defaultSchema,
39
+ attributes: {
40
+ ...defaultSchema.attributes,
41
+ a: [
42
+ ...(defaultSchema.attributes?.a || []),
43
+ ["target", "_blank"],
44
+ ["rel", "noopener noreferrer"]
45
+ ],
46
+ code: [...(defaultSchema.attributes?.code || []), "className"],
47
+ // Allow KaTeX elements and attributes
48
+ span: [
49
+ ...(defaultSchema.attributes?.span || []),
50
+ "className",
51
+ "style",
52
+ "aria-hidden"
53
+ ],
54
+ annotation: ["encoding"],
55
+ semantics: []
56
+ },
57
+ tagNames: [
58
+ ...(defaultSchema.tagNames || []),
59
+ "math",
60
+ "annotation",
61
+ "semantics",
62
+ "mtext",
63
+ "mn",
64
+ "mo",
65
+ "mi",
66
+ "mspace",
67
+ "mover",
68
+ "munder",
69
+ "munderover",
70
+ "msup",
71
+ "msub",
72
+ "msubsup",
73
+ "mfrac",
74
+ "mroot",
75
+ "msqrt",
76
+ "mtable",
77
+ "mtr",
78
+ "mtd",
79
+ "mlabeledtr",
80
+ "mrow",
81
+ "menclose",
82
+ "mstyle",
83
+ "mpadded",
84
+ "mphantom"
85
+ ]
86
+ };
87
+
88
+ /**
89
+ * Rich Text Formatting Toolbar
90
+ */
91
+ function FormattingToolbar({ onFormat, disabled }) {
92
+ const tools = [
93
+ { icon: Bold, action: "bold", title: "Bold (Ctrl+B)", syntax: ["**", "**"] },
94
+ { icon: Italic, action: "italic", title: "Italic (Ctrl+I)", syntax: ["*", "*"] },
95
+ { icon: Heading1, action: "h1", title: "Heading 1", syntax: ["# ", ""] },
96
+ { icon: Heading2, action: "h2", title: "Heading 2", syntax: ["## ", ""] },
97
+ { icon: List, action: "ul", title: "Bullet List", syntax: ["- ", ""] },
98
+ { icon: ListOrdered, action: "ol", title: "Numbered List", syntax: ["1. ", ""] },
99
+ { icon: Code, action: "code", title: "Inline Code", syntax: ["`", "`"] },
100
+ { icon: Link, action: "link", title: "Link", syntax: ["[", "](url)"] },
101
+ { icon: Quote, action: "quote", title: "Blockquote", syntax: ["> ", ""] },
102
+ ];
103
+
104
+ return (
105
+ <div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-border bg-background/30">
106
+ {tools.map(({ icon: Icon, action, title, syntax }) => (
107
+ <button
108
+ key={action}
109
+ onClick={() => onFormat(syntax[0], syntax[1])}
110
+ disabled={disabled}
111
+ className={cn(
112
+ "p-1.5 rounded-md text-muted-foreground transition-colors",
113
+ disabled
114
+ ? "opacity-40 cursor-not-allowed"
115
+ : "hover:text-white hover:bg-foreground/10"
116
+ )}
117
+ title={title}
118
+ >
119
+ <Icon size={16} />
120
+ </button>
121
+ ))}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Undo/Redo Controls
128
+ */
129
+ function HistoryControls({ canUndo, canRedo, onUndo, onRedo, historyCount }) {
130
+ return (
131
+ <div className="flex items-center gap-1">
132
+ <button
133
+ onClick={onUndo}
134
+ disabled={!canUndo}
135
+ className={cn(
136
+ "p-1.5 rounded-md transition-colors flex items-center gap-1",
137
+ canUndo
138
+ ? "text-muted-foreground hover:text-white hover:bg-foreground/10"
139
+ : "text-muted-foreground/30 cursor-not-allowed"
140
+ )}
141
+ title="Undo"
142
+ >
143
+ <Undo2 size={14} />
144
+ </button>
145
+ <button
146
+ onClick={onRedo}
147
+ disabled={!canRedo}
148
+ className={cn(
149
+ "p-1.5 rounded-md transition-colors",
150
+ canRedo
151
+ ? "text-muted-foreground hover:text-white hover:bg-foreground/10"
152
+ : "text-muted-foreground/30 cursor-not-allowed"
153
+ )}
154
+ title="Redo"
155
+ >
156
+ <Redo2 size={14} />
157
+ </button>
158
+ {historyCount > 0 && (
159
+ <span className="text-xs text-muted-foreground/50 ml-1">
160
+ {historyCount} snapshot{historyCount !== 1 ? 's' : ''}
161
+ </span>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Markdown preview component using same styling as chat
169
+ */
170
+ function MarkdownPreview({ content }) {
171
+ const [copiedCode, setCopiedCode] = useState(null);
172
+
173
+ const handleCopyCode = (code, index) => {
174
+ navigator.clipboard?.writeText(code);
175
+ setCopiedCode(index);
176
+ setTimeout(() => setCopiedCode(null), 2000);
177
+ };
178
+
179
+ if (!content) {
180
+ return (
181
+ <div className="flex items-center justify-center h-full text-muted-foreground">
182
+ <p>No content yet. Start typing or use AI to generate.</p>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ return (
188
+ <div className="markdown-content prose prose-invert max-w-none px-6 py-4">
189
+ <ReactMarkdown
190
+ remarkPlugins={[remarkGfm, remarkMath]}
191
+ rehypePlugins={[rehypeKatex, [rehypeSanitize, markdownSchema]]}
192
+ components={{
193
+ a: (props) => (
194
+ <a
195
+ {...props}
196
+ target="_blank"
197
+ rel="noopener noreferrer"
198
+ className="text-white underline decoration-border hover:text-white/90 hover:decoration-muted-foreground/40 transition-colors"
199
+ />
200
+ ),
201
+ pre: ({ children, ...props }) => {
202
+ const codeContent = React.Children.toArray(children)
203
+ .map(child => {
204
+ if (React.isValidElement(child) && child.props?.children) {
205
+ return typeof child.props.children === 'string'
206
+ ? child.props.children
207
+ : '';
208
+ }
209
+ return '';
210
+ })
211
+ .join('');
212
+ const index = Math.random().toString(36).substr(2, 9);
213
+
214
+ return (
215
+ <div className="relative group my-4">
216
+ <pre
217
+ {...props}
218
+ className="bg-popover border border-border rounded-lg p-4 overflow-x-auto text-sm"
219
+ >
220
+ {children}
221
+ </pre>
222
+ <button
223
+ onClick={() => handleCopyCode(codeContent, index)}
224
+ 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"
225
+ aria-label="Copy code"
226
+ >
227
+ {copiedCode === index ? (
228
+ <Check size={14} className="text-green-400" />
229
+ ) : (
230
+ <Copy size={14} />
231
+ )}
232
+ </button>
233
+ </div>
234
+ );
235
+ },
236
+ code: ({ inline, className, children, ...props }) => {
237
+ if (inline) {
238
+ return (
239
+ <code
240
+ className="px-1.5 py-0.5 rounded-md bg-foreground/10 text-white text-sm font-mono"
241
+ {...props}
242
+ >
243
+ {children}
244
+ </code>
245
+ );
246
+ }
247
+ return (
248
+ <code className={cn("text-white/90 font-mono text-sm", className)} {...props}>
249
+ {children}
250
+ </code>
251
+ );
252
+ },
253
+ table: ({ children, ...props }) => (
254
+ <div className="my-4 overflow-x-auto rounded-lg border border-border">
255
+ <table className="min-w-full divide-y divide-border" {...props}>
256
+ {children}
257
+ </table>
258
+ </div>
259
+ ),
260
+ thead: ({ children, ...props }) => (
261
+ <thead className="bg-foreground/5" {...props}>
262
+ {children}
263
+ </thead>
264
+ ),
265
+ th: ({ children, ...props }) => (
266
+ <th
267
+ className="px-4 py-3 text-left text-xs font-semibold text-white uppercase tracking-wider border-r border-border last:border-r-0"
268
+ {...props}
269
+ >
270
+ {children}
271
+ </th>
272
+ ),
273
+ td: ({ children, ...props }) => (
274
+ <td
275
+ className="px-4 py-3 text-sm text-white/80 border-r border-border last:border-r-0"
276
+ {...props}
277
+ >
278
+ {children}
279
+ </td>
280
+ ),
281
+ tr: ({ children, ...props }) => (
282
+ <tr
283
+ className="border-b border-border last:border-b-0 hover:bg-foreground/5 transition-colors"
284
+ {...props}
285
+ >
286
+ {children}
287
+ </tr>
288
+ ),
289
+ ul: ({ children, ...props }) => (
290
+ <ul className="my-3 ml-1 space-y-2 list-none" {...props}>
291
+ {children}
292
+ </ul>
293
+ ),
294
+ ol: ({ children, ...props }) => (
295
+ <ol className="my-3 ml-1 space-y-2 list-none counter-reset-item" {...props}>
296
+ {children}
297
+ </ol>
298
+ ),
299
+ li: ({ children, ordered, ...props }) => (
300
+ <li className="relative pl-6 text-white/90" {...props}>
301
+ <span className="absolute left-0 text-muted-foreground">•</span>
302
+ {children}
303
+ </li>
304
+ ),
305
+ h1: ({ children, ...props }) => (
306
+ <h1 className="text-2xl font-bold text-white mt-6 mb-4 pb-2 border-b border-border" {...props}>
307
+ {children}
308
+ </h1>
309
+ ),
310
+ h2: ({ children, ...props }) => (
311
+ <h2 className="text-xl font-semibold text-white mt-5 mb-3" {...props}>
312
+ {children}
313
+ </h2>
314
+ ),
315
+ h3: ({ children, ...props }) => (
316
+ <h3 className="text-lg font-semibold text-white mt-4 mb-2" {...props}>
317
+ {children}
318
+ </h3>
319
+ ),
320
+ p: ({ children, ...props }) => (
321
+ <p className="my-[0.7em] text-white/90 leading-[1.65]" {...props}>
322
+ {children}
323
+ </p>
324
+ ),
325
+ strong: ({ children, ...props }) => (
326
+ <strong className="font-bold text-white" {...props}>
327
+ {children}
328
+ </strong>
329
+ ),
330
+ em: ({ children, ...props }) => (
331
+ <em className="italic text-white/90" {...props}>
332
+ {children}
333
+ </em>
334
+ ),
335
+ blockquote: ({ children, ...props }) => (
336
+ <blockquote
337
+ className="my-4 pl-4 border-l-4 border-border bg-foreground/5 py-2 pr-4 rounded-r-lg italic text-white/80"
338
+ {...props}
339
+ >
340
+ {children}
341
+ </blockquote>
342
+ ),
343
+ hr: (props) => (
344
+ <hr className="my-6 border-t border-border" {...props} />
345
+ )
346
+ }}
347
+ >
348
+ {content}
349
+ </ReactMarkdown>
350
+ </div>
351
+ );
352
+ }
353
+
354
+ /**
355
+ * AI Edit Toolbar — bottom sheet on mobile, floating popover on desktop.
356
+ * Inspired by Google Docs / Notion mobile editing UX.
357
+ */
358
+ function SelectionToolbar({
359
+ position,
360
+ selectedText,
361
+ onClose,
362
+ onSubmit,
363
+ isProcessing
364
+ }) {
365
+ const [instruction, setInstruction] = useState("");
366
+ const inputRef = useRef(null);
367
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
368
+
369
+ useEffect(() => {
370
+ const check = () => setIsMobile(window.innerWidth < 768);
371
+ window.addEventListener("resize", check);
372
+ return () => window.removeEventListener("resize", check);
373
+ }, []);
374
+
375
+ // Auto-focus the input
376
+ useEffect(() => {
377
+ // Small delay to let animation complete
378
+ const t = setTimeout(() => inputRef.current?.focus(), 200);
379
+ return () => clearTimeout(t);
380
+ }, []);
381
+
382
+ const handleSubmit = () => {
383
+ if (instruction.trim() && !isProcessing) {
384
+ onSubmit(instruction.trim());
385
+ setInstruction("");
386
+ }
387
+ };
388
+
389
+ const handleKeyDown = (e) => {
390
+ if (e.key === "Enter" && !e.shiftKey) {
391
+ e.preventDefault();
392
+ handleSubmit();
393
+ } else if (e.key === "Escape") {
394
+ onClose();
395
+ }
396
+ };
397
+
398
+ const preview = selectedText.length > 60
399
+ ? selectedText.slice(0, 60) + "…"
400
+ : selectedText;
401
+
402
+ /* ── Inner content (shared by both layouts) ── */
403
+ const innerContent = (
404
+ <div className="selection-toolbar-inner">
405
+ {/* Header with selected text preview */}
406
+ <div className="flex items-center gap-2 mb-3">
407
+ <div className="flex items-center gap-2 flex-1 min-w-0">
408
+ <div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
409
+ <Sparkles size={14} className="text-primary" />
410
+ </div>
411
+ <div className="min-w-0">
412
+ <div className="text-xs font-semibold text-white">AI Edit Selection</div>
413
+ <div className="text-[11px] text-muted-foreground truncate">
414
+ "{preview}"
415
+ </div>
416
+ </div>
417
+ </div>
418
+ <button
419
+ onClick={onClose}
420
+ className="p-1.5 hover:bg-foreground/10 rounded-lg text-muted-foreground hover:text-white transition-colors shrink-0"
421
+ aria-label="Close"
422
+ >
423
+ <X size={16} />
424
+ </button>
425
+ </div>
426
+
427
+ {/* Instruction input */}
428
+ <div className="flex items-center gap-2">
429
+ <input
430
+ ref={inputRef}
431
+ type="text"
432
+ value={instruction}
433
+ onChange={(e) => setInstruction(e.target.value)}
434
+ onKeyDown={handleKeyDown}
435
+ placeholder="Describe the change…"
436
+ disabled={isProcessing}
437
+ className="flex-1 bg-foreground/5 border border-border rounded-xl px-3.5 py-2.5 text-sm text-white placeholder:text-muted-foreground/60 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all"
438
+ autoComplete="off"
439
+ />
440
+ <button
441
+ onClick={handleSubmit}
442
+ disabled={!instruction.trim() || isProcessing}
443
+ className={cn(
444
+ "p-2.5 rounded-xl transition-all",
445
+ instruction.trim() && !isProcessing
446
+ ? "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20"
447
+ : "bg-foreground/10 text-muted-foreground/50 cursor-not-allowed"
448
+ )}
449
+ >
450
+ {isProcessing ? (
451
+ <Loader2 size={16} className="animate-spin" />
452
+ ) : (
453
+ <Send size={16} />
454
+ )}
455
+ </button>
456
+ </div>
457
+
458
+ {/* Quick action chips */}
459
+ <div className="flex flex-wrap gap-1.5 mt-2.5">
460
+ {["Fix grammar", "Make shorter", "Make formal", "Simplify"].map(label => (
461
+ <button
462
+ key={label}
463
+ onClick={() => {
464
+ if (!isProcessing) onSubmit(label);
465
+ }}
466
+ disabled={isProcessing}
467
+ className="px-2.5 py-1 rounded-full text-[11px] font-medium border border-border bg-foreground/5 text-muted-foreground hover:bg-foreground/10 hover:text-white transition-colors disabled:opacity-50"
468
+ >
469
+ {label}
470
+ </button>
471
+ ))}
472
+ </div>
473
+ </div>
474
+ );
475
+
476
+ /* ── Mobile: bottom sheet with backdrop ── */
477
+ if (isMobile) {
478
+ return (
479
+ <>
480
+ {/* Backdrop */}
481
+ <motion.div
482
+ initial={{ opacity: 0 }}
483
+ animate={{ opacity: 1 }}
484
+ exit={{ opacity: 0 }}
485
+ transition={{ duration: 0.15 }}
486
+ className="fixed inset-0 z-[9998] bg-black/40"
487
+ onClick={onClose}
488
+ />
489
+ {/* Bottom sheet */}
490
+ <motion.div
491
+ initial={{ opacity: 0, y: 80 }}
492
+ animate={{ opacity: 1, y: 0 }}
493
+ exit={{ opacity: 0, y: 80 }}
494
+ transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
495
+ className="fixed bottom-0 left-0 right-0 z-[9999] selection-toolbar-sheet"
496
+ >
497
+ {/* Drag handle */}
498
+ <div className="flex justify-center pt-2 pb-1">
499
+ <div className="w-8 h-1 rounded-full bg-foreground/20" />
500
+ </div>
501
+ {innerContent}
502
+ </motion.div>
503
+ </>
504
+ );
505
+ }
506
+
507
+ /* ── Desktop: floating popover ── */
508
+ const clampedX = Math.min(Math.max(position.x, 180), window.innerWidth - 180);
509
+ const clampedY = Math.min(Math.max(position.y, 60), window.innerHeight - 200);
510
+
511
+ return (
512
+ <motion.div
513
+ initial={{ opacity: 0, y: 8, scale: 0.96 }}
514
+ animate={{ opacity: 1, y: 0, scale: 1 }}
515
+ exit={{ opacity: 0, y: 8, scale: 0.96 }}
516
+ transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
517
+ className="fixed z-[9999] selection-toolbar-popover"
518
+ style={{
519
+ left: `${clampedX}px`,
520
+ top: `${clampedY}px`,
521
+ transform: "translateX(-50%)",
522
+ }}
523
+ >
524
+ {innerContent}
525
+ </motion.div>
526
+ );
527
+ }
528
+
529
+ // Maximum history snapshots to keep
530
+ const MAX_HISTORY = 50;
531
+
532
+ /**
533
+ * Document editor with edit/preview modes, formatting toolbar, undo/redo, and AI editing
534
+ */
535
+ export default function LabsEditor({
536
+ content,
537
+ onChange,
538
+ isProcessing,
539
+ onSelectionEdit
540
+ }) {
541
+ const [mode, setMode] = useState("edit");
542
+ const textareaRef = useRef(null);
543
+ const containerRef = useRef(null);
544
+ const [localContent, setLocalContent] = useState(content || "");
545
+ const debounceRef = useRef(null);
546
+
547
+ // Undo/Redo history
548
+ const [history, setHistory] = useState([]);
549
+ const [historyIndex, setHistoryIndex] = useState(-1);
550
+ const isUndoRedoRef = useRef(false);
551
+
552
+ // Selection state
553
+ const [selection, setSelection] = useState(null);
554
+ const [toolbarPosition, setToolbarPosition] = useState(null);
555
+ const [isEditing, setIsEditing] = useState(false);
556
+ const [showToolbar, setShowToolbar] = useState(false);
557
+
558
+ // Sync external content changes
559
+ useEffect(() => {
560
+ if (content !== localContent && !isUndoRedoRef.current) {
561
+ setLocalContent(content || "");
562
+ }
563
+ isUndoRedoRef.current = false;
564
+ }, [content]);
565
+
566
+ // Save snapshot to history (called before AI edits and periodically)
567
+ const saveSnapshot = useCallback(() => {
568
+ setHistory(prev => {
569
+ // Remove any "future" states if we're not at the end
570
+ const newHistory = prev.slice(0, historyIndex + 1);
571
+ // Add current state
572
+ newHistory.push({
573
+ content: localContent,
574
+ timestamp: Date.now()
575
+ });
576
+ // Limit history size
577
+ if (newHistory.length > MAX_HISTORY) {
578
+ newHistory.shift();
579
+ }
580
+ return newHistory;
581
+ });
582
+ setHistoryIndex(prev => Math.min(prev + 1, MAX_HISTORY - 1));
583
+ }, [localContent, historyIndex]);
584
+
585
+ // Undo function
586
+ const handleUndo = useCallback(() => {
587
+ if (historyIndex > 0) {
588
+ isUndoRedoRef.current = true;
589
+ const prevState = history[historyIndex - 1];
590
+ setLocalContent(prevState.content);
591
+ onChange(prevState.content);
592
+ setHistoryIndex(prev => prev - 1);
593
+ }
594
+ }, [history, historyIndex, onChange]);
595
+
596
+ // Redo function
597
+ const handleRedo = useCallback(() => {
598
+ if (historyIndex < history.length - 1) {
599
+ isUndoRedoRef.current = true;
600
+ const nextState = history[historyIndex + 1];
601
+ setLocalContent(nextState.content);
602
+ onChange(nextState.content);
603
+ setHistoryIndex(prev => prev + 1);
604
+ }
605
+ }, [history, historyIndex, onChange]);
606
+
607
+ // Debounced save with snapshot
608
+ const handleChange = useCallback((e) => {
609
+ const value = e.target.value;
610
+ setLocalContent(value);
611
+
612
+ if (debounceRef.current) {
613
+ clearTimeout(debounceRef.current);
614
+ }
615
+
616
+ debounceRef.current = setTimeout(() => {
617
+ onChange(value);
618
+ // Save snapshot every few seconds of inactivity
619
+ saveSnapshot();
620
+ }, 1000);
621
+ }, [onChange, saveSnapshot]);
622
+
623
+ // Cleanup debounce on unmount
624
+ useEffect(() => {
625
+ return () => {
626
+ if (debounceRef.current) {
627
+ clearTimeout(debounceRef.current);
628
+ }
629
+ };
630
+ }, []);
631
+
632
+ // Auto-resize textarea
633
+ useEffect(() => {
634
+ const textarea = textareaRef.current;
635
+ if (textarea && mode === "edit") {
636
+ textarea.style.height = "auto";
637
+ textarea.style.height = `${textarea.scrollHeight}px`;
638
+ }
639
+ }, [localContent, mode]);
640
+
641
+ // Handle formatting toolbar actions
642
+ const handleFormat = useCallback((prefix, suffix) => {
643
+ const textarea = textareaRef.current;
644
+ if (!textarea) return;
645
+
646
+ const start = textarea.selectionStart;
647
+ const end = textarea.selectionEnd;
648
+ const selectedText = textarea.value.substring(start, end);
649
+
650
+ let newText;
651
+ let newCursorPos;
652
+
653
+ if (selectedText) {
654
+ // Wrap selected text
655
+ newText = textarea.value.substring(0, start) +
656
+ prefix + selectedText + suffix +
657
+ textarea.value.substring(end);
658
+ newCursorPos = end + prefix.length + suffix.length;
659
+ } else {
660
+ // Insert at cursor
661
+ newText = textarea.value.substring(0, start) +
662
+ prefix + suffix +
663
+ textarea.value.substring(end);
664
+ newCursorPos = start + prefix.length;
665
+ }
666
+
667
+ setLocalContent(newText);
668
+ onChange(newText);
669
+
670
+ // Restore cursor position
671
+ requestAnimationFrame(() => {
672
+ textarea.focus();
673
+ textarea.setSelectionRange(newCursorPos, newCursorPos);
674
+ });
675
+ }, [onChange]);
676
+
677
+ // Handle text selection
678
+ const handleSelect = useCallback(() => {
679
+ const textarea = textareaRef.current;
680
+ if (!textarea) return;
681
+
682
+ const start = textarea.selectionStart;
683
+ const end = textarea.selectionEnd;
684
+ const selectedText = textarea.value.substring(start, end);
685
+
686
+ if (selectedText.length > 3) {
687
+ const rect = textarea.getBoundingClientRect();
688
+ const textBeforeSelection = textarea.value.substring(0, start);
689
+ const lines = textBeforeSelection.split('\n');
690
+ const currentLine = lines.length;
691
+ const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 20;
692
+ const scrollTop = textarea.scrollTop;
693
+
694
+ const rawX = rect.left + rect.width / 2;
695
+ // Account for scroll position when calculating y
696
+ const rawY = rect.top + (currentLine * lineHeight) - scrollTop - 40;
697
+ // Clamp x so toolbar stays within viewport
698
+ const x = Math.min(Math.max(rawX, 160), window.innerWidth - 160);
699
+ // Clamp y to stay within the textarea's visible bounds
700
+ const y = Math.min(
701
+ Math.max(rawY, rect.top + 10),
702
+ rect.bottom - 50
703
+ );
704
+
705
+ setSelection({
706
+ start,
707
+ end,
708
+ text: selectedText
709
+ });
710
+ setToolbarPosition({ x, y });
711
+ } else {
712
+ closeToolbar();
713
+ }
714
+ }, []);
715
+
716
+ const closeToolbar = () => {
717
+ setSelection(null);
718
+ setToolbarPosition(null);
719
+ setShowToolbar(false);
720
+ setIsEditing(false);
721
+ };
722
+
723
+ // Handle selection edit submission
724
+ const handleSelectionEdit = async (instruction) => {
725
+ if (!selection || !onSelectionEdit) return;
726
+
727
+ // Save snapshot before AI edit
728
+ saveSnapshot();
729
+ setIsEditing(true);
730
+
731
+ try {
732
+ const contextBefore = localContent.substring(
733
+ Math.max(0, selection.start - 100),
734
+ selection.start
735
+ );
736
+ const contextAfter = localContent.substring(
737
+ selection.end,
738
+ Math.min(localContent.length, selection.end + 100)
739
+ );
740
+
741
+ const replacement = await onSelectionEdit({
742
+ selectedText: selection.text,
743
+ instruction,
744
+ contextBefore,
745
+ contextAfter
746
+ });
747
+
748
+ if (replacement) {
749
+ const newContent =
750
+ localContent.substring(0, selection.start) +
751
+ replacement +
752
+ localContent.substring(selection.end);
753
+
754
+ setLocalContent(newContent);
755
+ onChange(newContent);
756
+ }
757
+ } catch (error) {
758
+ console.error("[Labs] Selection edit failed:", error);
759
+ } finally {
760
+ setIsEditing(false);
761
+ closeToolbar();
762
+ }
763
+ };
764
+
765
+ // Keyboard shortcuts
766
+ useEffect(() => {
767
+ const handleKeyboard = (e) => {
768
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
769
+ e.preventDefault();
770
+ if (e.shiftKey) {
771
+ handleRedo();
772
+ } else {
773
+ handleUndo();
774
+ }
775
+ }
776
+ if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
777
+ e.preventDefault();
778
+ handleRedo();
779
+ }
780
+ };
781
+
782
+ document.addEventListener('keydown', handleKeyboard);
783
+ return () => document.removeEventListener('keydown', handleKeyboard);
784
+ }, [handleUndo, handleRedo]);
785
+
786
+ // Close toolbar when clicking outside
787
+ useEffect(() => {
788
+ const handleClickOutside = (e) => {
789
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
790
+ closeToolbar();
791
+ }
792
+ };
793
+
794
+ document.addEventListener("mousedown", handleClickOutside);
795
+ return () => document.removeEventListener("mousedown", handleClickOutside);
796
+ }, []);
797
+
798
+ const canUndo = historyIndex > 0;
799
+ const canRedo = historyIndex < history.length - 1;
800
+
801
+ return (
802
+ <div ref={containerRef} className="h-full flex flex-col relative">
803
+ {/* Mode Toggle & History Controls */}
804
+ <div className="flex items-center gap-1 px-4 py-2 border-b border-border bg-background/50">
805
+ <button
806
+ onClick={() => setMode("edit")}
807
+ className={cn(
808
+ "flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
809
+ mode === "edit"
810
+ ? "bg-foreground/10 text-white"
811
+ : "text-muted-foreground hover:text-white hover:bg-foreground/5"
812
+ )}
813
+ >
814
+ <Edit3 size={14} />
815
+ Edit
816
+ </button>
817
+ <button
818
+ onClick={() => { setMode("preview"); closeToolbar(); }}
819
+ className={cn(
820
+ "flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
821
+ mode === "preview"
822
+ ? "bg-foreground/10 text-white"
823
+ : "text-muted-foreground hover:text-white hover:bg-foreground/5"
824
+ )}
825
+ >
826
+ <Eye size={14} />
827
+ Preview
828
+ </button>
829
+
830
+ {/* Spacer */}
831
+ <div className="flex-1" />
832
+
833
+ {/* AI Edit button — always visible, enabled when text selected */}
834
+ {mode === "edit" && (
835
+ <button
836
+ onClick={() => {
837
+ if (selection) setShowToolbar(true);
838
+ }}
839
+ disabled={!selection || isProcessing || isEditing}
840
+ className={cn(
841
+ "flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all mr-1",
842
+ selection && !isProcessing && !isEditing
843
+ ? "bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25 shadow-sm shadow-primary/10"
844
+ : "bg-foreground/5 text-muted-foreground/40 border border-transparent cursor-not-allowed"
845
+ )}
846
+ title={selection ? "Edit selected text with AI" : "Select text to enable AI editing"}
847
+ >
848
+ <Sparkles size={13} />
849
+ <span className="hidden sm:inline">AI Edit</span>
850
+ </button>
851
+ )}
852
+
853
+ {/* Undo/Redo */}
854
+ <HistoryControls
855
+ canUndo={canUndo}
856
+ canRedo={canRedo}
857
+ onUndo={handleUndo}
858
+ onRedo={handleRedo}
859
+ historyCount={history.length}
860
+ />
861
+
862
+ {/* Status */}
863
+ {(isProcessing || isEditing) && (
864
+ <div className="flex items-center gap-2 text-primary text-sm ml-2">
865
+ <motion.div
866
+ animate={{ rotate: 360 }}
867
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
868
+ className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full"
869
+ />
870
+ <span>{isEditing ? "Editing..." : "AI working..."}</span>
871
+ </div>
872
+ )}
873
+ </div>
874
+
875
+ {/* Formatting Toolbar (only in edit mode) */}
876
+ {mode === "edit" && (
877
+ <FormattingToolbar
878
+ onFormat={handleFormat}
879
+ disabled={isProcessing || isEditing}
880
+ />
881
+ )}
882
+
883
+ {/* Editor/Preview Area */}
884
+ <div className="flex-1 overflow-auto custom-scrollbar">
885
+ {mode === "edit" ? (
886
+ <textarea
887
+ ref={textareaRef}
888
+ value={localContent}
889
+ onChange={handleChange}
890
+ onSelect={handleSelect}
891
+ onMouseUp={handleSelect}
892
+ placeholder="Start writing your document here...
893
+
894
+ You can use Markdown formatting:
895
+ # Heading 1
896
+ ## Heading 2
897
+ **bold** and *italic*
898
+ - Bullet lists
899
+ 1. Numbered lists
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",
907
+ "placeholder:text-muted-foreground/50",
908
+ (isProcessing || isEditing) && "opacity-50"
909
+ )}
910
+ spellCheck="true"
911
+ />
912
+ ) : (
913
+ <MarkdownPreview content={localContent} />
914
+ )}
915
+ </div>
916
+
917
+ {/* Selection Toolbar — opens via AI Edit button */}
918
+ <AnimatePresence>
919
+ {showToolbar && selection && mode === "edit" && (
920
+ <SelectionToolbar
921
+ position={toolbarPosition}
922
+ selectedText={selection.text}
923
+ onClose={closeToolbar}
924
+ onSubmit={handleSelectionEdit}
925
+ isProcessing={isEditing}
926
+ />
927
+ )}
928
+ </AnimatePresence>
929
+ </div>
930
+ );
931
+ }
client/src/hooks/useChatSession.js ADDED
@@ -0,0 +1,592 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { createParser } from "eventsource-parser";
3
+
4
+ const MAX_MESSAGES = 20;
5
+ const REQUEST_TIMEOUT_MS = 60000; // 60 second timeout
6
+ const GLOBAL_HISTORY_KEY = "flowise_history_global";
7
+
8
+ const MODES = [
9
+ { id: "chat", label: "Chat Mode" }
10
+ ];
11
+
12
+ const ACTIVITY_LABELS = {
13
+ searching: "Searching",
14
+ reading: "Reading sources",
15
+ reasoning: "Reasoning",
16
+ writing: "Writing answer",
17
+ tool: "Using tool",
18
+ thinking: "Thinking",
19
+ planning: "Planning",
20
+ executing: "Executing"
21
+ };
22
+
23
+ // Global history - all sessions stored together with their modelId
24
+ function loadAllSessions() {
25
+ const raw = localStorage.getItem(GLOBAL_HISTORY_KEY);
26
+ if (!raw) return [];
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ return Array.isArray(parsed) ? parsed : [];
30
+ } catch (error) {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ function trimMessages(messages) {
36
+ if (messages.length <= MAX_MESSAGES) return messages;
37
+ return messages.slice(-MAX_MESSAGES);
38
+ }
39
+
40
+ function normalizeSessionsForStorage(sessions) {
41
+ return sessions.map((session) => ({
42
+ ...session,
43
+ messages: trimMessages(session.messages || [])
44
+ }));
45
+ }
46
+
47
+ function saveAllSessions(sessions) {
48
+ const normalized = normalizeSessionsForStorage(sessions);
49
+ try {
50
+ localStorage.setItem(GLOBAL_HISTORY_KEY, JSON.stringify(normalized));
51
+ } catch (error) {
52
+ const reduced = normalized.map((session) => ({
53
+ ...session,
54
+ messages: session.messages.slice(-10)
55
+ }));
56
+ try {
57
+ localStorage.setItem(GLOBAL_HISTORY_KEY, JSON.stringify(reduced));
58
+ } catch (innerError) {
59
+ localStorage.removeItem(GLOBAL_HISTORY_KEY);
60
+ }
61
+ }
62
+ }
63
+
64
+ // Session now includes modelId to lock it to that model
65
+ function createSession(mode, modelId) {
66
+ return {
67
+ id: crypto.randomUUID(),
68
+ title: "New chat",
69
+ mode,
70
+ modelId, // Lock session to this model
71
+ createdAt: Date.now(),
72
+ messages: []
73
+ };
74
+ }
75
+
76
+ // Convert File object to Flowise upload format (base64 data URI)
77
+ async function fileToFlowise(file) {
78
+ return new Promise((resolve, reject) => {
79
+ const reader = new FileReader();
80
+ reader.onload = () => resolve({
81
+ type: "file",
82
+ name: file.name,
83
+ data: reader.result, // data:mime;base64,...
84
+ mime: file.type || "application/octet-stream"
85
+ });
86
+ reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
87
+ reader.readAsDataURL(file);
88
+ });
89
+ }
90
+
91
+ // Convert array of File objects to Flowise uploads array
92
+ async function filesToFlowise(files) {
93
+ if (!files || files.length === 0) return [];
94
+ const results = await Promise.all(
95
+ files.map(file => fileToFlowise(file).catch(err => {
96
+ console.warn(`Skipping file ${file.name}:`, err.message);
97
+ return null;
98
+ }))
99
+ );
100
+ return results.filter(Boolean);
101
+ }
102
+
103
+ export function useChatSession() {
104
+ const [models, setModels] = useState([]);
105
+ const [selectedModelId, setSelectedModelId] = useState("");
106
+ const [modelsIssues, setModelsIssues] = useState([]);
107
+ const [isModelsLoading, setIsModelsLoading] = useState(true);
108
+ const [modelsError, setModelsError] = useState("");
109
+ const [modelsReloadToken, setModelsReloadToken] = useState(0);
110
+ const [mode, setMode] = useState("chat");
111
+ const [sessions, setSessions] = useState([]);
112
+ const [activeSessionId, setActiveSessionId] = useState("");
113
+ const [message, setMessage] = useState("");
114
+ const [isStreaming, setIsStreaming] = useState(false);
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
+
121
+ // Session is locked when it has messages - disables model switching
122
+ // IMPORTANT: This is computed, not stored, so it updates instantly
123
+ const isSessionLocked = useMemo(() => {
124
+ if (!activeSession) return false;
125
+ return (activeSession.messages?.length ?? 0) > 0;
126
+ }, [activeSession]);
127
+
128
+ useEffect(() => {
129
+ let isMounted = true;
130
+ const controller = new AbortController();
131
+
132
+ async function loadModels() {
133
+ setIsModelsLoading(true);
134
+ setModelsError("");
135
+ try {
136
+ const res = await fetch("/models", { signal: controller.signal });
137
+ const raw = await res.json().catch(() => null);
138
+ if (!res.ok) {
139
+ const message =
140
+ (raw && (raw.error || raw.message)) ||
141
+ `Failed to load models (${res.status})`;
142
+ throw new Error(message);
143
+ }
144
+
145
+ const nextModels = Array.isArray(raw) ? raw : raw?.models;
146
+ const issues = Array.isArray(raw?.issues) ? raw.issues : [];
147
+
148
+ if (!Array.isArray(nextModels)) {
149
+ throw new Error("Invalid models response");
150
+ }
151
+
152
+ const normalized = nextModels
153
+ .filter((item) => item && typeof item === "object")
154
+ .map((item) => ({
155
+ id: String(item.id || ""),
156
+ name: String(item.name || ""),
157
+ features: {
158
+ uploads: Boolean(item?.features?.uploads),
159
+ tts: Boolean(item?.features?.tts),
160
+ stt: Boolean(item?.features?.stt),
161
+ status: String(item?.features?.status || "unknown")
162
+ }
163
+ }))
164
+ .filter((item) => item.id && item.name);
165
+
166
+ if (!isMounted) return;
167
+ setModels(normalized);
168
+ setModelsIssues(issues);
169
+
170
+ setSelectedModelId((prev) => {
171
+ if (prev && normalized.some((m) => m.id === prev)) return prev;
172
+ return normalized[0]?.id || "";
173
+ });
174
+ } catch (error) {
175
+ if (!isMounted) return;
176
+ if (error?.name === "AbortError") return;
177
+ setModels([]);
178
+ setModelsIssues([]);
179
+ setModelsError(error?.message || "Failed to load models");
180
+ setSelectedModelId("");
181
+ } finally {
182
+ if (!isMounted) return;
183
+ setIsModelsLoading(false);
184
+ }
185
+ }
186
+
187
+ loadModels();
188
+ return () => {
189
+ isMounted = false;
190
+ controller.abort();
191
+ };
192
+ }, [modelsReloadToken]);
193
+
194
+ // Load ALL sessions once on mount (global history)
195
+ useEffect(() => {
196
+ if (initialLoadDone.current) return;
197
+ initialLoadDone.current = true;
198
+
199
+ const stored = loadAllSessions();
200
+ setSessions(stored);
201
+ if (stored.length > 0) {
202
+ setActiveSessionId(stored[0].id);
203
+ // If the stored session has a modelId, switch to it
204
+ if (stored[0].modelId) {
205
+ setSelectedModelId(stored[0].modelId);
206
+ }
207
+ }
208
+ }, []);
209
+
210
+ // When no sessions exist and we have a model, create a fresh session
211
+ useEffect(() => {
212
+ if (sessions.length === 0 && selectedModelId && initialLoadDone.current) {
213
+ const fresh = createSession(mode, selectedModelId);
214
+ setSessions([fresh]);
215
+ setActiveSessionId(fresh.id);
216
+ }
217
+ }, [sessions.length, selectedModelId, mode]);
218
+
219
+ useEffect(() => {
220
+ if (activeSession?.mode) {
221
+ setMode(activeSession.mode);
222
+ }
223
+ }, [activeSession?.mode]);
224
+
225
+ // Save sessions globally whenever they change
226
+ useEffect(() => {
227
+ if (sessions.length > 0) {
228
+ saveAllSessions(sessions);
229
+ }
230
+ }, [sessions]);
231
+
232
+ const historyList = useMemo(() => {
233
+ return [...sessions].sort((a, b) => b.createdAt - a.createdAt);
234
+ }, [sessions]);
235
+
236
+ const selectedModel = useMemo(() => {
237
+ return models.find((m) => m.id === selectedModelId) || null;
238
+ }, [models, selectedModelId]);
239
+
240
+ // Handle session selection - auto-switch model to match session
241
+ function handleSelectSession(sessionId) {
242
+ const session = sessions.find(s => s.id === sessionId);
243
+ if (session) {
244
+ setActiveSessionId(sessionId);
245
+ // If the session has a modelId, switch to it
246
+ if (session.modelId) {
247
+ setSelectedModelId(session.modelId);
248
+ }
249
+ }
250
+ }
251
+
252
+ function updateSession(sessionId, updater) {
253
+ setSessions((prev) =>
254
+ prev.map((item) =>
255
+ item.id === sessionId ? updater(item) : item
256
+ )
257
+ );
258
+ }
259
+
260
+ function handleNewChat() {
261
+ const fresh = createSession(mode, selectedModelId);
262
+ setSessions((prev) => [fresh, ...prev]);
263
+ setActiveSessionId(fresh.id);
264
+ setMessage("");
265
+ }
266
+
267
+ function handleClearHistory() {
268
+ const fresh = createSession(mode, selectedModelId);
269
+ setSessions([fresh]);
270
+ setActiveSessionId(fresh.id);
271
+ setMessage("");
272
+ }
273
+
274
+ function handleModeChange(nextMode) {
275
+ setMode(nextMode);
276
+ if (activeSession) {
277
+ updateSession(activeSession.id, (session) => ({
278
+ ...session,
279
+ mode: nextMode
280
+ }));
281
+ }
282
+ }
283
+
284
+ async function handleSend(files = []) {
285
+ if (!message.trim() || !activeSession || isStreaming) return;
286
+ if (message.trim().length > 10000) return;
287
+
288
+ // Convert files to Flowise format
289
+ const uploads = await filesToFlowise(files);
290
+
291
+ const userMessage = {
292
+ id: crypto.randomUUID(),
293
+ role: "user",
294
+ content: message.trim(),
295
+ attachments: uploads.length > 0 ? uploads.map(u => u.name) : undefined
296
+ };
297
+ const assistantMessage = {
298
+ id: crypto.randomUUID(),
299
+ role: "assistant",
300
+ content: "",
301
+ activities: [],
302
+ createdAt: Date.now()
303
+ };
304
+
305
+ if (!selectedModelId) {
306
+ updateSession(activeSession.id, (session) => ({
307
+ ...session,
308
+ mode,
309
+ messages: trimMessages([
310
+ ...session.messages,
311
+ userMessage,
312
+ {
313
+ ...assistantMessage,
314
+ content: "No AI model selected or configured. Please check your .env configuration in the server folder."
315
+ }
316
+ ])
317
+ }));
318
+ setMessage("");
319
+ return;
320
+ }
321
+
322
+ updateSession(activeSession.id, (session) => {
323
+ const updated = {
324
+ ...session,
325
+ mode,
326
+ messages: trimMessages([
327
+ ...session.messages,
328
+ userMessage,
329
+ assistantMessage
330
+ ])
331
+ };
332
+ if (session.title === "New chat") {
333
+ updated.title = userMessage.content.slice(0, 36);
334
+ }
335
+ return updated;
336
+ });
337
+ setMessage("");
338
+ setIsStreaming(true);
339
+
340
+ abortRef.current?.abort();
341
+ const controller = new AbortController();
342
+ abortRef.current = controller;
343
+
344
+ // Clear any existing timeout
345
+ if (timeoutRef.current) {
346
+ clearTimeout(timeoutRef.current);
347
+ }
348
+
349
+ // Set up timeout for the request
350
+ timeoutRef.current = setTimeout(() => {
351
+ if (isStreaming) {
352
+ controller.abort();
353
+ updateSession(activeSession.id, (session) => ({
354
+ ...session,
355
+ messages: session.messages.map((msg) =>
356
+ msg.id === assistantMessage.id && !msg.content
357
+ ? {
358
+ ...msg,
359
+ content: "Request timed out. The AI server may be slow or unavailable. Please check your connection and try again."
360
+ }
361
+ : msg
362
+ )
363
+ }));
364
+ setIsStreaming(false);
365
+ }
366
+ }, REQUEST_TIMEOUT_MS);
367
+
368
+ let hasReceivedData = false;
369
+
370
+ try {
371
+ const response = await fetch("/chat", {
372
+ method: "POST",
373
+ headers: { "Content-Type": "application/json" },
374
+ body: JSON.stringify({
375
+ message: userMessage.content,
376
+ modelId: selectedModelId,
377
+ mode,
378
+ sessionId: activeSession.id,
379
+ uploads: uploads.length > 0 ? uploads : undefined
380
+ }),
381
+ signal: controller.signal
382
+ });
383
+
384
+ if (!response.ok) {
385
+ const errorText = await response.text().catch(() => "");
386
+ throw new Error(errorText || `Request failed (${response.status})`);
387
+ }
388
+
389
+ if (!response.body) {
390
+ throw new Error("No response received from server");
391
+ }
392
+
393
+ const reader = response.body.getReader();
394
+ const decoder = new TextDecoder();
395
+
396
+ const parser = createParser((event) => {
397
+ if (event.type !== "event") return;
398
+ const eventName = event.event || "";
399
+ const payload = event.data || "";
400
+ let parsed = null;
401
+ try {
402
+ parsed = JSON.parse(payload);
403
+ } catch (error) {
404
+ parsed = { text: payload };
405
+ }
406
+
407
+ if (eventName === "token") {
408
+ hasReceivedData = true;
409
+ // Reset timeout on receiving data
410
+ if (timeoutRef.current) {
411
+ clearTimeout(timeoutRef.current);
412
+ timeoutRef.current = setTimeout(() => {
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") {
427
+ const activityKey = parsed.tool
428
+ ? `tool:${parsed.tool}`
429
+ : parsed.state;
430
+ updateSession(activeSession.id, (session) => ({
431
+ ...session,
432
+ messages: session.messages.map((msg) =>
433
+ msg.id === assistantMessage.id
434
+ ? {
435
+ ...msg,
436
+ activities: Array.from(
437
+ new Set([...(msg.activities || []), activityKey])
438
+ )
439
+ }
440
+ : msg
441
+ )
442
+ }));
443
+ }
444
+
445
+ if (eventName === "agentStep") {
446
+ updateSession(activeSession.id, (session) => ({
447
+ ...session,
448
+ messages: session.messages.map((msg) =>
449
+ msg.id === assistantMessage.id
450
+ ? {
451
+ ...msg,
452
+ agentSteps: [...(msg.agentSteps || []), parsed]
453
+ }
454
+ : msg
455
+ )
456
+ }));
457
+ }
458
+
459
+ if (eventName === "error") {
460
+ updateSession(activeSession.id, (session) => ({
461
+ ...session,
462
+ messages: session.messages.map((msg) =>
463
+ msg.id === assistantMessage.id
464
+ ? {
465
+ ...msg,
466
+ content:
467
+ parsed.message || "Something went wrong. Please try again."
468
+ }
469
+ : msg
470
+ )
471
+ }));
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
499
+ if (timeoutRef.current) {
500
+ clearTimeout(timeoutRef.current);
501
+ }
502
+
503
+ if (error?.name === "AbortError") {
504
+ updateSession(activeSession.id, (session) => ({
505
+ ...session,
506
+ messages: session.messages.map((msg) =>
507
+ msg.id === assistantMessage.id && !msg.content
508
+ ? {
509
+ ...msg,
510
+ content: hasReceivedData
511
+ ? "Response was interrupted. The partial response has been saved."
512
+ : "Connection was interrupted. Please check your internet connection and try again."
513
+ }
514
+ : msg
515
+ )
516
+ }));
517
+ setIsStreaming(false);
518
+ return;
519
+ }
520
+
521
+ // Network error handling
522
+ const isNetworkError = error?.message?.includes("fetch") ||
523
+ error?.message?.includes("network") ||
524
+ error?.message?.includes("Failed to fetch") ||
525
+ !navigator.onLine;
526
+
527
+ updateSession(activeSession.id, (session) => ({
528
+ ...session,
529
+ messages: session.messages.map((msg) =>
530
+ msg.id === assistantMessage.id
531
+ ? {
532
+ ...msg,
533
+ content: isNetworkError
534
+ ? "Unable to connect to the server. Please check your internet connection and try again."
535
+ : error?.message || "Something went wrong. Please try again."
536
+ }
537
+ : msg
538
+ )
539
+ }));
540
+ } finally {
541
+ // Clear timeout on completion
542
+ if (timeoutRef.current) {
543
+ clearTimeout(timeoutRef.current);
544
+ }
545
+ setIsStreaming(false);
546
+ }
547
+ }
548
+
549
+ return {
550
+ models,
551
+ selectedModelId,
552
+ setSelectedModelId,
553
+ modelsIssues,
554
+ isModelsLoading,
555
+ modelsError,
556
+ reloadModels: () => setModelsReloadToken((prev) => prev + 1),
557
+ selectedModel,
558
+ mode,
559
+ setMode,
560
+ sessions,
561
+ activeSession,
562
+ activeSessionId,
563
+ setActiveSessionId,
564
+ handleSelectSession, // Use this instead of setActiveSessionId for history clicks
565
+ isSessionLocked, // True when viewing a session that's locked to a specific model
566
+ message,
567
+ setMessage,
568
+ isStreaming,
569
+ handleNewChat,
570
+ handleClearHistory,
571
+ handleModeChange,
572
+ handleSend,
573
+ historyList,
574
+ MODES,
575
+ ACTIVITY_LABELS
576
+ };
577
+ }
578
+
579
+ export async function query(data) {
580
+ const response = await fetch("/predict", {
581
+ method: "POST",
582
+ headers: { "Content-Type": "application/json" },
583
+ body: JSON.stringify(data || {})
584
+ });
585
+ const isJson = (response.headers.get("content-type") || "").includes("application/json");
586
+ if (!response.ok) {
587
+ const text = isJson ? await response.json().catch(() => null) : await response.text().catch(() => "");
588
+ const message = (text && (text.error || text.message)) || (typeof text === "string" ? text : "");
589
+ throw new Error(message || `Request failed (${response.status})`);
590
+ }
591
+ return isJson ? await response.json() : {};
592
+ }
client/src/hooks/useLabsProjects.js ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState, useCallback, useRef } from "react";
2
+ import { stripMetadata } from "../utils/contentUtils.js";
3
+
4
+ // Storage key
5
+ const STORAGE_KEY = "labs_projects_global";
6
+
7
+ /**
8
+ * Load projects from localStorage
9
+ */
10
+ function loadProjects() {
11
+ try {
12
+ const raw = localStorage.getItem(STORAGE_KEY);
13
+ if (!raw) {
14
+ console.log("[Labs] No saved projects");
15
+ return [];
16
+ }
17
+ const parsed = JSON.parse(raw);
18
+ if (!Array.isArray(parsed)) {
19
+ console.warn("[Labs] Invalid data format");
20
+ return [];
21
+ }
22
+ console.log(`[Labs] Loaded ${parsed.length} projects`);
23
+ return parsed;
24
+ } catch (e) {
25
+ console.error("[Labs] Load error:", e);
26
+ return [];
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Save projects to localStorage
32
+ */
33
+ function saveProjects(projects) {
34
+ try {
35
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
36
+ console.log(`[Labs] Saved ${projects.length} projects`);
37
+ } catch (e) {
38
+ console.error("[Labs] Save error:", e);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Hook for managing Labs projects with localStorage persistence.
44
+ */
45
+ export function useLabsProjects() {
46
+ // Initialize state from localStorage immediately
47
+ const [projects, setProjects] = useState(() => {
48
+ const loaded = loadProjects();
49
+ return loaded;
50
+ });
51
+
52
+ const [activeProjectId, setActiveProjectIdState] = useState(() => {
53
+ const loaded = loadProjects();
54
+ return loaded.length > 0 ? loaded[0].id : "";
55
+ });
56
+
57
+ const [isProcessing, setIsProcessing] = useState(false);
58
+
59
+ // Get active project
60
+ const activeProject = useMemo(() => {
61
+ return projects.find(p => p.id === activeProjectId) || null;
62
+ }, [projects, activeProjectId]);
63
+
64
+ // Project is locked when it has content
65
+ const isProjectLocked = useMemo(() => {
66
+ if (!activeProject) return false;
67
+ return (activeProject.document?.trim().length ?? 0) > 0;
68
+ }, [activeProject]);
69
+
70
+ // Fetch Labs model name from server
71
+ const [labsModelName, setLabsModelName] = useState("");
72
+ useEffect(() => {
73
+ fetch("/labs-model")
74
+ .then(r => r.json())
75
+ .then(data => {
76
+ if (data.model?.name) setLabsModelName(data.model.name);
77
+ })
78
+ .catch(() => { });
79
+ }, []);
80
+
81
+ // Save to localStorage whenever projects change
82
+ useEffect(() => {
83
+ if (projects.length > 0) {
84
+ saveProjects(projects);
85
+ }
86
+ }, [projects]);
87
+
88
+ // Create a fresh project if none exist
89
+ useEffect(() => {
90
+ if (projects.length === 0) {
91
+ console.log("[Labs] Creating initial project");
92
+ const newProject = {
93
+ id: crypto.randomUUID(),
94
+ sessionId: crypto.randomUUID(),
95
+ name: "Untitled Project",
96
+ createdAt: Date.now(),
97
+ updatedAt: Date.now(),
98
+ document: ""
99
+ };
100
+ setProjects([newProject]);
101
+ setActiveProjectIdState(newProject.id);
102
+ }
103
+ }, [projects.length]);
104
+
105
+ // Sorted list (newest first)
106
+ const projectList = useMemo(() => {
107
+ return [...projects].sort((a, b) => b.updatedAt - a.updatedAt);
108
+ }, [projects]);
109
+
110
+ // Get model name (always returns the Labs model name)
111
+ const getModelName = useCallback(() => {
112
+ return labsModelName || "Labs Model";
113
+ }, [labsModelName]);
114
+
115
+ /**
116
+ * Set active project
117
+ */
118
+ const setActiveProjectId = useCallback((projectId) => {
119
+ const project = projects.find(p => p.id === projectId);
120
+ if (project) {
121
+ setActiveProjectIdState(projectId);
122
+ console.log(`[Labs] Activated: ${project.name}`);
123
+ }
124
+ }, [projects]);
125
+
126
+ /**
127
+ * Create new project
128
+ */
129
+ const handleNewProject = useCallback((name = "Untitled Project", document = "") => {
130
+ const newProject = {
131
+ id: crypto.randomUUID(),
132
+ sessionId: crypto.randomUUID(),
133
+ name,
134
+ createdAt: Date.now(),
135
+ updatedAt: Date.now(),
136
+ document
137
+ };
138
+ setProjects(prev => [newProject, ...prev]);
139
+ setActiveProjectIdState(newProject.id);
140
+ console.log(`[Labs] Created: ${name}`);
141
+ return newProject;
142
+ }, []);
143
+
144
+ /**
145
+ * Import document
146
+ */
147
+ const handleImportDocument = useCallback(async (file) => {
148
+ if (!file) return null;
149
+ const name = file.name.replace(/\.(txt|md|docx)$/i, "") || "Imported";
150
+ let content = "";
151
+ try {
152
+ if (file.name.endsWith(".docx")) {
153
+ const mammoth = await import("mammoth");
154
+ const buffer = await file.arrayBuffer();
155
+ const result = await mammoth.extractRawText({ arrayBuffer: buffer });
156
+ content = result.value || "";
157
+ } else {
158
+ content = await file.text();
159
+ }
160
+ return handleNewProject(name, content);
161
+ } catch (e) {
162
+ console.error("[Labs] Import error:", e);
163
+ throw e;
164
+ }
165
+ }, [handleNewProject]);
166
+
167
+ /**
168
+ * Delete project
169
+ */
170
+ const handleDeleteProject = useCallback((projectId) => {
171
+ setProjects(prev => {
172
+ const filtered = prev.filter(p => p.id !== projectId);
173
+ // Switch to another project if we deleted the active one
174
+ if (projectId === activeProjectId && filtered.length > 0) {
175
+ setActiveProjectIdState(filtered[0].id);
176
+ }
177
+ // If no projects left, clear storage
178
+ if (filtered.length === 0) {
179
+ localStorage.removeItem(STORAGE_KEY);
180
+ }
181
+ return filtered;
182
+ });
183
+ }, [activeProjectId]);
184
+
185
+ /**
186
+ * Rename project
187
+ */
188
+ const handleRenameProject = useCallback((projectId, newName) => {
189
+ setProjects(prev => prev.map(p =>
190
+ p.id === projectId ? { ...p, name: newName, updatedAt: Date.now() } : p
191
+ ));
192
+ }, []);
193
+
194
+ /**
195
+ * Update document
196
+ */
197
+ const handleUpdateDocument = useCallback((newDocument) => {
198
+ if (!activeProjectId) return;
199
+ setProjects(prev => prev.map(p =>
200
+ p.id === activeProjectId
201
+ ? { ...p, document: newDocument, updatedAt: Date.now() }
202
+ : p
203
+ ));
204
+ }, [activeProjectId]);
205
+
206
+ /**
207
+ * AI Edit
208
+ */
209
+ const handleAIEdit = useCallback(async (instruction) => {
210
+ if (!activeProject) return null;
211
+ setIsProcessing(true);
212
+ console.log(`[Labs] AI Edit with session: ${activeProject.sessionId}`);
213
+
214
+ try {
215
+ const response = await fetch("/labs-edit", {
216
+ method: "POST",
217
+ headers: { "Content-Type": "application/json" },
218
+ body: JSON.stringify({
219
+ document: activeProject.document,
220
+ instruction,
221
+ sessionId: activeProject.sessionId
222
+ })
223
+ });
224
+
225
+ if (!response.ok) {
226
+ const err = await response.text().catch(() => "");
227
+ throw new Error(err || `Request failed (${response.status})`);
228
+ }
229
+
230
+ if (!response.body) throw new Error("No response body");
231
+
232
+ const reader = response.body.getReader();
233
+ const decoder = new TextDecoder();
234
+ let fullContent = "";
235
+ let buffer = "";
236
+
237
+ while (true) {
238
+ const { value, done } = await reader.read();
239
+ if (done) break;
240
+ buffer += decoder.decode(value, { stream: true });
241
+ const lines = buffer.split("\n");
242
+ buffer = lines.pop() || "";
243
+ for (const line of lines) {
244
+ if (line.startsWith("data: ")) {
245
+ try {
246
+ const data = JSON.parse(line.slice(6));
247
+ if (data.text) fullContent += data.text;
248
+ } catch {
249
+ fullContent += line.slice(6);
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ if (fullContent) {
256
+ const cleaned = stripMetadata(fullContent);
257
+ handleUpdateDocument(cleaned);
258
+ }
259
+ return fullContent;
260
+ } catch (e) {
261
+ console.error("[Labs] AI edit error:", e);
262
+ throw e;
263
+ } finally {
264
+ setIsProcessing(false);
265
+ }
266
+ }, [activeProject, handleUpdateDocument]);
267
+
268
+ /**
269
+ * Force sync to storage
270
+ */
271
+ const forceSync = useCallback(() => {
272
+ saveProjects(projects);
273
+ }, [projects]);
274
+
275
+ return {
276
+ projects: projectList,
277
+ activeProject,
278
+ activeProjectId,
279
+ setActiveProjectId,
280
+ isProjectLocked,
281
+ isProcessing,
282
+ handleNewProject,
283
+ handleImportDocument,
284
+ handleDeleteProject,
285
+ handleRenameProject,
286
+ handleUpdateDocument,
287
+ handleAIEdit,
288
+ forceSync,
289
+ getModelName,
290
+ labsModelName
291
+ };
292
+ }
client/src/index.css ADDED
@@ -0,0 +1,913 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ @layer base {
8
+ :root {
9
+ --background: 0 0% 6%;
10
+ --foreground: 0 0% 100%;
11
+ --card: 0 0% 10%;
12
+ --card-foreground: 0 0% 100%;
13
+ --popover: 0 0% 8%;
14
+ --popover-foreground: 0 0% 100%;
15
+ --primary: 187.9412 85.7143% 53.3333%;
16
+ --primary-foreground: 0 0% 0%;
17
+ --secondary: 0 0% 14%;
18
+ --secondary-foreground: 0 0% 100%;
19
+ --muted: 0 0% 12%;
20
+ --muted-foreground: 0 0% 65%;
21
+ --accent: 0 0% 12%;
22
+ --accent-foreground: 0 0% 100%;
23
+ --destructive: 0 62.8% 30.6%;
24
+ --destructive-foreground: 0 0% 98%;
25
+ --border: 0 0% 18%;
26
+ --input: 0 0% 16%;
27
+ --ring: 187.9412 85.7143% 53.3333%;
28
+ --input-surface: #1A1A1A;
29
+ --input-surface-focus: #1E1E1E;
30
+ --input-border: #2A2A2A;
31
+ --input-border-focus: #3A3A3A;
32
+ --placeholder-color: #6B6B6B;
33
+ --scrollbar-thumb: rgba(255, 255, 255, 0.12);
34
+ --scrollbar-thumb-hover: rgba(255, 255, 255, 0.18);
35
+ --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
36
+ }
37
+
38
+ .light {
39
+ /* ChatGPT-Style Light Mode with Cyan Primary */
40
+ --background: 0 0% 100%;
41
+ --foreground: 220 14% 10%;
42
+ --card: 0 0% 100%;
43
+ --card-foreground: 220 14% 10%;
44
+ --popover: 210 20% 97%;
45
+ --popover-foreground: 220 14% 10%;
46
+ /* Cyan primary - same hue as dark mode */
47
+ --primary: 187.9412 85.7143% 43%;
48
+ --primary-foreground: 0 0% 100%;
49
+ --secondary: 210 20% 96%;
50
+ --secondary-foreground: 220 14% 10%;
51
+ --muted: 210 20% 96%;
52
+ --muted-foreground: 220 9% 40%;
53
+ --accent: 210 20% 93%;
54
+ --accent-foreground: 220 14% 10%;
55
+ --destructive: 0 84.2% 60.2%;
56
+ --destructive-foreground: 0 0% 100%;
57
+ --border: 220 13% 88%;
58
+ --input: 210 20% 97%;
59
+ --ring: 187.9412 85.7143% 43%;
60
+ --input-surface: #FFFFFF;
61
+ --input-surface-focus: #F8FAFC;
62
+ --input-border: #E5E7EB;
63
+ --input-border-focus: #22D3EE;
64
+ --placeholder-color: #9CA3AF;
65
+ --scrollbar-thumb: rgba(0, 0, 0, 0.12);
66
+ --scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
67
+
68
+ /* Light mode specific colors for components */
69
+ --sidebar-bg: 210 20% 97%;
70
+ --sidebar-border: 220 13% 90%;
71
+ --chat-user-bubble: 187.9412 85.7143% 43%;
72
+ --chat-user-text: 0 0% 100%;
73
+ --message-text: 220 14% 15%;
74
+ --heading-text: 220 14% 10%;
75
+ --link-color: 187.9412 85.7143% 35%;
76
+ }
77
+ }
78
+
79
+ @layer base {
80
+ * {
81
+ @apply border-border;
82
+ }
83
+
84
+ html,
85
+ body {
86
+ overflow-x: hidden;
87
+ width: 100%;
88
+ position: relative;
89
+ font-family: var(--font-sans);
90
+ -webkit-font-smoothing: antialiased;
91
+ -moz-osx-font-smoothing: grayscale;
92
+ @apply bg-background text-foreground antialiased;
93
+ }
94
+ }
95
+
96
+ @layer utilities {
97
+ .glass {
98
+ @apply bg-background/75 backdrop-blur-xl border border-border;
99
+ }
100
+
101
+ .glass-light {
102
+ @apply bg-background/75 backdrop-blur-xl border border-border;
103
+ }
104
+ }
105
+
106
+ /* Refined Input Container - Floating, Calm, Precise */
107
+ .refined-input-container {
108
+ position: relative;
109
+ max-width: 100%;
110
+ margin: 0 auto;
111
+ padding: 0 8px;
112
+ }
113
+
114
+ .refined-input-container>div {
115
+ background: rgba(26, 26, 26, 0.6);
116
+ backdrop-filter: blur(8px);
117
+ border: 1px solid rgba(255, 255, 255, 0.06);
118
+ border-radius: 14px;
119
+ transition: all 180ms cubic-bezier(0.4, 0, 0.2, 1);
120
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
121
+ }
122
+
123
+ .refined-input-container>div:focus-within {
124
+ background: rgba(30, 30, 30, 0.7);
125
+ border-color: rgba(255, 255, 255, 0.10);
126
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
127
+ }
128
+
129
+ /* Light mode refinements */
130
+ .light .refined-input-container>div {
131
+ background: rgba(245, 245, 245, 0.8);
132
+ border-color: rgba(0, 0, 0, 0.08);
133
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
134
+ }
135
+
136
+ .light .refined-input-container>div:focus-within {
137
+ background: rgba(240, 240, 240, 0.9);
138
+ border-color: rgba(0, 0, 0, 0.12);
139
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
140
+ }
141
+
142
+ /* Floating Input Bar - Gemini Style */
143
+ .floating-input-bar {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 4px;
147
+ background: #2F2F2F;
148
+ border-radius: 28px;
149
+ padding: 4px 8px 4px 4px;
150
+ min-height: 48px;
151
+ max-width: 100%;
152
+ transition: all 150ms ease;
153
+ }
154
+
155
+ .floating-input-bar:focus-within {
156
+ background: #363636;
157
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
158
+ }
159
+
160
+ .light .floating-input-bar {
161
+ background: #FFFFFF;
162
+ border: 1px solid #E5E7EB;
163
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
164
+ }
165
+
166
+ .light .floating-input-bar:focus-within {
167
+ background: #FFFFFF;
168
+ border-color: #22D3EE;
169
+ box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.15), 0 1px 3px rgba(0, 0, 0, 0.06);
170
+ }
171
+
172
+
173
+ /* Custom Scrollbar */
174
+ ::selection {
175
+ background: rgba(34, 211, 238, 0.18);
176
+ color: hsl(var(--foreground));
177
+ }
178
+
179
+ ::-moz-selection {
180
+ background: rgba(34, 211, 238, 0.18);
181
+ color: hsl(var(--foreground));
182
+ }
183
+
184
+ ::-webkit-scrollbar {
185
+ width: 6px;
186
+ height: 6px;
187
+ }
188
+
189
+ ::-webkit-scrollbar-track {
190
+ background: transparent;
191
+ }
192
+
193
+ ::-webkit-scrollbar-thumb {
194
+ background-color: transparent;
195
+ border-radius: 9999px;
196
+ transition: background-color 150ms ease;
197
+ }
198
+
199
+ .custom-scrollbar::-webkit-scrollbar-thumb,
200
+ .custom-scrollbar.scrolling::-webkit-scrollbar-thumb {
201
+ background-color: var(--scrollbar-thumb);
202
+ }
203
+
204
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover,
205
+ .custom-scrollbar.scrolling::-webkit-scrollbar-thumb:hover {
206
+ background-color: var(--scrollbar-thumb-hover);
207
+ }
208
+
209
+ /* Markdown Content Styling */
210
+ .markdown-content {
211
+ line-height: 1.7;
212
+ word-wrap: break-word;
213
+ overflow-wrap: break-word;
214
+ }
215
+
216
+ .markdown-content>*:first-child {
217
+ margin-top: 0;
218
+ }
219
+
220
+ .markdown-content>*:last-child {
221
+ margin-bottom: 0;
222
+ }
223
+
224
+ .markdown-content p {
225
+ margin: 0.6em 0;
226
+ }
227
+
228
+ /* Ordered list counter styling */
229
+ .markdown-content ol {
230
+ counter-reset: list-item;
231
+ }
232
+
233
+ .markdown-content ol>li {
234
+ counter-increment: list-item;
235
+ }
236
+
237
+ .markdown-content ol>li>span:first-child {
238
+ content: counter(list-item) ".";
239
+ }
240
+
241
+ /* Code block syntax highlighting enhancement */
242
+ .markdown-content pre {
243
+ max-width: 100%;
244
+ overflow-x: auto;
245
+ }
246
+
247
+ .markdown-content pre code {
248
+ display: block;
249
+ padding: 0;
250
+ background: transparent;
251
+ color: #E2E8F0;
252
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
253
+ font-size: 13px;
254
+ line-height: 1.6;
255
+ }
256
+
257
+ /* Table row striping */
258
+ .markdown-content table {
259
+ font-size: 14px;
260
+ }
261
+
262
+ .markdown-content table tbody tr:nth-child(even) {
263
+ background-color: hsla(var(--foreground) / 0.02);
264
+ }
265
+
266
+ /* Smooth transitions for interactive elements */
267
+ .markdown-content a,
268
+ .markdown-content button,
269
+ .markdown-content tr {
270
+ transition: all 0.15s ease;
271
+ }
272
+
273
+ /* Image styling in markdown */
274
+ .markdown-content img {
275
+ @apply rounded-lg my-4 max-w-full h-auto border border-border;
276
+ }
277
+
278
+ /* Definition list styling */
279
+ .markdown-content dl {
280
+ @apply my-4;
281
+ }
282
+
283
+ .markdown-content dt {
284
+ @apply font-semibold text-foreground mt-2;
285
+ }
286
+
287
+ .markdown-content dd {
288
+ @apply ml-4 text-foreground/80;
289
+ }
290
+
291
+ /* Text Fade-in Animation */
292
+ @keyframes fadeInText {
293
+ from {
294
+ opacity: 0;
295
+ transform: translateY(2px);
296
+ }
297
+
298
+ to {
299
+ opacity: 1;
300
+ transform: translateY(0);
301
+ }
302
+ }
303
+
304
+ .fade-in-text>* {
305
+ animation: fadeInText 0.3s ease-out forwards;
306
+ }
307
+
308
+ .fade-in-text p,
309
+ .fade-in-text li,
310
+ .fade-in-text table {
311
+ animation: fadeInText 0.3s ease-out forwards;
312
+ }
313
+
314
+ @keyframes thinkingDotPulse {
315
+
316
+ 0%,
317
+ 80%,
318
+ 100% {
319
+ opacity: 0.25;
320
+ }
321
+
322
+ 40% {
323
+ opacity: 1;
324
+ }
325
+ }
326
+
327
+ .thinking-dot {
328
+ animation: thinkingDotPulse 1.4s infinite;
329
+ }
330
+
331
+ /* ── Activity Panel (Gemini/Perplexity-style) ── */
332
+ .activity-panel {
333
+ border: 1px solid hsl(var(--border));
334
+ border-radius: 12px;
335
+ background: hsl(var(--foreground) / 0.03);
336
+ overflow: hidden;
337
+ }
338
+
339
+ .activity-panel-header {
340
+ display: flex;
341
+ align-items: center;
342
+ justify-content: space-between;
343
+ width: 100%;
344
+ padding: 8px 12px;
345
+ font-size: 12px;
346
+ font-weight: 500;
347
+ color: hsl(var(--muted-foreground));
348
+ cursor: pointer;
349
+ transition: background 150ms ease;
350
+ border: none;
351
+ background: none;
352
+ text-align: left;
353
+ }
354
+
355
+ .activity-panel-header:hover {
356
+ background: hsl(var(--foreground) / 0.04);
357
+ }
358
+
359
+ .activity-panel-body {
360
+ display: flex;
361
+ flex-direction: column;
362
+ gap: 2px;
363
+ padding: 0 12px 10px;
364
+ }
365
+
366
+ .activity-step-row {
367
+ display: flex;
368
+ align-items: center;
369
+ gap: 8px;
370
+ padding: 4px 0;
371
+ font-size: 12px;
372
+ color: hsl(var(--muted-foreground));
373
+ line-height: 1.4;
374
+ }
375
+
376
+ .activity-step-icon {
377
+ flex-shrink: 0;
378
+ font-size: 12px;
379
+ width: 18px;
380
+ text-align: center;
381
+ }
382
+
383
+ .activity-step-sources {
384
+ display: flex;
385
+ flex-wrap: wrap;
386
+ gap: 6px;
387
+ padding: 4px 0 4px 26px;
388
+ }
389
+
390
+ .activity-source-chip {
391
+ display: inline-flex;
392
+ align-items: center;
393
+ gap: 5px;
394
+ padding: 3px 10px 3px 7px;
395
+ border-radius: 999px;
396
+ font-size: 11px;
397
+ font-weight: 500;
398
+ max-width: 220px;
399
+ border: 1px solid hsl(var(--border));
400
+ background: hsl(var(--foreground) / 0.05);
401
+ color: hsl(var(--muted-foreground));
402
+ text-decoration: none;
403
+ transition: background 150ms ease, border-color 150ms ease;
404
+ }
405
+
406
+ .activity-source-chip:hover {
407
+ background: hsl(var(--foreground) / 0.08);
408
+ border-color: hsl(var(--foreground) / 0.15);
409
+ color: hsl(var(--foreground) / 0.9);
410
+ }
411
+
412
+ /* ── Selection Toolbar (Labs AI Edit) ── */
413
+ .selection-toolbar-sheet {
414
+ background: hsl(var(--card));
415
+ border-top: 1px solid hsl(var(--border));
416
+ border-radius: 20px 20px 0 0;
417
+ padding-bottom: env(safe-area-inset-bottom, 8px);
418
+ box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.5);
419
+ }
420
+
421
+ .selection-toolbar-popover {
422
+ background: hsl(var(--card));
423
+ border: 1px solid hsl(var(--border));
424
+ border-radius: 16px;
425
+ box-shadow:
426
+ 0 8px 32px rgba(0, 0, 0, 0.4),
427
+ 0 0 0 1px hsl(var(--border));
428
+ min-width: 320px;
429
+ max-width: 420px;
430
+ }
431
+
432
+ .selection-toolbar-inner {
433
+ padding: 12px 16px 14px;
434
+ }
435
+
436
+ .chat-input {
437
+ font-family: inherit;
438
+ }
439
+
440
+ .chat-input::placeholder {
441
+ color: var(--placeholder-color);
442
+ opacity: 1;
443
+ transition: opacity 180ms ease-out;
444
+ }
445
+
446
+ .chat-input:focus::placeholder {
447
+ opacity: 0.4;
448
+ }
449
+
450
+ /* Mobile optimizations */
451
+ @media (max-width: 768px) {
452
+
453
+ /* Fix mobile viewport height issues - account for browser UI */
454
+ html,
455
+ body {
456
+ height: 100vh;
457
+ height: 100dvh;
458
+ overflow: hidden;
459
+ overscroll-behavior: none;
460
+ -webkit-overflow-scrolling: touch;
461
+ }
462
+
463
+ #root {
464
+ height: 100vh;
465
+ height: 100dvh;
466
+ overflow: hidden;
467
+ display: flex;
468
+ flex-direction: column;
469
+ }
470
+
471
+ /* Use JS-set custom property as ultimate fallback */
472
+ html.has-app-height,
473
+ html.has-app-height body {
474
+ height: var(--app-height, 100dvh);
475
+ }
476
+
477
+ html.has-app-height #root {
478
+ height: var(--app-height, 100dvh);
479
+ }
480
+
481
+ /* Safe area padding for devices with notches/navigation bars */
482
+ .floating-input-bar {
483
+ position: relative;
484
+ z-index: 10;
485
+ }
486
+
487
+ /* Ensure main content doesn't overflow */
488
+ main {
489
+ flex: 1;
490
+ min-height: 0;
491
+ overflow: hidden;
492
+ }
493
+
494
+ .markdown-content pre {
495
+ font-size: 12px;
496
+ padding: 12px;
497
+ max-width: 100%;
498
+ overflow-x: auto;
499
+ }
500
+
501
+ .markdown-content h1 {
502
+ font-size: 1.25rem;
503
+ }
504
+
505
+ .markdown-content h2 {
506
+ font-size: 1.125rem;
507
+ }
508
+
509
+ .markdown-content h3 {
510
+ font-size: 1rem;
511
+ }
512
+
513
+ .markdown-content table {
514
+ font-size: 12px;
515
+ max-width: 100%;
516
+ table-layout: auto;
517
+ width: 100%;
518
+ }
519
+
520
+ .markdown-content table th,
521
+ .markdown-content table td {
522
+ padding: 8px 10px;
523
+ white-space: nowrap;
524
+ }
525
+
526
+ .refined-input-container {
527
+ padding: 0 4px;
528
+ padding-bottom: env(safe-area-inset-bottom, 8px);
529
+ }
530
+
531
+ /* Fix text overflow/cutting issues */
532
+ .chat-input,
533
+ textarea,
534
+ input[type="text"] {
535
+ max-width: 100%;
536
+ box-sizing: border-box;
537
+ word-wrap: break-word;
538
+ overflow-wrap: break-word;
539
+ }
540
+
541
+ /* Prevent horizontal scroll */
542
+ .markdown-content {
543
+ max-width: 100%;
544
+ overflow-x: hidden;
545
+ word-wrap: break-word;
546
+ overflow-wrap: break-word;
547
+ }
548
+
549
+ .markdown-content p,
550
+ .markdown-content li {
551
+ word-wrap: break-word;
552
+ overflow-wrap: break-word;
553
+ }
554
+ }
555
+
556
+ /* ========================================
557
+ LIGHT MODE COMPREHENSIVE OVERRIDES
558
+ ChatGPT-Style Light Theme with Cyan Accents
559
+ ======================================== */
560
+
561
+ /* Sidebar styling for light mode */
562
+ .light aside,
563
+ .light [class*="bg-popover"] {
564
+ background-color: hsl(210, 20%, 97%) !important;
565
+ border-color: hsl(220, 13%, 90%) !important;
566
+ }
567
+
568
+ /* Light mode sidebar border */
569
+ .light .border-\[rgba\(255\,255\,255\,0\.04\)\] {
570
+ border-color: hsl(220, 13%, 90%) !important;
571
+ }
572
+
573
+ /* Chat area and cards in light mode */
574
+ .light .bg-card,
575
+ .light [class*="bg-card"] {
576
+ background-color: #FFFFFF !important;
577
+ }
578
+
579
+ /* User message bubble - cyan themed */
580
+ .light .bg-\[\#2F2F2F\] {
581
+ background-color: #0891B2 !important;
582
+ color: #FFFFFF !important;
583
+ }
584
+
585
+ /* History item selected state */
586
+ .light .bg-\[\#202338\] {
587
+ background-color: rgba(8, 145, 178, 0.12) !important;
588
+ }
589
+
590
+ /* History item hover state */
591
+ .light .hover\:bg-\[\#1A1D29\]:hover {
592
+ background-color: rgba(0, 0, 0, 0.04) !important;
593
+ }
594
+
595
+ /* Cyan accent indicator line */
596
+ .light .bg-\[\#22D3EE\] {
597
+ background-color: #0891B2 !important;
598
+ }
599
+
600
+ /* Text colors - ensure proper contrast */
601
+ .light .text-white {
602
+ color: hsl(220, 14%, 10%) !important;
603
+ }
604
+
605
+ .light .text-white\/90,
606
+ .light .text-white\/95,
607
+ .light .text-white\/80 {
608
+ color: hsl(220, 14%, 15%) !important;
609
+ }
610
+
611
+ /* Specific overrides for inputs where white text is needed on colored backgrounds */
612
+ .light .bg-\[\#2F2F2F\] .text-white,
613
+ .light .bg-\[\#0891B2\] .text-white,
614
+ .light .bg-primary .text-white,
615
+ .light [class*="bg-primary"] .text-white,
616
+ .light .floating-input-bar .text-white {
617
+ color: #FFFFFF !important;
618
+ }
619
+
620
+ /* Keep text input dark on light background - with high specificity */
621
+ .light .floating-input-bar textarea,
622
+ .light .floating-input-bar input,
623
+ .light .floating-input-bar textarea.text-white,
624
+ .light .floating-input-bar input.text-white {
625
+ color: hsl(220, 14%, 10%) !important;
626
+ caret-color: hsl(220, 14%, 10%) !important;
627
+ }
628
+
629
+ /* Ensure placeholder is visible but lighter */
630
+ .light .floating-input-bar textarea::placeholder,
631
+ .light .floating-input-bar input::placeholder {
632
+ color: hsl(220, 9%, 50%) !important;
633
+ opacity: 1 !important;
634
+ }
635
+
636
+ /* All input fields and textareas in light mode */
637
+ .light input[type="text"],
638
+ .light input[type="text"].text-white,
639
+ .light textarea,
640
+ .light textarea.text-white {
641
+ color: hsl(220, 14%, 10%) !important;
642
+ caret-color: hsl(220, 14%, 10%) !important;
643
+ }
644
+
645
+ /* Labs editor textarea specifically */
646
+ .light textarea.font-mono,
647
+ .light textarea.font-mono.text-white {
648
+ color: hsl(220, 14%, 10%) !important;
649
+ caret-color: hsl(220, 14%, 10%) !important;
650
+ }
651
+
652
+ /* All input placeholders in light mode */
653
+ .light input::placeholder,
654
+ .light textarea::placeholder {
655
+ color: hsl(220, 9%, 50%) !important;
656
+ opacity: 1 !important;
657
+ }
658
+
659
+ /* Muted foreground for labels and hints */
660
+ .light .text-muted-foreground {
661
+ color: hsl(220, 9%, 40%) !important;
662
+ }
663
+
664
+ /* Links in markdown content */
665
+ .light .markdown-content a {
666
+ color: #0891B2 !important;
667
+ text-decoration-color: rgba(8, 145, 178, 0.4) !important;
668
+ }
669
+
670
+ .light .markdown-content a:hover {
671
+ color: #0E7490 !important;
672
+ text-decoration-color: rgba(8, 145, 178, 0.6) !important;
673
+ }
674
+
675
+ /* Code blocks in light mode */
676
+ .light .markdown-content pre {
677
+ background-color: hsl(210, 20%, 96%) !important;
678
+ border-color: hsl(220, 13%, 88%) !important;
679
+ }
680
+
681
+ .light .markdown-content pre code,
682
+ .light .markdown-content code {
683
+ color: hsl(220, 14%, 15%) !important;
684
+ }
685
+
686
+ /* Inline code styling */
687
+ .light .bg-foreground\/10 {
688
+ background-color: rgba(0, 0, 0, 0.06) !important;
689
+ }
690
+
691
+ /* Table styling */
692
+ .light .markdown-content table {
693
+ border-color: hsl(220, 13%, 88%) !important;
694
+ }
695
+
696
+ .light .markdown-content thead {
697
+ background-color: hsl(210, 20%, 96%) !important;
698
+ }
699
+
700
+ .light .markdown-content th,
701
+ .light .markdown-content td {
702
+ color: hsl(220, 14%, 15%) !important;
703
+ border-color: hsl(220, 13%, 88%) !important;
704
+ }
705
+
706
+ /* Blockquotes */
707
+ .light .markdown-content blockquote {
708
+ background-color: hsl(210, 20%, 97%) !important;
709
+ border-left-color: #0891B2 !important;
710
+ color: hsl(220, 14%, 20%) !important;
711
+ }
712
+
713
+ /* Headings */
714
+ .light .markdown-content h1,
715
+ .light .markdown-content h2,
716
+ .light .markdown-content h3 {
717
+ color: hsl(220, 14%, 10%) !important;
718
+ border-color: hsl(220, 13%, 88%) !important;
719
+ }
720
+
721
+ /* Buttons in light mode */
722
+ .light .bg-\[\#4A4A4A\] {
723
+ background-color: #0891B2 !important;
724
+ color: #FFFFFF !important;
725
+ }
726
+
727
+ .light .hover\:bg-\[\#5A5A5A\]:hover {
728
+ background-color: #0E7490 !important;
729
+ }
730
+
731
+ .light .bg-\[\#3A3A3A\] {
732
+ background-color: hsl(210, 20%, 92%) !important;
733
+ color: hsl(220, 9%, 50%) !important;
734
+ }
735
+
736
+ /* File attachment chips */
737
+ .light .bg-\[\#3A3A3A\].rounded-full {
738
+ background-color: hsl(210, 20%, 90%) !important;
739
+ color: hsl(220, 14%, 20%) !important;
740
+ }
741
+
742
+ /* Source link chips */
743
+ .light .bg-\[\#2A2A2A\] {
744
+ background-color: hsl(210, 20%, 96%) !important;
745
+ border-color: hsl(220, 13%, 85%) !important;
746
+ }
747
+
748
+ /* Dropdown menus */
749
+ .light .bg-\[\#1E1E1E\] {
750
+ background-color: #FFFFFF !important;
751
+ border-color: hsl(220, 13%, 88%) !important;
752
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
753
+ }
754
+
755
+ /* Processing/loading states */
756
+ .light .border-muted-foreground\/20 {
757
+ border-color: rgba(0, 0, 0, 0.1) !important;
758
+ }
759
+
760
+ .light .border-t-muted-foreground\/60 {
761
+ border-top-color: rgba(0, 0, 0, 0.3) !important;
762
+ }
763
+
764
+ /* Pro tips / suggestion buttons */
765
+ .light .border-border {
766
+ border-color: hsl(220, 13%, 88%) !important;
767
+ }
768
+
769
+ /* Hero section styling */
770
+ .light .font-display {
771
+ color: hsl(220, 14%, 10%) !important;
772
+ }
773
+
774
+ /* Action buttons hover */
775
+ .light .hover\:bg-white\/5:hover {
776
+ background-color: rgba(0, 0, 0, 0.04) !important;
777
+ }
778
+
779
+ .light .hover\:text-white:hover {
780
+ color: hsl(220, 14%, 10%) !important;
781
+ }
782
+
783
+ /* Selection styling for light mode */
784
+ .light ::selection {
785
+ background: rgba(8, 145, 178, 0.2);
786
+ color: hsl(220, 14%, 10%);
787
+ }
788
+
789
+ .light ::-moz-selection {
790
+ background: rgba(8, 145, 178, 0.2);
791
+ color: hsl(220, 14%, 10%);
792
+ }
793
+
794
+ /* Mobile header in light mode */
795
+ .light .border-border\/30 {
796
+ border-color: hsl(220, 13%, 90%) !important;
797
+ }
798
+
799
+ /* Export menu items */
800
+ .light .hover\:bg-foreground\/10:hover {
801
+ background-color: rgba(0, 0, 0, 0.04) !important;
802
+ }
803
+
804
+ /* Labs editor area */
805
+ .light .bg-background {
806
+ background-color: #FFFFFF !important;
807
+ }
808
+
809
+ /* Status badges */
810
+ .light .bg-green-400\/10 {
811
+ background-color: rgba(74, 222, 128, 0.15) !important;
812
+ }
813
+
814
+ .light .text-green-400\/70,
815
+ .light .text-green-400 {
816
+ color: #16A34A !important;
817
+ }
818
+
819
+ .light .bg-yellow-400\/10 {
820
+ background-color: rgba(250, 204, 21, 0.15) !important;
821
+ }
822
+
823
+ .light .text-yellow-400\/70 {
824
+ color: #CA8A04 !important;
825
+ }
826
+
827
+ /* Cursor states and animations */
828
+ .light .bg-white\/70 {
829
+ background-color: hsl(220, 14%, 20%) !important;
830
+ }
831
+
832
+ /* Labs project sidebar */
833
+ .light .bg-foreground\/5 {
834
+ background-color: rgba(0, 0, 0, 0.03) !important;
835
+ }
836
+
837
+ .light .hover\:bg-foreground\/8:hover,
838
+ .light .hover\:bg-foreground\/5:hover {
839
+ background-color: rgba(0, 0, 0, 0.06) !important;
840
+ }
841
+
842
+ /* Thinking dots remain visible in light mode */
843
+ .light .thinking-dot {
844
+ background-color: hsl(220, 9%, 40%) !important;
845
+ }
846
+
847
+ /* Activity panel in light mode */
848
+ .light .activity-panel {
849
+ border-color: hsl(220, 13%, 88%) !important;
850
+ background: rgba(0, 0, 0, 0.02) !important;
851
+ }
852
+
853
+ .light .activity-panel-header {
854
+ color: hsl(220, 9%, 40%) !important;
855
+ }
856
+
857
+ .light .activity-panel-header:hover {
858
+ background: rgba(0, 0, 0, 0.04) !important;
859
+ }
860
+
861
+ .light .activity-step-row {
862
+ color: hsl(220, 9%, 40%) !important;
863
+ }
864
+
865
+ .light .activity-source-chip {
866
+ background: rgba(0, 0, 0, 0.04) !important;
867
+ border-color: hsl(220, 13%, 88%) !important;
868
+ color: hsl(220, 9%, 40%) !important;
869
+ }
870
+
871
+ .light .activity-source-chip:hover {
872
+ background: rgba(0, 0, 0, 0.07) !important;
873
+ }
874
+
875
+ /* Selection toolbar light mode — shadows only (colors via CSS vars) */
876
+ .light .selection-toolbar-sheet {
877
+ box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.1) !important;
878
+ }
879
+
880
+ .light .selection-toolbar-popover {
881
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 0 1px hsl(var(--border)) !important;
882
+ }
883
+
884
+ /* Streaming status badges */
885
+ .light .bg-foreground\/5.border-border {
886
+ background-color: rgba(0, 0, 0, 0.04) !important;
887
+ }
888
+
889
+ /* Ensure primary color elements use cyan */
890
+ .light .text-primary {
891
+ color: #0891B2 !important;
892
+ }
893
+
894
+ .light .bg-primary {
895
+ background-color: #0891B2 !important;
896
+ }
897
+
898
+ .light .bg-primary\/10 {
899
+ background-color: rgba(8, 145, 178, 0.1) !important;
900
+ }
901
+
902
+ .light .bg-primary\/20 {
903
+ background-color: rgba(8, 145, 178, 0.2) !important;
904
+ }
905
+
906
+ .light .hover\:bg-primary\/90:hover {
907
+ background-color: #0E7490 !important;
908
+ }
909
+
910
+ /* Icon colors in sidebar */
911
+ .light .text-\[\#22D3EE\] {
912
+ color: #0891B2 !important;
913
+ }
client/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App.jsx";
4
+ import "./index.css";
5
+
6
+ createRoot(document.getElementById("root")).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
client/src/test_labs_persistence.js ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Labs Persistence Test Suite
3
+ * Run this in browser console at http://localhost:5173/
4
+ *
5
+ * Tests the localStorage persistence logic for Labs projects
6
+ */
7
+
8
+ const STORAGE_KEY = "labs_projects_global";
9
+
10
+ console.log("=== LABS PERSISTENCE TEST SUITE ===\n");
11
+
12
+ // Test 1: Storage Key Existence
13
+ function test1_storageExists() {
14
+ console.log("TEST 1: Check if storage key exists");
15
+ const raw = localStorage.getItem(STORAGE_KEY);
16
+ if (raw) {
17
+ console.log("✅ Storage key exists");
18
+ console.log(` Size: ${raw.length} bytes`);
19
+ return true;
20
+ } else {
21
+ console.log("⚠️ Storage key does not exist (fresh start)");
22
+ return false;
23
+ }
24
+ }
25
+
26
+ // Test 2: Parse Storage Data
27
+ function test2_parseData() {
28
+ console.log("\nTEST 2: Parse stored data");
29
+ const raw = localStorage.getItem(STORAGE_KEY);
30
+ if (!raw) {
31
+ console.log("⚠️ No data to parse");
32
+ return null;
33
+ }
34
+ try {
35
+ const parsed = JSON.parse(raw);
36
+ if (Array.isArray(parsed)) {
37
+ console.log(`✅ Data parsed successfully - ${parsed.length} projects`);
38
+ return parsed;
39
+ } else {
40
+ console.log("❌ Data is not an array");
41
+ return null;
42
+ }
43
+ } catch (e) {
44
+ console.log("❌ JSON parse failed:", e.message);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ // Test 3: Validate Project Structure
50
+ function test3_validateProjects(projects) {
51
+ console.log("\nTEST 3: Validate project structure");
52
+ if (!projects || projects.length === 0) {
53
+ console.log("⚠️ No projects to validate");
54
+ return false;
55
+ }
56
+
57
+ let allValid = true;
58
+ const requiredFields = ["id", "sessionId", "name", "createdAt", "updatedAt", "document"];
59
+
60
+ projects.forEach((project, index) => {
61
+ const missing = requiredFields.filter(field => !(field in project));
62
+ if (missing.length > 0) {
63
+ console.log(`❌ Project ${index} missing fields: ${missing.join(", ")}`);
64
+ allValid = false;
65
+ } else {
66
+ console.log(`✅ Project ${index}: "${project.name}" - valid structure`);
67
+ console.log(` ID: ${project.id}`);
68
+ console.log(` SessionID: ${project.sessionId}`);
69
+ console.log(` Document length: ${(project.document || "").length} chars`);
70
+ }
71
+ });
72
+
73
+ return allValid;
74
+ }
75
+
76
+ // Test 4: Unique Session IDs
77
+ function test4_uniqueSessionIds(projects) {
78
+ console.log("\nTEST 4: Check unique sessionIds");
79
+ if (!projects || projects.length === 0) {
80
+ console.log("⚠️ No projects to check");
81
+ return true;
82
+ }
83
+
84
+ const sessionIds = projects.map(p => p.sessionId);
85
+ const uniqueIds = new Set(sessionIds);
86
+
87
+ if (sessionIds.length === uniqueIds.size) {
88
+ console.log(`✅ All ${sessionIds.length} sessionIds are unique`);
89
+ return true;
90
+ } else {
91
+ console.log(`❌ Duplicate sessionIds found!`);
92
+ return false;
93
+ }
94
+ }
95
+
96
+ // Test 5: Save and Load Cycle
97
+ function test5_saveLoadCycle() {
98
+ console.log("\nTEST 5: Save and Load Cycle");
99
+
100
+ // Create test project
101
+ const testProject = {
102
+ id: "test-" + Date.now(),
103
+ sessionId: "session-" + Date.now(),
104
+ modelId: "test-model",
105
+ name: "Test Project " + new Date().toLocaleTimeString(),
106
+ createdAt: Date.now(),
107
+ updatedAt: Date.now(),
108
+ document: "Test document content for persistence testing."
109
+ };
110
+
111
+ // Get current projects
112
+ const raw = localStorage.getItem(STORAGE_KEY);
113
+ let projects = [];
114
+ try {
115
+ projects = raw ? JSON.parse(raw) : [];
116
+ } catch (e) {
117
+ projects = [];
118
+ }
119
+
120
+ // Add test project
121
+ projects.push(testProject);
122
+
123
+ // Save
124
+ try {
125
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
126
+ console.log("✅ Save successful");
127
+ } catch (e) {
128
+ console.log("❌ Save failed:", e.message);
129
+ return false;
130
+ }
131
+
132
+ // Load and verify
133
+ try {
134
+ const loaded = JSON.parse(localStorage.getItem(STORAGE_KEY));
135
+ const found = loaded.find(p => p.id === testProject.id);
136
+ if (found && found.document === testProject.document) {
137
+ console.log("✅ Load and verify successful");
138
+ console.log(` Project "${testProject.name}" persisted correctly`);
139
+
140
+ // Clean up - remove test project
141
+ const cleaned = loaded.filter(p => p.id !== testProject.id);
142
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cleaned));
143
+ console.log("✅ Test project cleaned up");
144
+ return true;
145
+ } else {
146
+ console.log("❌ Loaded data doesn't match saved data");
147
+ return false;
148
+ }
149
+ } catch (e) {
150
+ console.log("❌ Load failed:", e.message);
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // Test 6: Large Document Handling
156
+ function test6_largeDocument() {
157
+ console.log("\nTEST 6: Large document handling");
158
+
159
+ // Create a large document (100KB)
160
+ const largeDoc = "x".repeat(100000);
161
+ const testProject = {
162
+ id: "large-test-" + Date.now(),
163
+ sessionId: "session-" + Date.now(),
164
+ modelId: "",
165
+ name: "Large Doc Test",
166
+ createdAt: Date.now(),
167
+ updatedAt: Date.now(),
168
+ document: largeDoc
169
+ };
170
+
171
+ try {
172
+ localStorage.setItem("test_large", JSON.stringify([testProject]));
173
+ const loaded = JSON.parse(localStorage.getItem("test_large"));
174
+ localStorage.removeItem("test_large");
175
+
176
+ if (loaded[0].document.length === 100000) {
177
+ console.log("✅ Large document (100KB) saved and loaded correctly");
178
+ return true;
179
+ } else {
180
+ console.log("❌ Large document corrupted");
181
+ return false;
182
+ }
183
+ } catch (e) {
184
+ console.log("❌ Large document test failed:", e.message);
185
+ localStorage.removeItem("test_large");
186
+ return false;
187
+ }
188
+ }
189
+
190
+ // Run all tests
191
+ function runAllTests() {
192
+ console.log("\n" + "=".repeat(50));
193
+ console.log("RUNNING ALL TESTS");
194
+ console.log("=".repeat(50) + "\n");
195
+
196
+ const results = [];
197
+
198
+ results.push({ name: "Storage Exists", passed: test1_storageExists() });
199
+
200
+ const projects = test2_parseData();
201
+ results.push({ name: "Parse Data", passed: projects !== null || localStorage.getItem(STORAGE_KEY) === null });
202
+
203
+ if (projects) {
204
+ results.push({ name: "Validate Projects", passed: test3_validateProjects(projects) });
205
+ results.push({ name: "Unique SessionIds", passed: test4_uniqueSessionIds(projects) });
206
+ }
207
+
208
+ results.push({ name: "Save/Load Cycle", passed: test5_saveLoadCycle() });
209
+ results.push({ name: "Large Document", passed: test6_largeDocument() });
210
+
211
+ console.log("\n" + "=".repeat(50));
212
+ console.log("TEST RESULTS SUMMARY");
213
+ console.log("=".repeat(50));
214
+
215
+ const passed = results.filter(r => r.passed).length;
216
+ const total = results.length;
217
+
218
+ results.forEach(r => {
219
+ console.log(`${r.passed ? "✅" : "❌"} ${r.name}`);
220
+ });
221
+
222
+ console.log("\n" + `${passed}/${total} tests passed`);
223
+
224
+ if (passed === total) {
225
+ console.log("\n🎉 ALL TESTS PASSED! Persistence is working correctly.");
226
+ } else {
227
+ console.log("\n⚠️ Some tests failed. Check the output above.");
228
+ }
229
+
230
+ return { passed, total, results };
231
+ }
232
+
233
+ // Export for browser console
234
+ window.labsTest = { runAllTests, test1_storageExists, test2_parseData, test3_validateProjects, test4_uniqueSessionIds, test5_saveLoadCycle, test6_largeDocument };
235
+
236
+ console.log("\nTest suite loaded. Run: labsTest.runAllTests()");
237
+ console.log("Or run individual tests: labsTest.test1_storageExists()");
238
+
239
+ // Auto-run
240
+ runAllTests();
client/src/utils/contentUtils.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utility functions for cleaning and processing AI response content
3
+ */
4
+
5
+ /**
6
+ * Strips JSON metadata blocks that AI agents sometimes output.
7
+ * These are internal traces that shouldn't be displayed to users.
8
+ *
9
+ * Patterns removed:
10
+ * - {"event":"agent_trace",...} blocks with nested content
11
+ * - Long JSON objects containing runId, parentRunId, toolInput, etc.
12
+ *
13
+ * @param {string} text - The raw text content
14
+ * @returns {string} - Cleaned text without metadata
15
+ */
16
+ export function stripMetadata(text) {
17
+ if (!text || typeof text !== "string") return text;
18
+
19
+ // Pattern to match agent_trace JSON blocks
20
+ // These can be quite long and contain nested objects
21
+ let cleaned = text;
22
+
23
+ // Remove {"event":"agent_trace",...} patterns
24
+ // This regex matches from {"event":"agent_trace" to the closing }
25
+ // accounting for nested braces
26
+ cleaned = cleaned.replace(
27
+ /\{"event"\s*:\s*"agent_trace"[^]*?"runId"\s*:\s*"[a-f0-9-]+"\s*,\s*"parentRunId"\s*:\s*"[a-f0-9-]+"\s*\}\s*\}/g,
28
+ ""
29
+ );
30
+
31
+ // Fallback simpler pattern for variations
32
+ cleaned = cleaned.replace(
33
+ /\{"event"\s*:\s*"agent_trace"[^}]*\}(?:\s*\})?/g,
34
+ ""
35
+ );
36
+
37
+ // Remove JSON blocks that look like tool metadata
38
+ cleaned = cleaned.replace(
39
+ /\{"tool"\s*:\s*"[^"]+"\s*,\s*"toolInput"\s*:[^}]+\}[^{]*/g,
40
+ ""
41
+ );
42
+
43
+ // Remove any remaining runId/parentRunId JSON fragments
44
+ cleaned = cleaned.replace(
45
+ /\{"runId"\s*:\s*"[a-f0-9-]+"\s*,\s*"parentRunId"\s*:\s*"[a-f0-9-]+"\s*\}/g,
46
+ ""
47
+ );
48
+
49
+ // Clean up multiple consecutive newlines that may result from removals
50
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
51
+
52
+ return cleaned.trim();
53
+ }
client/src/utils/exportToPdf.js ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import jsPDF from "jspdf";
2
+ import html2canvas from "html2canvas";
3
+
4
+ /**
5
+ * Converts markdown content to a styled PDF document
6
+ * @param {string} content - Markdown content
7
+ * @param {string} filename - Name for the exported file (without extension)
8
+ */
9
+ export async function exportToPdf(content, filename = "document") {
10
+ // Create a temporary container to render the content
11
+ const container = document.createElement("div");
12
+ container.style.cssText = `
13
+ position: absolute;
14
+ left: -9999px;
15
+ top: 0;
16
+ width: 210mm;
17
+ padding: 20mm;
18
+ background: white;
19
+ color: black;
20
+ font-family: 'Inter', 'Segoe UI', 'Arial', sans-serif;
21
+ font-size: 12pt;
22
+ line-height: 1.6;
23
+ `;
24
+
25
+ // Process the markdown content to HTML with basic styling
26
+ const htmlContent = markdownToHtml(content);
27
+ container.innerHTML = htmlContent;
28
+ document.body.appendChild(container);
29
+
30
+ try {
31
+ // Wait for any images/fonts to load
32
+ await new Promise(resolve => setTimeout(resolve, 100));
33
+
34
+ // Capture the content as canvas
35
+ const canvas = await html2canvas(container, {
36
+ scale: 2,
37
+ useCORS: true,
38
+ logging: false,
39
+ backgroundColor: "#ffffff"
40
+ });
41
+
42
+ // Create PDF
43
+ const imgWidth = 210; // A4 width in mm
44
+ const pageHeight = 297; // A4 height in mm
45
+ const imgHeight = (canvas.height * imgWidth) / canvas.width;
46
+ let heightLeft = imgHeight;
47
+ let position = 0;
48
+
49
+ const pdf = new jsPDF("p", "mm", "a4");
50
+ const imgData = canvas.toDataURL("image/png");
51
+
52
+ // Add first page
53
+ pdf.addImage(imgData, "PNG", 0, position, imgWidth, imgHeight);
54
+ heightLeft -= pageHeight;
55
+
56
+ // Add additional pages if content is longer than one page
57
+ while (heightLeft > 0) {
58
+ position = heightLeft - imgHeight;
59
+ pdf.addPage();
60
+ pdf.addImage(imgData, "PNG", 0, position, imgWidth, imgHeight);
61
+ heightLeft -= pageHeight;
62
+ }
63
+
64
+ // Save the PDF
65
+ pdf.save(`${filename}.pdf`);
66
+ } finally {
67
+ // Clean up
68
+ document.body.removeChild(container);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Converts markdown to styled HTML for PDF rendering
74
+ * Handles common markdown syntax including LaTeX (as plain text for PDF)
75
+ */
76
+ function markdownToHtml(markdown) {
77
+ if (!markdown) return "";
78
+
79
+ let html = markdown;
80
+
81
+ // Escape HTML entities
82
+ html = html
83
+ .replace(/&/g, "&amp;")
84
+ .replace(/</g, "&lt;")
85
+ .replace(/>/g, "&gt;");
86
+
87
+ // Convert LaTeX blocks to styled spans (rendered as formatted text)
88
+ html = html.replace(/\$\$([^$]+)\$\$/g, '<div style="text-align: center; font-style: italic; padding: 10px; background: #f5f5f5; border-radius: 4px; margin: 10px 0;">$1</div>');
89
+ html = html.replace(/\$([^$]+)\$/g, '<span style="font-style: italic; background: #f0f0f0; padding: 2px 4px; border-radius: 2px;">$1</span>');
90
+
91
+ // Headers
92
+ html = html.replace(/^### (.+)$/gm, '<h3 style="font-size: 14pt; font-weight: 600; margin: 16px 0 8px 0; color: #333;">$1</h3>');
93
+ html = html.replace(/^## (.+)$/gm, '<h2 style="font-size: 16pt; font-weight: 600; margin: 20px 0 10px 0; color: #222;">$1</h2>');
94
+ html = html.replace(/^# (.+)$/gm, '<h1 style="font-size: 20pt; font-weight: 700; margin: 24px 0 12px 0; color: #111; border-bottom: 1px solid #ddd; padding-bottom: 8px;">$1</h1>');
95
+
96
+ // Bold and Italic
97
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
98
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
99
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
100
+ html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
101
+ html = html.replace(/_(.+?)_/g, '<em>$1</em>');
102
+
103
+ // Inline code
104
+ html = html.replace(/`([^`]+)`/g, '<code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-family: Consolas, monospace; font-size: 11pt;">$1</code>');
105
+
106
+ // Code blocks
107
+ html = html.replace(/```[\w]*\n([\s\S]*?)```/g, '<pre style="background: #f5f5f5; padding: 12px; border-radius: 6px; font-family: Consolas, monospace; font-size: 10pt; overflow-x: auto; margin: 12px 0; border: 1px solid #e0e0e0;">$1</pre>');
108
+
109
+ // Blockquotes
110
+ html = html.replace(/^> (.+)$/gm, '<blockquote style="border-left: 3px solid #4a90d9; padding-left: 12px; margin: 12px 0; color: #555; font-style: italic;">$1</blockquote>');
111
+
112
+ // Horizontal rules
113
+ html = html.replace(/^---$/gm, '<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;">');
114
+
115
+ // Unordered lists
116
+ html = html.replace(/^- (.+)$/gm, '<li style="margin: 4px 0;">$1</li>');
117
+ html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, '<ul style="margin: 10px 0; padding-left: 24px;">$&</ul>');
118
+
119
+ // Ordered lists
120
+ html = html.replace(/^\d+\. (.+)$/gm, '<li style="margin: 4px 0;">$1</li>');
121
+
122
+ // Links
123
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color: #4a90d9; text-decoration: underline;">$1</a>');
124
+
125
+ // Tables - parse markdown tables to HTML tables
126
+ html = html.replace(/^(\|.+\|)\r?\n(\|[-:| ]+\|)\r?\n((?:\|.+\|\r?\n?)+)/gm, (match, headerRow, separatorRow, bodyRows) => {
127
+ // Parse header cells
128
+ const headers = headerRow.split('|').slice(1, -1).map(h => h.trim());
129
+
130
+ // Parse alignment from separator row
131
+ const alignments = separatorRow.split('|').slice(1, -1).map(sep => {
132
+ const s = sep.trim();
133
+ if (s.startsWith(':') && s.endsWith(':')) return 'center';
134
+ if (s.endsWith(':')) return 'right';
135
+ return 'left';
136
+ });
137
+
138
+ // Create header HTML
139
+ const headerHtml = headers.map((h, i) =>
140
+ `<th style="border: 1px solid #ddd; padding: 10px 12px; text-align: ${alignments[i] || 'left'}; background: #f5f5f5; font-weight: 600;">${h}</th>`
141
+ ).join('');
142
+
143
+ // Parse and create body rows
144
+ const rows = bodyRows.trim().split('\n').map((row, rowIdx) => {
145
+ const cells = row.split('|').slice(1, -1).map(c => c.trim());
146
+ const cellsHtml = cells.map((c, i) =>
147
+ `<td style="border: 1px solid #ddd; padding: 8px 12px; text-align: ${alignments[i] || 'left'};">${c}</td>`
148
+ ).join('');
149
+ return `<tr style="background: ${rowIdx % 2 === 0 ? '#fff' : '#fafafa'};">${cellsHtml}</tr>`;
150
+ }).join('');
151
+
152
+ return `<table style="border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 11pt;">
153
+ <thead><tr>${headerHtml}</tr></thead>
154
+ <tbody>${rows}</tbody>
155
+ </table>`;
156
+ });
157
+
158
+ // Paragraphs (handle line breaks)
159
+ html = html.replace(/\n\n/g, '</p><p style="margin: 10px 0;">');
160
+ html = html.replace(/\n/g, '<br>');
161
+
162
+ // Wrap in paragraph tags
163
+ html = `<p style="margin: 10px 0;">${html}</p>`;
164
+
165
+ // Clean up empty paragraphs
166
+ html = html.replace(/<p[^>]*>\s*<\/p>/g, '');
167
+
168
+ return `
169
+ <style>
170
+ * { box-sizing: border-box; }
171
+ body { margin: 0; padding: 0; }
172
+ </style>
173
+ ${html}
174
+ `;
175
+ }
client/src/utils/exportToWord.js ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Export markdown content to a Word document (.docx)
3
+ * Uses the docx library to create properly formatted documents
4
+ */
5
+
6
+ import {
7
+ Document,
8
+ Paragraph,
9
+ TextRun,
10
+ HeadingLevel,
11
+ Table,
12
+ TableRow,
13
+ TableCell,
14
+ WidthType,
15
+ BorderStyle,
16
+ Packer,
17
+ AlignmentType
18
+ } from "docx";
19
+
20
+ /**
21
+ * Simple markdown parser for converting to docx elements
22
+ */
23
+ function parseMarkdown(markdown) {
24
+ const lines = markdown.split("\n");
25
+ const elements = [];
26
+ let inCodeBlock = false;
27
+ let codeContent = [];
28
+ let inTable = false;
29
+ let tableRows = [];
30
+ let listItems = [];
31
+ let listType = null; // "ul" or "ol"
32
+
33
+ const flushList = () => {
34
+ if (listItems.length > 0) {
35
+ elements.push({
36
+ type: listType === "ol" ? "orderedList" : "bulletList",
37
+ items: [...listItems]
38
+ });
39
+ listItems = [];
40
+ listType = null;
41
+ }
42
+ };
43
+
44
+ const flushTable = () => {
45
+ if (tableRows.length > 0) {
46
+ elements.push({
47
+ type: "table",
48
+ rows: [...tableRows]
49
+ });
50
+ tableRows = [];
51
+ inTable = false;
52
+ }
53
+ };
54
+
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const line = lines[i];
57
+
58
+ // Code block handling
59
+ if (line.startsWith("```")) {
60
+ if (inCodeBlock) {
61
+ elements.push({
62
+ type: "codeBlock",
63
+ content: codeContent.join("\n")
64
+ });
65
+ codeContent = [];
66
+ inCodeBlock = false;
67
+ } else {
68
+ flushList();
69
+ flushTable();
70
+ inCodeBlock = true;
71
+ }
72
+ continue;
73
+ }
74
+
75
+ if (inCodeBlock) {
76
+ codeContent.push(line);
77
+ continue;
78
+ }
79
+
80
+ // Table handling
81
+ if (line.includes("|") && line.trim().startsWith("|")) {
82
+ flushList();
83
+ const cells = line.split("|").slice(1, -1).map(c => c.trim());
84
+
85
+ // Skip separator row
86
+ if (cells.every(c => /^[-:]+$/.test(c))) {
87
+ continue;
88
+ }
89
+
90
+ tableRows.push(cells);
91
+ inTable = true;
92
+ continue;
93
+ } else if (inTable) {
94
+ flushTable();
95
+ }
96
+
97
+ // Empty line
98
+ if (!line.trim()) {
99
+ flushList();
100
+ continue;
101
+ }
102
+
103
+ // Headings
104
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
105
+ if (headingMatch) {
106
+ flushList();
107
+ elements.push({
108
+ type: "heading",
109
+ level: headingMatch[1].length,
110
+ content: headingMatch[2]
111
+ });
112
+ continue;
113
+ }
114
+
115
+ // Horizontal rule
116
+ if (/^[-*_]{3,}$/.test(line.trim())) {
117
+ flushList();
118
+ elements.push({ type: "hr" });
119
+ continue;
120
+ }
121
+
122
+ // Blockquote
123
+ if (line.startsWith(">")) {
124
+ flushList();
125
+ elements.push({
126
+ type: "blockquote",
127
+ content: line.replace(/^>\s*/, "")
128
+ });
129
+ continue;
130
+ }
131
+
132
+ // Ordered list
133
+ const olMatch = line.match(/^(\d+)\.\s+(.+)$/);
134
+ if (olMatch) {
135
+ if (listType === "ul") flushList();
136
+ listType = "ol";
137
+ listItems.push(olMatch[2]);
138
+ continue;
139
+ }
140
+
141
+ // Unordered list
142
+ const ulMatch = line.match(/^[-*+]\s+(.+)$/);
143
+ if (ulMatch) {
144
+ if (listType === "ol") flushList();
145
+ listType = "ul";
146
+ listItems.push(ulMatch[1]);
147
+ continue;
148
+ }
149
+
150
+ // Regular paragraph
151
+ flushList();
152
+ elements.push({
153
+ type: "paragraph",
154
+ content: line
155
+ });
156
+ }
157
+
158
+ // Flush any remaining items
159
+ flushList();
160
+ flushTable();
161
+
162
+ return elements;
163
+ }
164
+
165
+ /**
166
+ * Parse inline formatting (bold, italic, code, links)
167
+ */
168
+ function parseInlineFormatting(text) {
169
+ const runs = [];
170
+ let remaining = text;
171
+
172
+ while (remaining.length > 0) {
173
+ // Bold and italic: ***text***
174
+ let match = remaining.match(/^\*\*\*(.+?)\*\*\*/);
175
+ if (match) {
176
+ runs.push(new TextRun({ text: match[1], bold: true, italics: true, font: "Calibri" }));
177
+ remaining = remaining.slice(match[0].length);
178
+ continue;
179
+ }
180
+
181
+ // Bold: **text** or __text__
182
+ match = remaining.match(/^(\*\*|__)(.+?)(\*\*|__)/);
183
+ if (match) {
184
+ runs.push(new TextRun({ text: match[2], bold: true, font: "Calibri" }));
185
+ remaining = remaining.slice(match[0].length);
186
+ continue;
187
+ }
188
+
189
+ // Italic: *text* or _text_
190
+ match = remaining.match(/^(\*|_)(.+?)(\*|_)/);
191
+ if (match) {
192
+ runs.push(new TextRun({ text: match[2], italics: true, font: "Calibri" }));
193
+ remaining = remaining.slice(match[0].length);
194
+ continue;
195
+ }
196
+
197
+ // Inline code: `code`
198
+ match = remaining.match(/^`([^`]+)`/);
199
+ if (match) {
200
+ runs.push(new TextRun({
201
+ text: match[1],
202
+ font: "Consolas",
203
+ shading: { fill: "E8E8E8" }
204
+ }));
205
+ remaining = remaining.slice(match[0].length);
206
+ continue;
207
+ }
208
+
209
+ // Link: [text](url)
210
+ match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
211
+ if (match) {
212
+ runs.push(new TextRun({
213
+ text: match[1],
214
+ color: "0563C1",
215
+ underline: {},
216
+ font: "Calibri"
217
+ }));
218
+ remaining = remaining.slice(match[0].length);
219
+ continue;
220
+ }
221
+
222
+ // Regular text (take until next special character or end)
223
+ match = remaining.match(/^[^*_`\[]+/);
224
+ if (match) {
225
+ runs.push(new TextRun({ text: match[0], font: "Calibri" }));
226
+ remaining = remaining.slice(match[0].length);
227
+ continue;
228
+ }
229
+
230
+ // Single special character that didn't match formatting
231
+ runs.push(new TextRun({ text: remaining[0], font: "Calibri" }));
232
+ remaining = remaining.slice(1);
233
+ }
234
+
235
+ return runs;
236
+ }
237
+
238
+ /**
239
+ * Convert parsed elements to docx sections
240
+ */
241
+ function elementsToDocx(elements) {
242
+ const children = [];
243
+
244
+ for (const el of elements) {
245
+ switch (el.type) {
246
+ case "heading":
247
+ const headingLevels = {
248
+ 1: HeadingLevel.HEADING_1,
249
+ 2: HeadingLevel.HEADING_2,
250
+ 3: HeadingLevel.HEADING_3,
251
+ 4: HeadingLevel.HEADING_4,
252
+ 5: HeadingLevel.HEADING_5,
253
+ 6: HeadingLevel.HEADING_6
254
+ };
255
+ children.push(
256
+ new Paragraph({
257
+ heading: headingLevels[el.level] || HeadingLevel.HEADING_1,
258
+ children: parseInlineFormatting(el.content),
259
+ spacing: { before: 280, after: 120 }
260
+ })
261
+ );
262
+ break;
263
+
264
+ case "paragraph":
265
+ children.push(
266
+ new Paragraph({
267
+ children: parseInlineFormatting(el.content),
268
+ spacing: { after: 280, line: 276 }
269
+ })
270
+ );
271
+ break;
272
+
273
+ case "bulletList":
274
+ for (let i = 0; i < el.items.length; i++) {
275
+ children.push(
276
+ new Paragraph({
277
+ bullet: { level: 0 },
278
+ children: parseInlineFormatting(el.items[i]),
279
+ spacing: { after: i === el.items.length - 1 ? 200 : 60 }
280
+ })
281
+ );
282
+ }
283
+ break;
284
+
285
+ case "orderedList":
286
+ for (let idx = 0; idx < el.items.length; idx++) {
287
+ children.push(
288
+ new Paragraph({
289
+ numbering: { reference: "default-numbering", level: 0 },
290
+ children: parseInlineFormatting(el.items[idx]),
291
+ spacing: { after: idx === el.items.length - 1 ? 200 : 60 }
292
+ })
293
+ );
294
+ }
295
+ break;
296
+
297
+ case "codeBlock":
298
+ children.push(
299
+ new Paragraph({
300
+ children: [
301
+ new TextRun({
302
+ text: el.content,
303
+ font: "Consolas",
304
+ size: 20
305
+ })
306
+ ],
307
+ shading: { fill: "F5F5F5" },
308
+ spacing: { before: 200, after: 200 }
309
+ })
310
+ );
311
+ break;
312
+
313
+ case "blockquote":
314
+ children.push(
315
+ new Paragraph({
316
+ children: parseInlineFormatting(el.content),
317
+ indent: { left: 720 },
318
+ border: {
319
+ left: { style: BorderStyle.SINGLE, size: 24, color: "CCCCCC" }
320
+ },
321
+ spacing: { before: 200, after: 200 }
322
+ })
323
+ );
324
+ break;
325
+
326
+ case "table":
327
+ const tableRows = el.rows.map((row, rowIdx) =>
328
+ new TableRow({
329
+ children: row.map(cell =>
330
+ new TableCell({
331
+ children: [new Paragraph({ children: parseInlineFormatting(cell) })],
332
+ shading: rowIdx === 0 ? { fill: "E8E8E8" } : undefined
333
+ })
334
+ )
335
+ })
336
+ );
337
+ children.push(
338
+ new Table({
339
+ rows: tableRows,
340
+ width: { size: 100, type: WidthType.PERCENTAGE }
341
+ })
342
+ );
343
+ break;
344
+
345
+ case "hr":
346
+ children.push(
347
+ new Paragraph({
348
+ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "CCCCCC" } },
349
+ spacing: { before: 400, after: 400 }
350
+ })
351
+ );
352
+ break;
353
+ }
354
+ }
355
+
356
+ return children;
357
+ }
358
+
359
+ /**
360
+ * Export markdown content to Word document and trigger download
361
+ */
362
+ export async function exportToWord(markdown, filename = "document") {
363
+ const elements = parseMarkdown(markdown);
364
+ const children = elementsToDocx(elements);
365
+
366
+ const doc = new Document({
367
+ numbering: {
368
+ config: [
369
+ {
370
+ reference: "default-numbering",
371
+ levels: [
372
+ {
373
+ level: 0,
374
+ format: "decimal",
375
+ text: "%1.",
376
+ alignment: AlignmentType.START
377
+ }
378
+ ]
379
+ }
380
+ ]
381
+ },
382
+ sections: [
383
+ {
384
+ children
385
+ }
386
+ ]
387
+ });
388
+
389
+ const blob = await Packer.toBlob(doc);
390
+
391
+ // Trigger download
392
+ const url = URL.createObjectURL(blob);
393
+ const link = document.createElement("a");
394
+ link.href = url;
395
+ link.download = `${filename.replace(/[^a-zA-Z0-9-_]/g, "_")}.docx`;
396
+ document.body.appendChild(link);
397
+ link.click();
398
+ document.body.removeChild(link);
399
+ URL.revokeObjectURL(url);
400
+ }
client/tailwind.config.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ content: ["./index.html", "./src/**/*.{js,jsx}"],
3
+ darkMode: "class",
4
+ theme: {
5
+ container: {
6
+ center: true,
7
+ padding: "2rem",
8
+ screens: {
9
+ "2xl": "1400px",
10
+ },
11
+ },
12
+ extend: {
13
+ fontFamily: {
14
+ sans: ["Inter", "system-ui", "sans-serif"],
15
+ display: ["Plus Jakarta Sans", "Inter", "system-ui", "sans-serif"],
16
+ },
17
+ colors: {
18
+ border: "hsl(var(--border))",
19
+ input: "hsl(var(--input))",
20
+ ring: "hsl(var(--ring))",
21
+ background: "hsl(var(--background))",
22
+ foreground: "hsl(var(--foreground))",
23
+ primary: {
24
+ DEFAULT: "hsl(var(--primary))",
25
+ foreground: "hsl(var(--primary-foreground))",
26
+ },
27
+ secondary: {
28
+ DEFAULT: "hsl(var(--secondary))",
29
+ foreground: "hsl(var(--secondary-foreground))",
30
+ },
31
+ destructive: {
32
+ DEFAULT: "hsl(var(--destructive))",
33
+ foreground: "hsl(var(--destructive-foreground))",
34
+ },
35
+ muted: {
36
+ DEFAULT: "hsl(var(--muted))",
37
+ foreground: "hsl(var(--muted-foreground))",
38
+ },
39
+ accent: {
40
+ DEFAULT: "hsl(var(--accent))",
41
+ foreground: "hsl(var(--accent-foreground))",
42
+ },
43
+ popover: {
44
+ DEFAULT: "hsl(var(--popover))",
45
+ foreground: "hsl(var(--popover-foreground))",
46
+ },
47
+ card: {
48
+ DEFAULT: "hsl(var(--card))",
49
+ foreground: "hsl(var(--card-foreground))",
50
+ },
51
+ },
52
+ keyframes: {
53
+ "accordion-down": {
54
+ from: { height: 0 },
55
+ to: { height: "var(--radix-accordion-content-height)" },
56
+ },
57
+ "accordion-up": {
58
+ from: { height: "var(--radix-accordion-content-height)" },
59
+ to: { height: 0 },
60
+ },
61
+ shimmer: {
62
+ "0%": { backgroundPosition: "200% 0" },
63
+ "100%": { backgroundPosition: "-200% 0" }
64
+ },
65
+ float: {
66
+ "0%, 100%": { transform: "translateY(0)" },
67
+ "50%": { transform: "translateY(-10px)" }
68
+ }
69
+ },
70
+ animation: {
71
+ "accordion-down": "accordion-down 0.2s ease-out",
72
+ "accordion-up": "accordion-up 0.2s ease-out",
73
+ shimmer: "shimmer 8s infinite linear",
74
+ float: "float 6s ease-in-out infinite",
75
+ },
76
+ },
77
+ },
78
+ plugins: [],
79
+ };
client/vite.config.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ export default defineConfig({
10
+ plugins: [react()],
11
+ server: {
12
+ proxy: {
13
+ "/chat": "http://localhost:3001",
14
+ "/models": "http://localhost:3001"
15
+ }
16
+ },
17
+ build: {
18
+ outDir: path.resolve(__dirname, "../server/public"),
19
+ emptyOutDir: true,
20
+ chunkSizeWarningLimit: 1000 // Suppress warning for chunks up to 1MB
21
+ }
22
+ });
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "flowise-chat-app",
3
+ "private": true,
4
+ "workspaces": [
5
+ "client"
6
+ ],
7
+ "engines": {
8
+ "node": ">=18.0.0"
9
+ },
10
+ "scripts": {
11
+ "dev": "node server/index.js",
12
+ "build": "npm --prefix client run build",
13
+ "start": "node server/index.js"
14
+ },
15
+ "dependencies": {
16
+ "dotenv": "^16.4.5",
17
+ "eventsource-parser": "^1.1.2",
18
+ "express": "^4.19.2",
19
+ "morgan": "^1.10.0"
20
+ }
21
+ }
server/index.js ADDED
@@ -0,0 +1,881 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require("express");
2
+ const path = require("path");
3
+ const fs = require("fs");
4
+ const morgan = require("morgan");
5
+ const { createParser } = require("eventsource-parser");
6
+ const { loadModelsFromEnv, loadModelsFromEnvDetailed, loadPublicModels } = require("./models");
7
+
8
+ /**
9
+ * Load the dedicated Labs model from LABS_MODEL_* env vars.
10
+ * Falls back to the first regular model if not set.
11
+ */
12
+ function loadLabsModel() {
13
+ const name = process.env.LABS_MODEL_NAME || "Labs Model";
14
+ const id = (process.env.LABS_MODEL_ID || "").trim();
15
+ const host = (process.env.LABS_MODEL_HOST || "").trim().replace(/\/$/, "");
16
+ const apiKey = (process.env.LABS_MODEL_API_KEY || "").trim();
17
+ const authHeader = (process.env.LABS_MODEL_AUTH_HEADER || "").trim();
18
+ const authValue = (process.env.LABS_MODEL_AUTH_VALUE || "").trim();
19
+
20
+ if (id && host) {
21
+ return { name, id, host, index: "labs", apiKey, authHeader, authValue };
22
+ }
23
+ // Fallback to first regular model
24
+ const detailed = loadModelsFromEnvDetailed(process.env);
25
+ if (detailed.models.length > 0) {
26
+ return { ...detailed.models[0], name: name || detailed.models[0].name };
27
+ }
28
+ return null;
29
+ }
30
+
31
+ require("dotenv").config();
32
+
33
+ const app = express();
34
+ app.use(express.json({ limit: "2mb" }));
35
+ app.use(morgan("tiny"));
36
+
37
+ const publicDir = path.join(__dirname, "public");
38
+ if (fs.existsSync(publicDir)) {
39
+ app.use(express.static(publicDir));
40
+ }
41
+
42
+ const capabilityCache = new Map();
43
+ const CAPABILITY_TTL_MS = 60000;
44
+
45
+ function sendEvent(res, event, data) {
46
+ if (res.writableEnded) return;
47
+ res.write(`event: ${event}\n`);
48
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
49
+ }
50
+
51
+ function getFlowiseHeaders(model) {
52
+ const extraHeaders = {};
53
+ if (model?.authHeader && model?.authValue) {
54
+ extraHeaders[model.authHeader] = model.authValue;
55
+ } else if (model?.apiKey) {
56
+ extraHeaders["Authorization"] = `Bearer ${model.apiKey}`;
57
+ } else if (process.env.FLOWISE_AUTH_HEADER && process.env.FLOWISE_AUTH_VALUE) {
58
+ extraHeaders[process.env.FLOWISE_AUTH_HEADER] = process.env.FLOWISE_AUTH_VALUE;
59
+ } else if (process.env.FLOWISE_API_KEY) {
60
+ extraHeaders["Authorization"] = `Bearer ${process.env.FLOWISE_API_KEY}`;
61
+ }
62
+ return extraHeaders;
63
+ }
64
+
65
+ function parseMaybeJson(value) {
66
+ if (!value) return null;
67
+ if (typeof value === "object") return value;
68
+ if (typeof value !== "string") return null;
69
+ try {
70
+ return JSON.parse(value);
71
+ } catch (error) {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function findTruthyFlag(target, keys) {
77
+ if (!target || typeof target !== "object") return false;
78
+ const stack = [target];
79
+ while (stack.length) {
80
+ const current = stack.pop();
81
+ if (!current || typeof current !== "object") continue;
82
+ for (const [key, value] of Object.entries(current)) {
83
+ if (keys.includes(key) && Boolean(value)) {
84
+ return true;
85
+ }
86
+ if (value && typeof value === "object") {
87
+ stack.push(value);
88
+ }
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function deriveCapabilities(chatflow) {
95
+ const chatbotConfig = parseMaybeJson(chatflow?.chatbotConfig);
96
+ const apiConfig = parseMaybeJson(chatflow?.apiConfig);
97
+ const speechToText = parseMaybeJson(chatflow?.speechToText);
98
+ const flowData = typeof chatflow?.flowData === "string" ? chatflow.flowData : "";
99
+ const uploadKeys = [
100
+ "uploads",
101
+ "upload",
102
+ "fileUpload",
103
+ "fileUploads",
104
+ "enableUploads",
105
+ "enableFileUploads",
106
+ "allowUploads",
107
+ "allowFileUploads",
108
+ "isFileUploadEnabled",
109
+ "uploadEnabled"
110
+ ];
111
+ const ttsKeys = [
112
+ "tts",
113
+ "textToSpeech",
114
+ "speechSynthesis",
115
+ "voice",
116
+ "enableTTS",
117
+ "enableTextToSpeech"
118
+ ];
119
+ const hasFlowUpload =
120
+ flowData.includes("File Loader") ||
121
+ flowData.includes("Document Loader") ||
122
+ flowData.includes("FileLoader") ||
123
+ flowData.includes("DocumentLoader") ||
124
+ flowData.includes("Uploads") ||
125
+ flowData.includes("upload") ||
126
+ flowData.includes("Document") ||
127
+ flowData.includes("PDF") ||
128
+ flowData.includes("Image");
129
+ const uploads =
130
+ hasFlowUpload ||
131
+ findTruthyFlag(chatflow, uploadKeys) ||
132
+ findTruthyFlag(chatbotConfig, uploadKeys) ||
133
+ findTruthyFlag(apiConfig, uploadKeys);
134
+ const tts = findTruthyFlag(chatbotConfig, ttsKeys) || findTruthyFlag(apiConfig, ttsKeys);
135
+ const stt = speechToText && Object.keys(speechToText).length > 0;
136
+ return { uploads, tts, stt };
137
+ }
138
+
139
+ async function fetchCapabilities(model) {
140
+ const cacheKey = String(model.index);
141
+ const cached = capabilityCache.get(cacheKey);
142
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
143
+ const baseHost = String(model.host || "").trim().replace(/\/$/, "");
144
+ const urls = [
145
+ {
146
+ url: `${baseHost}/api/v1/chatflows/${model.id}`,
147
+ headers: { "Content-Type": "application/json", ...getFlowiseHeaders(model) }
148
+ },
149
+ {
150
+ url: `${baseHost}/api/v1/public-chatflows/${model.id}`,
151
+ headers: { "Content-Type": "application/json" }
152
+ },
153
+ {
154
+ url: `${baseHost}/api/v1/public-chatbotConfig/${model.id}`,
155
+ headers: { "Content-Type": "application/json" }
156
+ }
157
+ ];
158
+ const controller = new AbortController();
159
+ const timeout = setTimeout(() => controller.abort(), 4000);
160
+ let value = { uploads: false, tts: false, stt: false, status: "unknown" };
161
+ try {
162
+ let sawUnauthorized = false;
163
+ let httpStatus = null;
164
+ for (const candidate of urls) {
165
+ const response = await fetch(candidate.url, {
166
+ headers: candidate.headers,
167
+ signal: controller.signal
168
+ });
169
+
170
+ if (response.ok) {
171
+ const data = await response.json().catch(() => null);
172
+ if (data) {
173
+ value = { ...deriveCapabilities(data), status: "ok" };
174
+ break;
175
+ }
176
+ } else {
177
+ if (response.status === 401 || response.status === 403) {
178
+ sawUnauthorized = true;
179
+ } else if (!httpStatus) {
180
+ httpStatus = response.status;
181
+ }
182
+ }
183
+ }
184
+ if (value.status !== "ok") {
185
+ value = {
186
+ uploads: false,
187
+ tts: false,
188
+ stt: false,
189
+ status: sawUnauthorized ? "unauthorized" : httpStatus ? `http_${httpStatus}` : "unknown"
190
+ };
191
+ }
192
+ } catch (error) {
193
+ value = { uploads: false, tts: false, stt: false, status: "unknown" };
194
+ } finally {
195
+ clearTimeout(timeout);
196
+ }
197
+ capabilityCache.set(cacheKey, { value, expiresAt: Date.now() + CAPABILITY_TTL_MS });
198
+ return value;
199
+ }
200
+
201
+ function buildPrompt(message, mode) {
202
+ // Return raw user message only - all prompts/formatting are configured in Flowise
203
+ // The mode parameter is kept for future use but not used to modify the message
204
+ return message;
205
+ }
206
+
207
+ /* ── Helpers for structured agent-trace parsing ── */
208
+
209
+ function parseMaybeJson(val) {
210
+ if (val && typeof val === "object") return val;
211
+ if (typeof val !== "string") return null;
212
+ try { return JSON.parse(val); } catch { return null; }
213
+ }
214
+
215
+ function processAgentTrace(res, traceData) {
216
+ if (!traceData || typeof traceData !== "object") return;
217
+ const step = traceData.step;
218
+
219
+ if (step === "agent_action") {
220
+ const actionObj = parseMaybeJson(traceData.action);
221
+ if (!actionObj) return;
222
+ const toolRaw = actionObj.tool || "";
223
+ const toolInput = parseMaybeJson(actionObj.toolInput) || {};
224
+
225
+ // Normalize common tool names
226
+ const isSearch = /tavily|search|serp|google/i.test(toolRaw);
227
+ const isBrowser = /browser|scrape|crawl|fetch/i.test(toolRaw);
228
+
229
+ if (isSearch) {
230
+ const query = toolInput.input || toolInput.query || toolInput.q || "";
231
+ sendEvent(res, "agentStep", { type: "search", query });
232
+ sendEvent(res, "activity", { state: "searching" });
233
+ } else if (isBrowser) {
234
+ const url = toolInput.input || toolInput.url || "";
235
+ sendEvent(res, "agentStep", { type: "browse", url });
236
+ sendEvent(res, "activity", { state: "reading" });
237
+ } else {
238
+ sendEvent(res, "agentStep", { type: "tool", tool: toolRaw });
239
+ sendEvent(res, "activity", { state: "tool", tool: toolRaw });
240
+ }
241
+ return;
242
+ }
243
+
244
+ if (step === "tool_end") {
245
+ const output = parseMaybeJson(traceData.output);
246
+ if (Array.isArray(output)) {
247
+ const sources = output
248
+ .filter(item => item && item.url)
249
+ .map(item => ({ url: item.url, title: item.title || "" }))
250
+ .slice(0, 8);
251
+ if (sources.length > 0) {
252
+ sendEvent(res, "agentStep", { type: "sources", items: sources });
253
+ }
254
+ } else if (typeof output === "string" && output.startsWith("**Summary")) {
255
+ // Web browser tool returned a readable summary — skip, not actionable
256
+ }
257
+ return;
258
+ }
259
+
260
+ // tool_start is redundant with agent_action — silently consume
261
+ }
262
+
263
+ async function streamFlowise({
264
+ res,
265
+ model,
266
+ message,
267
+ mode,
268
+ sessionId,
269
+ uploads = [],
270
+ signal
271
+ }) {
272
+ const url = `${model.host}/api/v1/prediction/${model.id}`;
273
+ const payload = {
274
+ question: buildPrompt(message, mode),
275
+ streaming: true,
276
+ chatId: sessionId,
277
+ overrideConfig: sessionId ? { sessionId } : undefined,
278
+ uploads: uploads.length > 0 ? uploads : undefined
279
+ };
280
+
281
+ console.log(`[Flowise] Fetching URL: ${url}`);
282
+ console.log(`[Flowise] Payload:`, JSON.stringify(payload));
283
+
284
+ const extraHeaders = getFlowiseHeaders(model);
285
+
286
+ const response = await fetch(url, {
287
+ method: "POST",
288
+ headers: {
289
+ "Content-Type": "application/json",
290
+ ...extraHeaders
291
+ },
292
+ body: JSON.stringify(payload),
293
+ signal,
294
+ // Add a longer timeout for Hugging Face cold starts
295
+ duplex: 'half'
296
+ }).catch((err) => {
297
+ console.error("[Flowise] Fetch error:", err);
298
+ const wrapped = new Error(`Failed to connect to Flowise: ${err.message}`);
299
+ wrapped.name = err?.name || wrapped.name;
300
+ wrapped.cause = err;
301
+ throw wrapped;
302
+ });
303
+
304
+ const contentType = response.headers.get("content-type") || "";
305
+ console.log(`[Flowise] Status: ${response.status}, Content-Type: ${contentType}`);
306
+ console.log(`[Flowise] Headers:`, JSON.stringify(Object.fromEntries(response.headers.entries())));
307
+
308
+ if (!response.ok) {
309
+ const text = await response.text().catch(() => "");
310
+ console.error(`[Flowise] API error (${response.status}):`, text);
311
+ throw new Error(`Flowise error ${response.status}: ${text}`);
312
+ }
313
+
314
+ if (!response.body) {
315
+ throw new Error("Flowise returned an empty response body.");
316
+ }
317
+
318
+ // If the content type is not a stream, it might be a JSON error hidden in a 200 OK
319
+ // or just a non-streaming response that we should handle.
320
+ if (!contentType.includes("text/event-stream")) {
321
+ console.warn(`[Flowise] Warning: Expected event-stream but got ${contentType}`);
322
+ // If it's JSON, we can try to parse it
323
+ if (contentType.includes("application/json")) {
324
+ const json = await response.json().catch(() => null);
325
+ if (json) {
326
+ console.log(`[Flowise] Parsed JSON instead of stream:`, JSON.stringify(json).slice(0, 50));
327
+ const text = json.text || json.answer || json.output || json.message || JSON.stringify(json);
328
+ sendEvent(res, "token", { text });
329
+ return; // We're done
330
+ }
331
+ }
332
+ }
333
+
334
+ const reader = response.body.getReader();
335
+ const decoder = new TextDecoder();
336
+ let ended = false;
337
+ const parser = createParser((event) => {
338
+ if (event.type !== "event") return;
339
+ const upstreamEventName = event.event || "";
340
+ const raw = event.data || "";
341
+ if (!raw) return;
342
+
343
+ if (upstreamEventName) {
344
+ if (upstreamEventName === "token") {
345
+ sendEvent(res, "token", { text: raw });
346
+ return;
347
+ }
348
+
349
+ if (upstreamEventName === "metadata") {
350
+ let meta = null;
351
+ try {
352
+ meta = JSON.parse(raw);
353
+ } catch (error) {
354
+ meta = { value: raw };
355
+ }
356
+ sendEvent(res, "metadata", meta);
357
+ return;
358
+ }
359
+
360
+ if (upstreamEventName === "start") {
361
+ sendEvent(res, "activity", { state: "writing" });
362
+ return;
363
+ }
364
+
365
+ if (upstreamEventName === "end") {
366
+ ended = true;
367
+ return;
368
+ }
369
+
370
+ if (upstreamEventName === "error") {
371
+ sendEvent(res, "error", { message: raw });
372
+ ended = true;
373
+ return;
374
+ }
375
+
376
+ if (upstreamEventName === "usedTools") {
377
+ let toolData = parseMaybeJson(raw);
378
+ if (toolData) {
379
+ const tools = Array.isArray(toolData) ? toolData : [toolData];
380
+ for (const t of tools) {
381
+ const toolName = t.tool || t.name || t.toolName || "Tool";
382
+ sendEvent(res, "activity", { state: "tool", tool: toolName });
383
+ }
384
+ }
385
+ return;
386
+ }
387
+
388
+ if (upstreamEventName === "agentFlowEvent") {
389
+ let flowData = parseMaybeJson(raw);
390
+ if (flowData) {
391
+ const step = flowData.step || flowData.state || flowData.type || "";
392
+ if (step) {
393
+ sendEvent(res, "activity", { state: step });
394
+ }
395
+ }
396
+ return;
397
+ }
398
+
399
+ if (upstreamEventName === "agent_trace") {
400
+ processAgentTrace(res, parseMaybeJson(raw));
401
+ return;
402
+ }
403
+ }
404
+
405
+ let parsed = null;
406
+ try {
407
+ parsed = JSON.parse(raw);
408
+ } catch (error) {
409
+ parsed = null;
410
+ }
411
+
412
+ if (parsed && typeof parsed === "object") {
413
+ if (typeof parsed.event === "string" && Object.prototype.hasOwnProperty.call(parsed, "data")) {
414
+ const innerEvent = parsed.event;
415
+ const innerData = parsed.data;
416
+
417
+ if (innerEvent === "token") {
418
+ sendEvent(res, "token", { text: typeof innerData === "string" ? innerData : String(innerData || "") });
419
+ return;
420
+ }
421
+
422
+ if (innerEvent === "metadata") {
423
+ sendEvent(res, "metadata", innerData && typeof innerData === "object" ? innerData : { value: innerData });
424
+ return;
425
+ }
426
+
427
+ if (innerEvent === "start") {
428
+ sendEvent(res, "activity", { state: "writing" });
429
+ return;
430
+ }
431
+
432
+ if (innerEvent === "end") {
433
+ ended = true;
434
+ return;
435
+ }
436
+
437
+ if (innerEvent === "error") {
438
+ const message =
439
+ typeof innerData === "string"
440
+ ? innerData
441
+ : (innerData && typeof innerData === "object" && (innerData.message || innerData.error)) || "Unknown error";
442
+ sendEvent(res, "error", { message });
443
+ ended = true;
444
+ return;
445
+ }
446
+
447
+ if (innerEvent === "usedTools") {
448
+ let toolData = innerData && typeof innerData === "object" ? innerData : parseMaybeJson(innerData);
449
+ if (toolData) {
450
+ const tools = Array.isArray(toolData) ? toolData : [toolData];
451
+ for (const t of tools) {
452
+ const toolName = t.tool || t.name || t.toolName || "Tool";
453
+ sendEvent(res, "activity", { state: "tool", tool: toolName });
454
+ }
455
+ }
456
+ return;
457
+ }
458
+
459
+ if (innerEvent === "agentFlowEvent") {
460
+ let flowData = innerData && typeof innerData === "object" ? innerData : parseMaybeJson(innerData);
461
+ if (flowData) {
462
+ const step = flowData.step || flowData.state || flowData.type || "";
463
+ if (step) {
464
+ sendEvent(res, "activity", { state: step });
465
+ }
466
+ }
467
+ return;
468
+ }
469
+
470
+ if (innerEvent === "agent_trace") {
471
+ const traceData = innerData && typeof innerData === "object" ? innerData : parseMaybeJson(innerData);
472
+ processAgentTrace(res, traceData);
473
+ return;
474
+ }
475
+ }
476
+
477
+ const errorText = parsed.error || parsed.message?.error;
478
+ if (errorText) {
479
+ sendEvent(res, "error", { message: errorText });
480
+ ended = true;
481
+ return;
482
+ }
483
+ }
484
+
485
+ const payloadText =
486
+ (parsed &&
487
+ (parsed.token || parsed.text || parsed.answer || parsed.message)) ||
488
+ raw;
489
+ if (payloadText) {
490
+ sendEvent(res, "token", { text: payloadText });
491
+ }
492
+ });
493
+
494
+ while (true) {
495
+ const { value, done } = await reader.read();
496
+ if (done) {
497
+ break;
498
+ }
499
+ parser.feed(decoder.decode(value, { stream: true }));
500
+ if (ended) {
501
+ await reader.cancel().catch(() => undefined);
502
+ break;
503
+ }
504
+ }
505
+ return ended;
506
+ }
507
+
508
+ function scheduleActivities(res, mode) {
509
+ // Lightweight fallback: just send "thinking" after a short delay.
510
+ // Real activities from Flowise metadata will override this.
511
+ const timer = setTimeout(() => {
512
+ sendEvent(res, "activity", { state: "thinking" });
513
+ }, 1200);
514
+ return () => clearTimeout(timer);
515
+ }
516
+
517
+ app.get("/models", async (req, res) => {
518
+ const detailed = loadModelsFromEnvDetailed(process.env).models;
519
+ const { models, issues } = loadPublicModels(process.env);
520
+ const capabilityPairs = await Promise.all(
521
+ detailed.map(async (model) => ({
522
+ id: String(model.index),
523
+ features: await fetchCapabilities(model)
524
+ }))
525
+ );
526
+ const capabilityMap = new Map(capabilityPairs.map((item) => [item.id, item.features]));
527
+ const enriched = models.map((model) => ({
528
+ ...model,
529
+ features: capabilityMap.get(model.id) || { uploads: false, tts: false, stt: false, status: "unknown" }
530
+ }));
531
+ res.json({ models: enriched, issues });
532
+ });
533
+
534
+ // Expose the dedicated Labs model info to the client
535
+ app.get("/labs-model", (req, res) => {
536
+ const labsModel = loadLabsModel();
537
+ if (!labsModel) {
538
+ return res.json({ model: null, error: "No Labs model configured" });
539
+ }
540
+ res.json({ model: { name: labsModel.name } });
541
+ });
542
+
543
+ app.post("/chat", async (req, res) => {
544
+ const { message, modelId, mode, sessionId, uploads } = req.body || {};
545
+ if (
546
+ !message ||
547
+ typeof message !== "string" ||
548
+ message.length > 10000 ||
549
+ !modelId ||
550
+ typeof modelId !== "string" ||
551
+ !mode ||
552
+ typeof mode !== "string" ||
553
+ !["chat", "research"].includes(mode)
554
+ ) {
555
+ return res.status(400).json({ error: "Invalid message" });
556
+ }
557
+ const safeSessionId = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim().slice(0, 128) : "";
558
+
559
+ // Validate uploads if provided
560
+ const safeUploads = Array.isArray(uploads) ? uploads.filter(u =>
561
+ u && typeof u === "object" &&
562
+ typeof u.name === "string" &&
563
+ typeof u.data === "string" &&
564
+ typeof u.mime === "string"
565
+ ) : [];
566
+
567
+ // Resolve safe modelId (index-based) to actual model config
568
+ const detailed = loadModelsFromEnvDetailed(process.env);
569
+ const idx = Number(modelId);
570
+ const model = detailed.models.find((item) => item.index === idx);
571
+ if (!model) {
572
+ return res.status(404).json({ error: "Model not found" });
573
+ }
574
+
575
+ res.writeHead(200, {
576
+ "Content-Type": "text/event-stream",
577
+ "Cache-Control": "no-cache",
578
+ Connection: "keep-alive",
579
+ "X-Accel-Buffering": "no"
580
+ });
581
+
582
+ sendEvent(res, "activity", { state: "writing" });
583
+
584
+ const clearActivities = scheduleActivities(res, mode);
585
+
586
+ const controller = new AbortController();
587
+ req.on("aborted", () => {
588
+ controller.abort();
589
+ clearActivities();
590
+ });
591
+ res.on("close", () => {
592
+ if (!res.writableEnded) {
593
+ controller.abort();
594
+ }
595
+ clearActivities();
596
+ });
597
+
598
+ if (controller.signal.aborted) {
599
+ console.warn("[Chat] Request already aborted by client before starting.");
600
+ return res.end();
601
+ }
602
+
603
+ try {
604
+ // Attempt streaming first
605
+ await streamFlowise({
606
+ res,
607
+ model,
608
+ message,
609
+ mode,
610
+ sessionId: safeSessionId,
611
+ uploads: safeUploads,
612
+ signal: controller.signal
613
+ });
614
+ sendEvent(res, "done", { ok: true });
615
+ } catch (error) {
616
+ const clientAborted = req.aborted || controller.signal.aborted;
617
+ const isAbortError = error?.name === "AbortError";
618
+ console.error(
619
+ `[Chat] Streaming failed (ClientAborted: ${clientAborted}, AbortError: ${isAbortError}):`,
620
+ error?.message
621
+ );
622
+
623
+ if (clientAborted) {
624
+ sendEvent(res, "error", { message: "Request was cancelled before completion." });
625
+ sendEvent(res, "done", { ok: false, cancelled: true });
626
+ return;
627
+ }
628
+
629
+ try {
630
+ // Fallback to non-streaming (user's working snippet format)
631
+ const url = `${model.host}/api/v1/prediction/${model.id}`;
632
+ const payload = {
633
+ question: buildPrompt(message, mode),
634
+ chatId: safeSessionId,
635
+ overrideConfig: safeSessionId ? { sessionId: safeSessionId } : undefined
636
+ };
637
+
638
+ console.log(`[Flowise Fallback] Fetching URL: ${url}`);
639
+
640
+ const extraHeaders = getFlowiseHeaders(model);
641
+ const response = await fetch(url, {
642
+ method: "POST",
643
+ headers: {
644
+ "Content-Type": "application/json",
645
+ ...extraHeaders
646
+ },
647
+ body: JSON.stringify(payload),
648
+ // For fallback, we'll use a fresh fetch without the same abort signal
649
+ // to ensure it reaches Flowise even if the streaming connection had a glitch.
650
+ });
651
+
652
+ if (!response.ok) {
653
+ const text = await response.text().catch(() => "");
654
+ throw new Error(`Flowise fallback error ${response.status}: ${text}`);
655
+ }
656
+
657
+ const result = await response.json();
658
+ console.log("[Flowise Fallback] Success");
659
+
660
+ const finalContent = result.text || result.answer || result.output || result.message || JSON.stringify(result);
661
+
662
+ sendEvent(res, "token", { text: finalContent });
663
+ sendEvent(res, "done", { ok: true });
664
+ } catch (fallbackError) {
665
+ const isFbAbort = fallbackError.name === "AbortError" || fallbackError.message?.includes("aborted");
666
+ console.error(`[Chat] Fallback failed (Abort: ${isFbAbort}):`, fallbackError.message);
667
+ if (!isFbAbort) {
668
+ sendEvent(res, "error", { message: fallbackError.message });
669
+ }
670
+ }
671
+ } finally {
672
+ clearActivities();
673
+ if (!res.writableEnded) res.end();
674
+ }
675
+ });
676
+
677
+ // Labs AI editing endpoint - generates or edits documents based on instruction
678
+ app.post("/labs-edit", async (req, res) => {
679
+ const { document, instruction, modelId, sessionId } = req.body || {};
680
+ const safeSessionId = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim().slice(0, 128) : "";
681
+
682
+ if (!instruction || typeof instruction !== "string" || instruction.length > 10000) {
683
+ return res.status(400).json({ error: "Invalid instruction" });
684
+ }
685
+
686
+ // Use the dedicated Labs model (ignoring client-side modelId)
687
+ const model = loadLabsModel();
688
+ if (!model) {
689
+ return res.status(404).json({ error: "No Labs model configured" });
690
+ }
691
+
692
+ // Build the prompt based on whether document exists
693
+ const isGeneration = !document || document.trim() === "";
694
+
695
+ const editPrompt = isGeneration
696
+ ? instruction // For generation, just pass the instruction directly
697
+ : `CURRENT DOCUMENT:
698
+ ${document}
699
+
700
+ USER INSTRUCTION:
701
+ ${instruction}
702
+
703
+ Apply the instruction to edit the document. Return ONLY the updated document content, no explanations.`;
704
+
705
+ res.writeHead(200, {
706
+ "Content-Type": "text/event-stream",
707
+ "Cache-Control": "no-cache",
708
+ Connection: "keep-alive",
709
+ "X-Accel-Buffering": "no"
710
+ });
711
+
712
+ const controller = new AbortController();
713
+ req.on("aborted", () => controller.abort());
714
+ res.on("close", () => {
715
+ if (!res.writableEnded) controller.abort();
716
+ });
717
+
718
+ try {
719
+ await streamFlowise({
720
+ res,
721
+ model,
722
+ message: editPrompt,
723
+ mode: "chat",
724
+ sessionId: safeSessionId,
725
+ uploads: [],
726
+ signal: controller.signal
727
+ });
728
+ sendEvent(res, "done", { ok: true });
729
+ } catch (error) {
730
+ console.error("[Labs] AI edit failed:", error.message);
731
+ if (!controller.signal.aborted) {
732
+ sendEvent(res, "error", { message: error.message });
733
+ }
734
+ } finally {
735
+ if (!res.writableEnded) res.end();
736
+ }
737
+ });
738
+
739
+ // Labs selection-based AI editing - edits only the selected portion
740
+ app.post("/labs-edit-selection", async (req, res) => {
741
+ const { selectedText, instruction, contextBefore, contextAfter, modelId, sessionId } = req.body || {};
742
+ const safeSessionId = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim().slice(0, 128) : "";
743
+
744
+ if (!selectedText || typeof selectedText !== "string") {
745
+ return res.status(400).json({ error: "No text selected" });
746
+ }
747
+ if (!instruction || typeof instruction !== "string" || instruction.length > 2000) {
748
+ return res.status(400).json({ error: "Invalid instruction" });
749
+ }
750
+
751
+ // Use the dedicated Labs model (ignoring client-side modelId)
752
+ const model = loadLabsModel();
753
+ if (!model) {
754
+ return res.status(404).json({ error: "No Labs model configured" });
755
+ }
756
+
757
+ // Build prompt for surgical editing
758
+ const editPrompt = `You are editing a specific text selection within a larger document.
759
+
760
+ CONTEXT BEFORE THE SELECTION:
761
+ ${contextBefore || "(start of document)"}
762
+
763
+ SELECTED TEXT TO EDIT:
764
+ ${selectedText}
765
+
766
+ CONTEXT AFTER THE SELECTION:
767
+ ${contextAfter || "(end of document)"}
768
+
769
+ USER INSTRUCTION: ${instruction}
770
+
771
+ CRITICAL: Return ONLY the replacement text for the selection. Do not include context, explanations, or markdown code blocks. Just the edited text that will replace the selection.`;
772
+
773
+ res.writeHead(200, {
774
+ "Content-Type": "text/event-stream",
775
+ "Cache-Control": "no-cache",
776
+ Connection: "keep-alive",
777
+ "X-Accel-Buffering": "no"
778
+ });
779
+
780
+ const controller = new AbortController();
781
+ req.on("aborted", () => controller.abort());
782
+ res.on("close", () => {
783
+ if (!res.writableEnded) controller.abort();
784
+ });
785
+
786
+ try {
787
+ await streamFlowise({
788
+ res,
789
+ model,
790
+ message: editPrompt,
791
+ mode: "chat",
792
+ sessionId: safeSessionId,
793
+ uploads: [],
794
+ signal: controller.signal
795
+ });
796
+ sendEvent(res, "done", { ok: true });
797
+ } catch (error) {
798
+ console.error("[Labs] Selection edit failed:", error.message);
799
+ if (!controller.signal.aborted) {
800
+ sendEvent(res, "error", { message: error.message });
801
+ }
802
+ } finally {
803
+ if (!res.writableEnded) res.end();
804
+ }
805
+ });
806
+
807
+ // Non-streaming JSON endpoint compatible with Flowise template usage
808
+ app.post("/predict", async (req, res) => {
809
+
810
+ const { question, modelId, mode = "chat", sessionId } = req.body || {};
811
+ if (
812
+ !question ||
813
+ typeof question !== "string" ||
814
+ question.length > 10000 ||
815
+ !["chat", "research"].includes(mode)
816
+ ) {
817
+ return res.status(400).json({ error: "Invalid request" });
818
+ }
819
+ const safeSessionId = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim().slice(0, 128) : "";
820
+
821
+ // Resolve safe modelId (index-based) to actual model config
822
+ const detailed = loadModelsFromEnvDetailed(process.env);
823
+ let model = null;
824
+ if (typeof modelId === "string" && modelId.trim() !== "") {
825
+ const idx = Number(modelId);
826
+ model = detailed.models.find((item) => item.index === idx) || null;
827
+ }
828
+ // Fallback to first configured model if none provided
829
+ if (!model) {
830
+ model = detailed.models[0] || null;
831
+ }
832
+ if (!model) {
833
+ return res.status(404).json({ error: "Model not found" });
834
+ }
835
+
836
+ const url = `${model.host}/api/v1/prediction/${model.id}`;
837
+ const payload = {
838
+ question: buildPrompt(question, mode),
839
+ chatId: safeSessionId,
840
+ overrideConfig: safeSessionId ? { sessionId: safeSessionId } : undefined
841
+ };
842
+
843
+ const extraHeaders = getFlowiseHeaders(model);
844
+
845
+ try {
846
+ const response = await fetch(url, {
847
+ method: "POST",
848
+ headers: { "Content-Type": "application/json", ...extraHeaders },
849
+ body: JSON.stringify(payload)
850
+ });
851
+
852
+ if (!response.ok) {
853
+ const text = await response.text().catch(() => "");
854
+ return res
855
+ .status(response.status)
856
+ .json({ error: `Upstream error: ${text || response.statusText}` });
857
+ }
858
+
859
+ const result = await response.json();
860
+ return res.json(result);
861
+ } catch (err) {
862
+ return res.status(500).json({ error: err.message || "Server error" });
863
+ }
864
+ });
865
+
866
+ app.get("*", (req, res, next) => {
867
+ if (fs.existsSync(publicDir)) {
868
+ const indexPath = path.join(publicDir, "index.html");
869
+ if (fs.existsSync(indexPath)) {
870
+ return res.sendFile(indexPath);
871
+ }
872
+ }
873
+ next();
874
+ });
875
+
876
+ const port = process.env.PORT || 3000;
877
+ app.listen(port, () => {
878
+ console.log(`Server running on http://localhost:${port}`);
879
+ const { models } = loadModelsFromEnvDetailed(process.env);
880
+ console.log(`Loaded models:`, JSON.stringify(models, null, 2));
881
+ });
server/models.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function normalizeHost(host) {
2
+ if (typeof host !== "string") return "";
3
+ return host
4
+ .trim()
5
+ .replace(/^[\s'"`\u2018\u2019\u201C\u201D]+|[\s'"`\u2018\u2019\u201C\u201D]+$/g, "")
6
+ .replace(/`/g, "")
7
+ .replace(/\/$/, "");
8
+ }
9
+
10
+ function normalizeId(id) {
11
+ if (typeof id !== "string") return "";
12
+ return id
13
+ .trim()
14
+ .replace(/^[\s'"`\u2018\u2019\u201C\u201D]+|[\s'"`\u2018\u2019\u201C\u201D]+$/g, "")
15
+ .replace(/`/g, "");
16
+ }
17
+
18
+ function normalizeHeaderName(value) {
19
+ if (typeof value !== "string") return "";
20
+ return value
21
+ .trim()
22
+ .replace(/^[\s'"`\u2018\u2019\u201C\u201D]+|[\s'"`\u2018\u2019\u201C\u201D]+$/g, "")
23
+ .replace(/`/g, "");
24
+ }
25
+
26
+ function normalizeHeaderValue(value) {
27
+ if (typeof value !== "string") return "";
28
+ return value
29
+ .trim()
30
+ .replace(/^[\s'"`\u2018\u2019\u201C\u201D]+|[\s'"`\u2018\u2019\u201C\u201D]+$/g, "")
31
+ .replace(/`/g, "");
32
+ }
33
+
34
+ function collectModelIndices(env) {
35
+ const indices = new Set();
36
+ for (const key of Object.keys(env || {})) {
37
+ const match = /^MODEL_(\d+)_(NAME|ID|HOST|API_KEY|AUTH_HEADER|AUTH_VALUE)$/.exec(key);
38
+ if (match) {
39
+ indices.add(Number(match[1]));
40
+ }
41
+ }
42
+ return Array.from(indices).filter(Number.isFinite).sort((a, b) => a - b);
43
+ }
44
+
45
+ function loadModelsFromEnvDetailed(env) {
46
+ const indices = collectModelIndices(env);
47
+ const models = [];
48
+ const issues = [];
49
+
50
+ if (indices.length === 0) {
51
+ issues.push("No model environment variables found (MODEL_1_NAME/ID/HOST).");
52
+ return { models, issues };
53
+ }
54
+
55
+ for (const index of indices) {
56
+ const name = env[`MODEL_${index}_NAME`] || `Model ${index}`;
57
+ let id = env[`MODEL_${index}_ID`];
58
+ if (id && typeof id === "string") {
59
+ id = normalizeId(id);
60
+ // If the ID contains a slash, it might be a partial path, extract the last segment
61
+ if (id.includes("/")) {
62
+ id = id.split("/").pop();
63
+ }
64
+ }
65
+ const host = normalizeHost(env[`MODEL_${index}_HOST`]);
66
+ const apiKey = normalizeHeaderValue(env[`MODEL_${index}_API_KEY`]);
67
+ const authHeader = normalizeHeaderName(env[`MODEL_${index}_AUTH_HEADER`]);
68
+ const authValue = normalizeHeaderValue(env[`MODEL_${index}_AUTH_VALUE`]);
69
+
70
+ const missing = [];
71
+ if (!id) missing.push(`MODEL_${index}_ID`);
72
+ if (!host) missing.push(`MODEL_${index}_HOST`);
73
+
74
+ if (missing.length > 0) {
75
+ issues.push(`Model ${index} is incomplete (missing: ${missing.join(", ")}).`);
76
+ continue;
77
+ }
78
+
79
+ models.push({ name, id, host, index, apiKey, authHeader, authValue });
80
+ }
81
+
82
+ if (models.length === 0) {
83
+ issues.push("No complete models found. Check MODEL_n_NAME/ID/HOST.");
84
+ }
85
+
86
+ return { models, issues };
87
+ }
88
+
89
+ function loadModelsFromEnv(env) {
90
+ return loadModelsFromEnvDetailed(env).models;
91
+ }
92
+
93
+ function loadPublicModels(env) {
94
+ const detailed = loadModelsFromEnvDetailed(env);
95
+ const publicModels = detailed.models.map((m) => ({
96
+ id: String(m.index),
97
+ name: m.name
98
+ }));
99
+ return { models: publicModels, issues: detailed.issues };
100
+ }
101
+
102
+ module.exports = {
103
+ loadModelsFromEnv,
104
+ loadModelsFromEnvDetailed,
105
+ loadPublicModels
106
+ };