Upload 28 files
Browse files- .dockerignore +19 -0
- .gitignore +30 -0
- Dockerfile +28 -0
- README.md +106 -7
- client/index.html +21 -0
- client/package.json +38 -0
- client/postcss.config.js +6 -0
- client/src/App.jsx +171 -0
- client/src/components/AudioVisualizer.jsx +156 -0
- client/src/components/ChatArea.jsx +1172 -0
- client/src/components/ChatSidebar.jsx +312 -0
- client/src/components/ErrorBoundary.jsx +86 -0
- client/src/components/LabsArea.jsx +613 -0
- client/src/components/LabsEditor.jsx +931 -0
- client/src/hooks/useChatSession.js +592 -0
- client/src/hooks/useLabsProjects.js +292 -0
- client/src/index.css +913 -0
- client/src/main.jsx +10 -0
- client/src/test_labs_persistence.js +240 -0
- client/src/utils/contentUtils.js +53 -0
- client/src/utils/exportToPdf.js +175 -0
- client/src/utils/exportToWord.js +400 -0
- client/tailwind.config.js +79 -0
- client/vite.config.js +22 -0
- package-lock.json +0 -0
- package.json +21 -0
- server/index.js +881 -0
- server/models.js +106 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
| 1 |
+
# Vector AI 🚀
|
| 2 |
+
|
| 3 |
+
Vector AI is a premium, self-hosted AI workspace that combines the conversational power of ChatGPT with the research capabilities of Perplexity. Built on top of **Flowise**, it provides a stunning, high-performance interface for both casual chat and professional document development.
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+

|
| 7 |
+

|
| 8 |
+

|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## ✨ Key Features
|
| 13 |
+
|
| 14 |
+
### 💬 Advanced Chat Interface
|
| 15 |
+
- **SSE Streaming**: Ultra-fast, real-time response streaming for a seamless experience.
|
| 16 |
+
- **Live Activity Indicators**: See exactly what the AI is doing—whether it's "Thinking", "Searching", or using specific tools.
|
| 17 |
+
- **LaTeX & KaTeX**: Full support for mathematical equations, fractions, and scientific notation with beautiful rendering.
|
| 18 |
+
- **File Uploads**: Support for processing PDF and text files directly within the chat.
|
| 19 |
+
- **Speech-to-Text**: Built-in voice input for hands-free interaction.
|
| 20 |
+
|
| 21 |
+
### 🧪 Vector Labs (Workspaces)
|
| 22 |
+
- **Project-Based Editing**: Create and manage long-form documents in dedicated workspaces.
|
| 23 |
+
- **Surgical AI Edits**: Highlight specific sections of your text and have the AI refine just that selection.
|
| 24 |
+
- **Persistent State**: Projects are saved locally and persist through page refreshes and sessions.
|
| 25 |
+
- **Global Context**: The AI understands the full document while you edit, ensuring consistency.
|
| 26 |
+
|
| 27 |
+
### 📄 Export & Integration
|
| 28 |
+
- **Modern Word Export**: Export your documents to `.docx` with clean, modern typography (Arial/Inter style).
|
| 29 |
+
- **PDF Generation**: Quick PDF exports for sharing your work.
|
| 30 |
+
- **Model Selector**: Easily switch between different Flowise Chatflows for specialized tasks.
|
| 31 |
+
|
| 32 |
---
|
| 33 |
+
|
| 34 |
+
## 🚀 Quick Start
|
| 35 |
+
|
| 36 |
+
### 1. Prerequisites
|
| 37 |
+
- **Node.js** (v18 or higher)
|
| 38 |
+
- A running **Flowise** instance (local, HF Spaces, or Railway)
|
| 39 |
+
|
| 40 |
+
### 2. Installation
|
| 41 |
+
```bash
|
| 42 |
+
# Clone the repository
|
| 43 |
+
git clone https://github.com/your-repo/vector-ai.git
|
| 44 |
+
cd vector-ai
|
| 45 |
+
|
| 46 |
+
# Install dependencies
|
| 47 |
+
npm install
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 3. Configuration
|
| 51 |
+
Create a `.env` file in the root directory:
|
| 52 |
+
|
| 53 |
+
```env
|
| 54 |
+
PORT=3000
|
| 55 |
+
|
| 56 |
+
# Primary Chat Models
|
| 57 |
+
MODEL_1_NAME="Vector GPT"
|
| 58 |
+
MODEL_1_ID="YOUR_CHATFLOW_ID_1"
|
| 59 |
+
MODEL_1_HOST="https://your-flowise.hf.space"
|
| 60 |
+
|
| 61 |
+
MODEL_2_NAME="Vector Research"
|
| 62 |
+
MODEL_2_ID="YOUR_CHATFLOW_ID_2"
|
| 63 |
+
MODEL_2_HOST="https://your-flowise.hf.space"
|
| 64 |
+
|
| 65 |
+
# Dedicated Labs Model (Optional - falls back to Model 1)
|
| 66 |
+
LABS_MODEL_NAME="Vector Editor"
|
| 67 |
+
LABS_MODEL_ID="YOUR_LABS_CHATFLOW_ID"
|
| 68 |
+
LABS_MODEL_HOST="https://your-flowise.hf.space"
|
| 69 |
+
|
| 70 |
+
# Optional: Flowise Authentication
|
| 71 |
+
# FLOWISE_API_KEY=your_api_key
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### 4. Development & Build
|
| 75 |
+
```bash
|
| 76 |
+
# Run in development mode
|
| 77 |
+
npm run dev
|
| 78 |
+
|
| 79 |
+
# Build the frontend and run the production server
|
| 80 |
+
npm run build
|
| 81 |
+
npm start
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 🛠 Tech Stack
|
| 87 |
+
|
| 88 |
+
- **Frontend**: [React](https://reactjs.org/) + [Vite](https://vitejs.dev/)
|
| 89 |
+
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) + [Framer Motion](https://www.framer.com/motion/)
|
| 90 |
+
- **Backend**: [Node.js](https://nodejs.org/) + [Express](https://expressjs.com/)
|
| 91 |
+
- **Streaming**: Server-Sent Events (SSE)
|
| 92 |
+
- **AI Orchestration**: [Flowise](https://flowiseai.com/)
|
| 93 |
+
- **Rich Text**: [React Markdown](https://github.com/remarkjs/react-markdown) + [KaTeX](https://katex.org/)
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## ☁️ Deployment
|
| 98 |
+
|
| 99 |
+
### Render / Railway / HF Spaces
|
| 100 |
+
This project is designed to be easily deployable as a single service:
|
| 101 |
+
|
| 102 |
+
1. **Build Command**: `npm install && npm run build`
|
| 103 |
+
2. **Start Command**: `npm start`
|
| 104 |
+
3. **Environment Variables**: Add your `MODEL_*` variables in the platform's dashboard.
|
| 105 |
+
|
| 106 |
---
|
| 107 |
|
| 108 |
+
## 📝 License
|
| 109 |
+
This project is licensed under the MIT License.
|
client/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, "&")
|
| 84 |
+
.replace(/</g, "<")
|
| 85 |
+
.replace(/>/g, ">");
|
| 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 |
+
};
|