Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .dockerignore +9 -0
- .gitignore +41 -0
- Dockerfile +24 -0
- LICENSE +21 -0
- README.md +418 -5
- components.json +23 -0
- eslint.config.mjs +18 -0
- next.config.ts +7 -0
- package-lock.json +0 -0
- package.json +51 -0
- postcss.config.mjs +7 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/icon.svg +25 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/app/api/chat/route.ts +172 -0
- src/app/api/upload/route.ts +48 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +116 -0
- src/app/layout.tsx +27 -0
- src/app/page.tsx +5 -0
- src/components/chat/chat-area.tsx +59 -0
- src/components/chat/chat-input.tsx +149 -0
- src/components/chat/chat-message.tsx +43 -0
- src/components/chat/chat-page.tsx +362 -0
- src/components/chat/file-badge.tsx +46 -0
- src/components/chat/file-preview.tsx +34 -0
- src/components/chat/markdown-renderer.tsx +132 -0
- src/components/chat/settings-dialog.tsx +159 -0
- src/components/chat/sidebar.tsx +160 -0
- src/components/chat/welcome-screen.tsx +19 -0
- src/components/ui/avatar.tsx +109 -0
- src/components/ui/badge.tsx +48 -0
- src/components/ui/button.tsx +64 -0
- src/components/ui/dialog.tsx +158 -0
- src/components/ui/scroll-area.tsx +58 -0
- src/components/ui/separator.tsx +28 -0
- src/components/ui/sheet.tsx +143 -0
- src/components/ui/textarea.tsx +18 -0
- src/components/ui/tooltip.tsx +57 -0
- src/hooks/use-chat-store.ts +191 -0
- src/lib/constants.ts +71 -0
- src/lib/file-processor.ts +258 -0
- src/lib/types.ts +25 -0
- src/lib/utils.ts +6 -0
- tsconfig.json +34 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
.git
|
| 4 |
+
.gitignore
|
| 5 |
+
*.md
|
| 6 |
+
!README.md
|
| 7 |
+
.env
|
| 8 |
+
.env.*
|
| 9 |
+
.DS_Store
|
.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:18-alpine
|
| 2 |
+
|
| 3 |
+
# Install system dependencies for PDF and OCR processing
|
| 4 |
+
RUN apk add --no-cache \
|
| 5 |
+
poppler-utils \
|
| 6 |
+
tesseract-ocr \
|
| 7 |
+
tesseract-ocr-data-eng \
|
| 8 |
+
tesseract-ocr-data-ind
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Copy package files and install dependencies
|
| 13 |
+
COPY package*.json ./
|
| 14 |
+
RUN npm ci
|
| 15 |
+
|
| 16 |
+
# Copy source code and build
|
| 17 |
+
COPY . .
|
| 18 |
+
RUN npm run build
|
| 19 |
+
|
| 20 |
+
# HF Spaces uses port 7860
|
| 21 |
+
ENV PORT=7860
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
CMD ["npm", "start"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Romi Nur Ismanto
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,423 @@
|
|
| 1 |
---
|
| 2 |
title: Open Chatbot
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Open Chatbot
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
---
|
| 9 |
|
| 10 |
+
<div align="center">
|
| 11 |
+
|
| 12 |
+
# <img src="public/icon.svg" width="40" height="40" alt="icon" /> Open Chatbot
|
| 13 |
+
|
| 14 |
+
### Zero File Leakage AI Chatbot — Privacy by Design
|
| 15 |
+
|
| 16 |
+
**Your documents never leave your server. Only text goes to the cloud.**
|
| 17 |
+
|
| 18 |
+
This architectural approach adopts the principle of **Privacy by Design**, where all documents are processed locally on the user's server before interacting with AI models through an API. The original files — whether PDF, Word, Excel, or OCR-extracted text from images — are **never transmitted** to the AI provider. The system only sends parsed and sliced text segments structured as JSON. Only the textual content relevant for reasoning is transmitted to the API, without including raw files, full document structures, or any sensitive metadata.
|
| 19 |
+
|
| 20 |
+
[](https://nextjs.org/)
|
| 21 |
+
[](https://react.dev/)
|
| 22 |
+
[](https://www.typescriptlang.org/)
|
| 23 |
+
[](https://sdk.vercel.ai/)
|
| 24 |
+
[](LICENSE)
|
| 25 |
+
|
| 26 |
+
[The Problem](#-the-problem-document-data-leakage) · [The Solution](#-how-open-chatbot-solves-it) · [How It Works](#-how-it-works-technically) · [Quick Start](#-quick-start) · [Features](#-full-features)
|
| 27 |
+
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 🚨 The Problem: Document Data Leakage
|
| 33 |
+
|
| 34 |
+
Many companies and individuals want to leverage AI to analyze internal documents — financial reports, contracts, HR data, medical records, legal documents — but face serious risks:
|
| 35 |
+
|
| 36 |
+
| Risk | Explanation |
|
| 37 |
+
|------|------------|
|
| 38 |
+
| **Files sent raw to cloud** | When uploading PDF/Word to ChatGPT, Claude, or other AI, the original file is sent to their servers |
|
| 39 |
+
| **Binary files stored on AI servers** | `.pdf`, `.docx`, `.xlsx` files are stored temporarily or permanently on AI provider infrastructure |
|
| 40 |
+
| **Metadata leakage** | Filenames, author info, revision history, hidden comments are all transmitted |
|
| 41 |
+
| **No control** | Once a file is sent, you have no control over data retention and usage |
|
| 42 |
+
| **Compliance violation** | Violates GDPR, HIPAA, SOC 2, or internal corporate policies |
|
| 43 |
+
|
| 44 |
+
> **Real-world example:** You upload a Q4 financial report to ChatGPT. The 5MB PDF is sent in full to OpenAI's servers — including metadata, embedded images, hidden text, and revision history. You have no idea how long that file is retained.
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## ✅ How Open Chatbot Solves It
|
| 49 |
+
|
| 50 |
+
Open Chatbot uses a **Local Processing + Text-Only Inference** architecture that ensures original files never leave your infrastructure:
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
YOUR INFRASTRUCTURE (On-Premise / VPS) CLOUD (AI Provider)
|
| 54 |
+
========================================== =======================
|
| 55 |
+
|
| 56 |
+
📄 PDF 📝 DOCX 📊 XLSX 🖼️ Image OpenAI / Claude /
|
| 57 |
+
| DeepSeek API
|
| 58 |
+
v
|
| 59 |
+
+-----------------------+ Only receives:
|
| 60 |
+
| LOCAL FILE PROCESSOR | ✅ Sliced text (JSON)
|
| 61 |
+
| • PDF → pdftotext | ✅ User questions
|
| 62 |
+
| • DOCX → mammoth | text JSON ✅ System prompt
|
| 63 |
+
| • XLSX → SheetJS | ─────────────────────►
|
| 64 |
+
| • Image → Tesseract | (max 30KB/file) Never receives:
|
| 65 |
+
| • OCR scanned docs | ❌ Original files (binary)
|
| 66 |
+
+-----------------------+ ❌ Images / scans
|
| 67 |
+
| ❌ Document metadata
|
| 68 |
+
v ❌ Revision history
|
| 69 |
+
🗑️ File deleted from ❌ Hidden content
|
| 70 |
+
memory after ❌ Embedded objects
|
| 71 |
+
text extraction
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### Security Principles
|
| 75 |
+
|
| 76 |
+
| Principle | Implementation |
|
| 77 |
+
|-----------|---------------|
|
| 78 |
+
| **Files never sent to AI** | Files are processed locally → only extracted text is transmitted |
|
| 79 |
+
| **Text is sliced** | Each file is capped at max **30KB of text** before being sent as a JSON chunk |
|
| 80 |
+
| **No server storage** | Files are buffered in memory, extracted, then **immediately deleted** from temp |
|
| 81 |
+
| **Stateless API inference** | DeepSeek, OpenAI, Claude APIs do not store data from API calls |
|
| 82 |
+
| **API keys in browser** | Keys stored in browser localStorage, never on the server |
|
| 83 |
+
| **Zero binary transfer** | What's sent to AI: `{"role":"system","content":"text..."}` — not files |
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
## 🔬 How It Works Technically
|
| 88 |
+
|
| 89 |
+
### 1. Upload & Local Processing
|
| 90 |
+
|
| 91 |
+
When a user uploads a file, **all processing happens on your own server**:
|
| 92 |
+
|
| 93 |
+
```
|
| 94 |
+
POST /api/upload → FormData { files: [File] }
|
| 95 |
+
|
|
| 96 |
+
v
|
| 97 |
+
┌─────────────────────┐
|
| 98 |
+
│ file-processor.ts │
|
| 99 |
+
│ │
|
| 100 |
+
│ PDF ──► pdftotext │ CLI tool (poppler)
|
| 101 |
+
│ PDF ──► OCR │ tesseract CLI (fallback for scanned docs)
|
| 102 |
+
│ DOCX ──► mammoth │ npm library
|
| 103 |
+
│ DOC ──► word-ext │ npm library
|
| 104 |
+
│ XLSX ──► SheetJS │ npm library
|
| 105 |
+
│ IMG ──► tesseract │ OCR engine
|
| 106 |
+
│ TXT ──► buffer │ native Node.js
|
| 107 |
+
└─────────┬───────────┘
|
| 108 |
+
│
|
| 109 |
+
v
|
| 110 |
+
{ id, filename, text, size } ← JSON response
|
| 111 |
+
(text only, not the file)
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
**What happens in memory:**
|
| 115 |
+
1. File is buffered as a `Buffer` in RAM
|
| 116 |
+
2. Text is extracted by the appropriate library
|
| 117 |
+
3. Buffer and temp files are **immediately deleted** after extraction
|
| 118 |
+
4. Only a `string` of text is returned to the client
|
| 119 |
+
|
| 120 |
+
### 2. Sending to AI Provider
|
| 121 |
+
|
| 122 |
+
When the user sends a message, **only JSON text is sent to the AI API**:
|
| 123 |
+
|
| 124 |
+
```typescript
|
| 125 |
+
// What is ACTUALLY sent to the API (from chat/route.ts)
|
| 126 |
+
{
|
| 127 |
+
"model": "deepseek-chat",
|
| 128 |
+
"system": "You are an AI assistant...\n\n=== File: report.pdf ===\nExtracted text here (max 30KB)...\n=== End File ===",
|
| 129 |
+
"messages": [
|
| 130 |
+
{ "role": "user", "content": "Analyze this financial report" }
|
| 131 |
+
],
|
| 132 |
+
"max_tokens": 8192,
|
| 133 |
+
"stream": true
|
| 134 |
+
}
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
**Notice:**
|
| 138 |
+
- There is no `file`, `attachment`, or `binary` in the payload
|
| 139 |
+
- The `system` field contains **plain text** from extraction, not a file
|
| 140 |
+
- Each file context is **truncated** to max 30,000 characters (`fc.text.slice(0, 30000)`)
|
| 141 |
+
- Responses are streamed as `text-delta` chunks — not stored on the server
|
| 142 |
+
|
| 143 |
+
### 3. Why AI Providers Don't Store Your Data
|
| 144 |
+
|
| 145 |
+
| Provider | API Policy |
|
| 146 |
+
|----------|-----------|
|
| 147 |
+
| **OpenAI** | Data from API calls is **not used for training** and not permanently stored (unlike the ChatGPT web interface) |
|
| 148 |
+
| **Anthropic** | API calls are **not stored** for model training. Limited retention for abuse monitoring only |
|
| 149 |
+
| **DeepSeek** | API follows minimal data retention policy for inference |
|
| 150 |
+
|
| 151 |
+
> **Note:** This applies to usage via **API** (which Open Chatbot uses), not via web interfaces (ChatGPT/Claude web). Web interfaces have different policies.
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## 📊 Comparison: Open Chatbot vs Direct Upload to AI
|
| 156 |
+
|
| 157 |
+
| Aspect | Upload to ChatGPT/Claude Web | Open Chatbot |
|
| 158 |
+
|--------|------------------------------|-------------|
|
| 159 |
+
| Original file sent to cloud | ✅ Yes, full file | ❌ No, text only |
|
| 160 |
+
| Binary/images transmitted | ✅ Yes | ❌ No |
|
| 161 |
+
| Document metadata transmitted | ✅ Yes (author, revisions) | ❌ No |
|
| 162 |
+
| Data used for training | ⚠️ Possibly (depends on settings) | ❌ No (API mode) |
|
| 163 |
+
| File stored on AI server | ⚠️ Temporarily/permanently | ❌ No file at all |
|
| 164 |
+
| Data retention control | ❌ Minimal | ✅ Full (self-hosted) |
|
| 165 |
+
| Compliance friendly | ⚠️ Requires review | ✅ Data stays on-premise |
|
| 166 |
+
| Data size transmitted | Full file (MBs) | Text only (max 30KB/file) |
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## 🚀 Quick Start
|
| 171 |
+
|
| 172 |
+
### Prerequisites
|
| 173 |
+
|
| 174 |
+
- **Node.js** 18+
|
| 175 |
+
- An API key from at least one provider: [DeepSeek](https://platform.deepseek.com/), [OpenAI](https://platform.openai.com/), or [Anthropic](https://console.anthropic.com/)
|
| 176 |
+
- *(Optional)* `poppler-utils` and `tesseract-ocr` for PDF OCR
|
| 177 |
+
|
| 178 |
+
### Install & Run
|
| 179 |
+
|
| 180 |
+
```bash
|
| 181 |
+
# Clone the repository
|
| 182 |
+
git clone https://github.com/romizone/chatbot-next.git
|
| 183 |
+
cd chatbot-next
|
| 184 |
+
|
| 185 |
+
# Install dependencies
|
| 186 |
+
npm install
|
| 187 |
+
|
| 188 |
+
# Start development server
|
| 189 |
+
npm run dev
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
Open [http://localhost:3000](http://localhost:3000) → click **Settings** → select a provider → enter your API key → start chatting!
|
| 193 |
+
|
| 194 |
+
### Environment Variables (Optional)
|
| 195 |
+
|
| 196 |
+
Create a `.env.local` file for server-side fallback keys:
|
| 197 |
+
|
| 198 |
+
```env
|
| 199 |
+
DEEPSEEK_API_KEY=sk-...
|
| 200 |
+
OPENAI_API_KEY=sk-...
|
| 201 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### Install OCR Tools (Optional, for scanned PDFs & images)
|
| 205 |
+
|
| 206 |
+
```bash
|
| 207 |
+
# macOS
|
| 208 |
+
brew install poppler tesseract tesseract-lang
|
| 209 |
+
|
| 210 |
+
# Ubuntu/Debian
|
| 211 |
+
sudo apt install poppler-utils tesseract-ocr tesseract-ocr-eng
|
| 212 |
+
|
| 213 |
+
# Windows (via chocolatey)
|
| 214 |
+
choco install poppler tesseract
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
---
|
| 218 |
+
|
| 219 |
+
## 🎯 Full Features
|
| 220 |
+
|
| 221 |
+
### Multi-Provider AI Engine
|
| 222 |
+
|
| 223 |
+
| Provider | Models | Max Output |
|
| 224 |
+
|----------|--------|------------|
|
| 225 |
+
| **DeepSeek** | DeepSeek Chat, DeepSeek Reasoner | 8K - 16K tokens |
|
| 226 |
+
| **OpenAI** | GPT-4o, GPT-4o Mini, GPT-4.1, GPT-4.1 Mini | 16K - 32K tokens |
|
| 227 |
+
| **Anthropic** | Claude Sonnet 4.5, Claude Haiku 4.5 | 8K - 16K tokens |
|
| 228 |
+
|
| 229 |
+
### Local Document Processing
|
| 230 |
+
|
| 231 |
+
| Format | Engine | Capability |
|
| 232 |
+
|--------|--------|-----------|
|
| 233 |
+
| PDF | `pdftotext` CLI + Tesseract OCR | Text extraction + OCR for scanned PDFs (max 20 pages) |
|
| 234 |
+
| DOCX | `mammoth` | Full text and formatting extraction |
|
| 235 |
+
| DOC | `word-extractor` | Legacy Word document support |
|
| 236 |
+
| XLSX/XLS | `xlsx` (SheetJS) | Spreadsheet to structured text (CSV per sheet) |
|
| 237 |
+
| CSV | `xlsx` (SheetJS) | Direct parsing |
|
| 238 |
+
| Images | `tesseract` CLI | OCR: PNG, JPG, BMP, TIFF, WebP |
|
| 239 |
+
| Text | Native `Buffer` | TXT, MD, JSON, XML, HTML, source code (20+ formats) |
|
| 240 |
+
|
| 241 |
+
### Core Features
|
| 242 |
+
|
| 243 |
+
- **Zero File Leakage** — Files processed locally, only text sent to AI via JSON
|
| 244 |
+
- **Per-Provider API Keys** — Each provider has its own key slot in browser localStorage
|
| 245 |
+
- **Real-time Connection Indicator** — Green/red status in sidebar
|
| 246 |
+
- **Auto-Continue** — Detects truncated responses and automatically requests continuation
|
| 247 |
+
- **LaTeX Math (KaTeX)** — Mathematical formula rendering: `$inline$` and `$$block$$`
|
| 248 |
+
- **Syntax Highlighting** — Code blocks with language detection (Prism theme)
|
| 249 |
+
- **Multi-Session** — Chat history with multiple sessions
|
| 250 |
+
- **File Context Persistence** — File contexts preserved per session
|
| 251 |
+
- **Responsive UI** — Collapsible sidebar, Tailwind CSS + Radix UI
|
| 252 |
+
- **Streaming Response** — Real-time responses via Vercel AI SDK `streamText`
|
| 253 |
+
|
| 254 |
+
---
|
| 255 |
+
|
| 256 |
+
## 🏗️ Architecture
|
| 257 |
+
|
| 258 |
+
```
|
| 259 |
+
chatbot-next/
|
| 260 |
+
├── src/
|
| 261 |
+
│ ├── app/
|
| 262 |
+
│ │ ├── api/
|
| 263 |
+
│ │ │ ├── chat/route.ts # Streaming chat endpoint (multi-provider)
|
| 264 |
+
│ │ │ └── upload/route.ts # Local file processing endpoint
|
| 265 |
+
│ │ ├── layout.tsx
|
| 266 |
+
│ │ └── page.tsx
|
| 267 |
+
│ ├── components/
|
| 268 |
+
│ │ ├── chat/
|
| 269 |
+
│ │ │ ├── chat-page.tsx # Main orchestrator
|
| 270 |
+
│ │ │ ├── chat-area.tsx # Message display area
|
| 271 |
+
│ │ │ ├── chat-input.tsx # Input with file upload
|
| 272 |
+
│ │ │ ├── chat-message.tsx # Message bubble
|
| 273 |
+
│ │ │ ├── markdown-renderer.tsx # Markdown + KaTeX + syntax highlight
|
| 274 |
+
│ │ │ ├── settings-dialog.tsx # Provider & API key settings
|
| 275 |
+
│ │ │ ├── sidebar.tsx # Session list + connection indicator
|
| 276 |
+
│ │ │ └── welcome-screen.tsx # Landing screen
|
| 277 |
+
│ │ └── ui/ # Radix UI primitives
|
| 278 |
+
│ ├── hooks/
|
| 279 |
+
│ │ └── use-chat-store.ts # State management (localStorage)
|
| 280 |
+
│ └── lib/
|
| 281 |
+
│ ├── constants.ts # Model list, system prompt, defaults
|
| 282 |
+
│ ├── file-processor.ts # Local document processing engine
|
| 283 |
+
│ └── types.ts # TypeScript interfaces
|
| 284 |
+
├── public/
|
| 285 |
+
├── package.json
|
| 286 |
+
└── README.md
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
### Data Flow
|
| 290 |
+
|
| 291 |
+
```
|
| 292 |
+
User uploads file ─────────────────────────────────────────────────
|
| 293 |
+
│
|
| 294 |
+
▼
|
| 295 |
+
POST /api/upload
|
| 296 |
+
│
|
| 297 |
+
▼
|
| 298 |
+
file-processor.ts ── [PDF → pdftotext] ── [DOCX → mammoth]
|
| 299 |
+
│ [XLSX → SheetJS] [IMG → tesseract OCR]
|
| 300 |
+
│
|
| 301 |
+
▼
|
| 302 |
+
JSON response: { filename, text, size } ← text only, file deleted
|
| 303 |
+
│
|
| 304 |
+
▼
|
| 305 |
+
Browser stores text in memory
|
| 306 |
+
│
|
| 307 |
+
▼
|
| 308 |
+
User sends message
|
| 309 |
+
│
|
| 310 |
+
▼
|
| 311 |
+
POST /api/chat ──► { messages, provider, model, apiKey, fileContexts }
|
| 312 |
+
│
|
| 313 |
+
▼
|
| 314 |
+
Build system prompt + file text (sliced max 30KB/file)
|
| 315 |
+
│
|
| 316 |
+
▼
|
| 317 |
+
streamText() to AI Provider ──► text-delta chunks ──► UI
|
| 318 |
+
│
|
| 319 |
+
▼
|
| 320 |
+
AI only receives JSON text, never file binaries
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
---
|
| 324 |
+
|
| 325 |
+
## 🐳 Deployment
|
| 326 |
+
|
| 327 |
+
### Self-Hosted (Recommended for Enterprise)
|
| 328 |
+
|
| 329 |
+
For maximum data privacy, deploy on your own infrastructure:
|
| 330 |
+
|
| 331 |
+
```bash
|
| 332 |
+
npm run build
|
| 333 |
+
npm start
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
### Docker
|
| 337 |
+
|
| 338 |
+
```dockerfile
|
| 339 |
+
FROM node:18-alpine
|
| 340 |
+
|
| 341 |
+
# Install OCR tools (optional)
|
| 342 |
+
RUN apk add --no-cache poppler-utils tesseract-ocr
|
| 343 |
+
|
| 344 |
+
WORKDIR /app
|
| 345 |
+
COPY package*.json ./
|
| 346 |
+
RUN npm ci --only=production
|
| 347 |
+
COPY . .
|
| 348 |
+
RUN npm run build
|
| 349 |
+
EXPOSE 3000
|
| 350 |
+
CMD ["npm", "start"]
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
```bash
|
| 354 |
+
docker build -t open-chatbot .
|
| 355 |
+
docker run -p 3000:3000 open-chatbot
|
| 356 |
+
```
|
| 357 |
+
|
| 358 |
+
### Vercel
|
| 359 |
+
|
| 360 |
+
```bash
|
| 361 |
+
npx vercel
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
> **Note:** On Vercel (serverless), OCR features require the Tesseract binary which may not be available. Use Docker deployment for full OCR support.
|
| 365 |
+
|
| 366 |
+
---
|
| 367 |
+
|
| 368 |
+
## 🔒 Security Summary
|
| 369 |
+
|
| 370 |
+
| Aspect | Detail |
|
| 371 |
+
|--------|--------|
|
| 372 |
+
| **File Processing** | 100% local on your server, files deleted after extraction |
|
| 373 |
+
| **Data to AI Provider** | Plain text JSON chunks only (max 30KB/file) |
|
| 374 |
+
| **API Keys** | Stored in browser localStorage, sent per-request via HTTPS |
|
| 375 |
+
| **File Binaries** | Never sent to AI providers |
|
| 376 |
+
| **Document Metadata** | Stripped during extraction — only text content |
|
| 377 |
+
| **Temp Files** | Auto-cleanup after processing (using `finally` blocks) |
|
| 378 |
+
| **Data Retention** | Chat history in browser localStorage only |
|
| 379 |
+
| **Network Payload** | JSON `{ role, content }` — not multipart/form-data files |
|
| 380 |
+
|
| 381 |
+
---
|
| 382 |
+
|
| 383 |
+
## 🛠️ Tech Stack
|
| 384 |
+
|
| 385 |
+
| Layer | Technology |
|
| 386 |
+
|-------|-----------|
|
| 387 |
+
| **Framework** | Next.js 16 (App Router, Turbopack) |
|
| 388 |
+
| **UI** | React 19, Tailwind CSS 4, Radix UI, Lucide Icons |
|
| 389 |
+
| **AI Integration** | Vercel AI SDK 6 (`streamText`, `createUIMessageStream`) |
|
| 390 |
+
| **Document Processing** | pdftotext (poppler), mammoth, word-extractor, SheetJS, Tesseract |
|
| 391 |
+
| **Math Rendering** | KaTeX, remark-math, rehype-katex |
|
| 392 |
+
| **Code Highlighting** | react-syntax-highlighter (Prism) |
|
| 393 |
+
| **Language** | TypeScript 5 (strict mode) |
|
| 394 |
+
|
| 395 |
+
---
|
| 396 |
+
|
| 397 |
+
## 🤝 Contributing
|
| 398 |
+
|
| 399 |
+
Contributions are welcome! Feel free to submit a Pull Request.
|
| 400 |
+
|
| 401 |
+
1. Fork the repository
|
| 402 |
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
| 403 |
+
3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
|
| 404 |
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
| 405 |
+
5. Open a Pull Request
|
| 406 |
+
|
| 407 |
+
---
|
| 408 |
+
|
| 409 |
+
## 📄 License
|
| 410 |
+
|
| 411 |
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
| 412 |
+
|
| 413 |
+
---
|
| 414 |
+
|
| 415 |
+
<div align="center">
|
| 416 |
+
|
| 417 |
+
**Built to prevent document data leakage when using AI.**
|
| 418 |
+
|
| 419 |
+
Your files stay on your server. Only text goes to the cloud.
|
| 420 |
+
|
| 421 |
+
Made with ❤️ by [Romi Nur Ismanto](https://github.com/romizone)
|
| 422 |
+
|
| 423 |
+
</div>
|
components.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"rtl": false,
|
| 15 |
+
"aliases": {
|
| 16 |
+
"components": "@/components",
|
| 17 |
+
"utils": "@/lib/utils",
|
| 18 |
+
"ui": "@/components/ui",
|
| 19 |
+
"lib": "@/lib",
|
| 20 |
+
"hooks": "@/hooks"
|
| 21 |
+
},
|
| 22 |
+
"registries": {}
|
| 23 |
+
}
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "chatbot-next",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"scripts": {
|
| 5 |
+
"dev": "next dev",
|
| 6 |
+
"build": "next build",
|
| 7 |
+
"start": "next start",
|
| 8 |
+
"lint": "eslint"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"@ai-sdk/anthropic": "^3.0.44",
|
| 12 |
+
"@ai-sdk/deepseek": "^2.0.20",
|
| 13 |
+
"@ai-sdk/openai": "^3.0.29",
|
| 14 |
+
"@ai-sdk/react": "^3.0.88",
|
| 15 |
+
"ai": "^6.0.86",
|
| 16 |
+
"class-variance-authority": "^0.7.1",
|
| 17 |
+
"clsx": "^2.1.1",
|
| 18 |
+
"katex": "^0.16.28",
|
| 19 |
+
"lucide-react": "^0.564.0",
|
| 20 |
+
"mammoth": "^1.11.0",
|
| 21 |
+
"next": "16.1.6",
|
| 22 |
+
"pdf-parse": "^2.4.5",
|
| 23 |
+
"radix-ui": "^1.4.3",
|
| 24 |
+
"react": "19.2.3",
|
| 25 |
+
"react-dom": "19.2.3",
|
| 26 |
+
"react-markdown": "^10.1.0",
|
| 27 |
+
"react-syntax-highlighter": "^16.1.0",
|
| 28 |
+
"rehype-katex": "^7.0.1",
|
| 29 |
+
"remark-gfm": "^4.0.1",
|
| 30 |
+
"remark-math": "^6.0.0",
|
| 31 |
+
"tailwind-merge": "^3.4.0",
|
| 32 |
+
"tesseract.js": "^5.1.1",
|
| 33 |
+
"uuid": "^13.0.0",
|
| 34 |
+
"word-extractor": "^1.0.4",
|
| 35 |
+
"xlsx": "^0.18.5"
|
| 36 |
+
},
|
| 37 |
+
"devDependencies": {
|
| 38 |
+
"@tailwindcss/postcss": "^4",
|
| 39 |
+
"@types/node": "^20",
|
| 40 |
+
"@types/react": "^19",
|
| 41 |
+
"@types/react-dom": "^19",
|
| 42 |
+
"@types/react-syntax-highlighter": "^15.5.13",
|
| 43 |
+
"@types/uuid": "^10.0.0",
|
| 44 |
+
"eslint": "^9",
|
| 45 |
+
"eslint-config-next": "16.1.6",
|
| 46 |
+
"shadcn": "^3.8.4",
|
| 47 |
+
"tailwindcss": "^4",
|
| 48 |
+
"tw-animate-css": "^1.4.0",
|
| 49 |
+
"typescript": "^5"
|
| 50 |
+
}
|
| 51 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/icon.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/app/api/chat/route.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { streamText, type UIMessage, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
| 2 |
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
| 3 |
+
import { createOpenAI } from "@ai-sdk/openai";
|
| 4 |
+
import { createDeepSeek } from "@ai-sdk/deepseek";
|
| 5 |
+
import { SYSTEM_PROMPT } from "@/lib/constants";
|
| 6 |
+
|
| 7 |
+
export const maxDuration = 300;
|
| 8 |
+
|
| 9 |
+
export async function POST(req: Request) {
|
| 10 |
+
try {
|
| 11 |
+
const body = await req.json();
|
| 12 |
+
const { messages, provider, model, apiKey, fileContexts } = body;
|
| 13 |
+
|
| 14 |
+
let systemPrompt = SYSTEM_PROMPT;
|
| 15 |
+
|
| 16 |
+
if (fileContexts && fileContexts.length > 0) {
|
| 17 |
+
const parts = fileContexts
|
| 18 |
+
.filter((fc: { error: string | null; text: string }) => !fc.error && fc.text)
|
| 19 |
+
.map((fc: { filename: string; text: string }) => {
|
| 20 |
+
const text = fc.text.length > 30000 ? fc.text.slice(0, 30000) : fc.text;
|
| 21 |
+
return `=== File: ${fc.filename} ===\n${text}\n=== End File ===`;
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
if (parts.length > 0) {
|
| 25 |
+
systemPrompt +=
|
| 26 |
+
"\n\nBerikut adalah isi file yang di-upload oleh user:\n\n" +
|
| 27 |
+
parts.join("\n\n");
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const providerName = provider || "deepseek";
|
| 32 |
+
const modelName = model || "deepseek-chat";
|
| 33 |
+
|
| 34 |
+
console.log("[chat] provider:", providerName, "model:", modelName, "files:", fileContexts?.length ?? 0);
|
| 35 |
+
|
| 36 |
+
// Create provider instance per-request with API key from client (fallback to env var)
|
| 37 |
+
let modelInstance;
|
| 38 |
+
if (providerName === "deepseek") {
|
| 39 |
+
const key = apiKey || process.env.DEEPSEEK_API_KEY;
|
| 40 |
+
if (!key) {
|
| 41 |
+
return new Response(
|
| 42 |
+
JSON.stringify({ error: "DeepSeek API key belum diisi. Buka Pengaturan untuk memasukkan API key." }),
|
| 43 |
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
const ds = createDeepSeek({ apiKey: key });
|
| 47 |
+
modelInstance = ds(modelName);
|
| 48 |
+
} else if (providerName === "openai") {
|
| 49 |
+
const key = apiKey || process.env.OPENAI_API_KEY;
|
| 50 |
+
if (!key) {
|
| 51 |
+
return new Response(
|
| 52 |
+
JSON.stringify({ error: "OpenAI API key belum diisi. Buka Pengaturan untuk memasukkan API key." }),
|
| 53 |
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
const oai = createOpenAI({ apiKey: key });
|
| 57 |
+
modelInstance = oai(modelName);
|
| 58 |
+
} else {
|
| 59 |
+
const key = apiKey || process.env.CHATBOT_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
|
| 60 |
+
if (!key) {
|
| 61 |
+
return new Response(
|
| 62 |
+
JSON.stringify({ error: "Claude API key belum diisi. Buka Pengaturan untuk memasukkan API key." }),
|
| 63 |
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
const anth = createAnthropic({
|
| 67 |
+
baseURL: "https://api.anthropic.com/v1",
|
| 68 |
+
apiKey: key,
|
| 69 |
+
});
|
| 70 |
+
modelInstance = anth(modelName);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Convert UIMessages to model messages
|
| 74 |
+
const modelMessages = await convertToModelMessages(
|
| 75 |
+
(messages || []) as UIMessage[]
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
// Max output tokens per model
|
| 79 |
+
const MAX_TOKENS: Record<string, number> = {
|
| 80 |
+
"deepseek-chat": 8192,
|
| 81 |
+
"deepseek-reasoner": 16384,
|
| 82 |
+
"gpt-4o": 16384,
|
| 83 |
+
"gpt-4o-mini": 16384,
|
| 84 |
+
"gpt-4.1": 32768,
|
| 85 |
+
"gpt-4.1-mini": 32768,
|
| 86 |
+
"claude-sonnet-4-5-20250929": 16384,
|
| 87 |
+
"claude-haiku-4-5-20251001": 8192,
|
| 88 |
+
};
|
| 89 |
+
const maxTokens = MAX_TOKENS[modelName] || 8192;
|
| 90 |
+
|
| 91 |
+
const result = streamText({
|
| 92 |
+
model: modelInstance,
|
| 93 |
+
system: systemPrompt,
|
| 94 |
+
messages: modelMessages,
|
| 95 |
+
maxOutputTokens: maxTokens,
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
// Wrap the stream: intercept chunks and inject [LANJUT] BEFORE finish-step
|
| 99 |
+
// when finishReason is "length" (token limit hit)
|
| 100 |
+
return createUIMessageStreamResponse({
|
| 101 |
+
stream: createUIMessageStream({
|
| 102 |
+
execute: async ({ writer }) => {
|
| 103 |
+
// Start resolving finishReason in parallel (resolves when generation completes)
|
| 104 |
+
const finishReasonPromise = result.finishReason;
|
| 105 |
+
|
| 106 |
+
const uiStream = result.toUIMessageStream();
|
| 107 |
+
const reader = uiStream.getReader();
|
| 108 |
+
type UIStreamChunk = Parameters<typeof writer.write>[0];
|
| 109 |
+
// Buffer: hold back finish-step/finish events so we can inject before them
|
| 110 |
+
const buffered: UIStreamChunk[] = [];
|
| 111 |
+
let foundFinishStep = false;
|
| 112 |
+
let lastTextId = "";
|
| 113 |
+
|
| 114 |
+
try {
|
| 115 |
+
while (true) {
|
| 116 |
+
const { done, value } = await reader.read();
|
| 117 |
+
if (done) break;
|
| 118 |
+
|
| 119 |
+
// Check if this chunk is a finish-step or finish event
|
| 120 |
+
const chunk = value as { type?: string; id?: string };
|
| 121 |
+
if (chunk.type === "finish-step" || chunk.type === "finish") {
|
| 122 |
+
buffered.push(value);
|
| 123 |
+
foundFinishStep = true;
|
| 124 |
+
continue;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Track the id of the last text-delta for injection
|
| 128 |
+
if (chunk.type === "text-delta" && chunk.id) {
|
| 129 |
+
lastTextId = chunk.id;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// If we already found finish-step but get more chunks, flush buffer first
|
| 133 |
+
if (foundFinishStep) {
|
| 134 |
+
for (const b of buffered) writer.write(b);
|
| 135 |
+
buffered.length = 0;
|
| 136 |
+
foundFinishStep = false;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
writer.write(value);
|
| 140 |
+
}
|
| 141 |
+
} finally {
|
| 142 |
+
reader.releaseLock();
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// Now check finishReason and inject [LANJUT] BEFORE buffered finish events
|
| 146 |
+
const finishReason = await finishReasonPromise;
|
| 147 |
+
console.log("[chat] finishReason:", finishReason);
|
| 148 |
+
|
| 149 |
+
if (finishReason === "length" && lastTextId) {
|
| 150 |
+
// Inject [LANJUT] using the same text part id as the model's last delta
|
| 151 |
+
writer.write({
|
| 152 |
+
type: "text-delta",
|
| 153 |
+
delta: "\n\n[LANJUT]",
|
| 154 |
+
id: lastTextId,
|
| 155 |
+
} as UIStreamChunk);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// Flush remaining buffered finish events
|
| 159 |
+
for (const b of buffered) writer.write(b);
|
| 160 |
+
},
|
| 161 |
+
}),
|
| 162 |
+
});
|
| 163 |
+
} catch (error: unknown) {
|
| 164 |
+
console.error("Chat API Error:", error);
|
| 165 |
+
return new Response(
|
| 166 |
+
JSON.stringify({
|
| 167 |
+
error: error instanceof Error ? error.message : "Internal server error",
|
| 168 |
+
}),
|
| 169 |
+
{ status: 500, headers: { "Content-Type": "application/json" } }
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
}
|
src/app/api/upload/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { processFile } from "@/lib/file-processor";
|
| 3 |
+
|
| 4 |
+
export async function POST(req: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const formData = await req.formData();
|
| 7 |
+
const files = formData.getAll("files") as File[];
|
| 8 |
+
|
| 9 |
+
if (!files || files.length === 0) {
|
| 10 |
+
return NextResponse.json({ error: "No files provided" }, { status: 400 });
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB per file
|
| 14 |
+
const MAX_FILES = 10;
|
| 15 |
+
|
| 16 |
+
if (files.length > MAX_FILES) {
|
| 17 |
+
return NextResponse.json(
|
| 18 |
+
{ error: `Maksimum ${MAX_FILES} file per upload.` },
|
| 19 |
+
{ status: 400 }
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Process files sequentially to avoid memory spikes from parallel large buffers
|
| 24 |
+
const results = [];
|
| 25 |
+
for (const file of files) {
|
| 26 |
+
if (file.size > MAX_FILE_SIZE) {
|
| 27 |
+
results.push({
|
| 28 |
+
id: crypto.randomUUID(),
|
| 29 |
+
filename: file.name,
|
| 30 |
+
extension: file.name.split(".").pop()?.toLowerCase() || "",
|
| 31 |
+
text: "",
|
| 32 |
+
error: `File '${file.name}' terlalu besar (${(file.size / 1024 / 1024).toFixed(1)}MB). Maksimum 20MB.`,
|
| 33 |
+
size: file.size,
|
| 34 |
+
});
|
| 35 |
+
continue;
|
| 36 |
+
}
|
| 37 |
+
const buffer = Buffer.from(await file.arrayBuffer());
|
| 38 |
+
results.push(await processFile(buffer, file.name));
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return NextResponse.json({ files: results });
|
| 42 |
+
} catch (e: unknown) {
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: e instanceof Error ? e.message : "Upload failed" },
|
| 45 |
+
{ status: 500 }
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
}
|
src/app/favicon.ico
ADDED
|
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
+
@import "shadcn/tailwind.css";
|
| 4 |
+
|
| 5 |
+
@custom-variant dark (&:is(.dark *));
|
| 6 |
+
|
| 7 |
+
@theme inline {
|
| 8 |
+
--color-background: var(--background);
|
| 9 |
+
--color-foreground: var(--foreground);
|
| 10 |
+
--font-sans: var(--font-inter);
|
| 11 |
+
--font-mono: var(--font-geist-mono);
|
| 12 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 13 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 14 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 15 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 16 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 17 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 18 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 19 |
+
--color-sidebar: var(--sidebar);
|
| 20 |
+
--color-chart-5: var(--chart-5);
|
| 21 |
+
--color-chart-4: var(--chart-4);
|
| 22 |
+
--color-chart-3: var(--chart-3);
|
| 23 |
+
--color-chart-2: var(--chart-2);
|
| 24 |
+
--color-chart-1: var(--chart-1);
|
| 25 |
+
--color-ring: var(--ring);
|
| 26 |
+
--color-input: var(--input);
|
| 27 |
+
--color-border: var(--border);
|
| 28 |
+
--color-destructive: var(--destructive);
|
| 29 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 30 |
+
--color-accent: var(--accent);
|
| 31 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 32 |
+
--color-muted: var(--muted);
|
| 33 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 34 |
+
--color-secondary: var(--secondary);
|
| 35 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 36 |
+
--color-primary: var(--primary);
|
| 37 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 38 |
+
--color-popover: var(--popover);
|
| 39 |
+
--color-card-foreground: var(--card-foreground);
|
| 40 |
+
--color-card: var(--card);
|
| 41 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 42 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 43 |
+
--radius-lg: var(--radius);
|
| 44 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 45 |
+
--radius-2xl: calc(var(--radius) + 8px);
|
| 46 |
+
--radius-3xl: calc(var(--radius) + 12px);
|
| 47 |
+
--radius-4xl: calc(var(--radius) + 16px);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
:root {
|
| 51 |
+
--radius: 0.625rem;
|
| 52 |
+
--background: oklch(1 0 0);
|
| 53 |
+
--foreground: oklch(0.145 0 0);
|
| 54 |
+
--card: oklch(1 0 0);
|
| 55 |
+
--card-foreground: oklch(0.145 0 0);
|
| 56 |
+
--popover: oklch(1 0 0);
|
| 57 |
+
--popover-foreground: oklch(0.145 0 0);
|
| 58 |
+
--primary: oklch(0.205 0 0);
|
| 59 |
+
--primary-foreground: oklch(0.985 0 0);
|
| 60 |
+
--secondary: oklch(0.97 0 0);
|
| 61 |
+
--secondary-foreground: oklch(0.205 0 0);
|
| 62 |
+
--muted: oklch(0.97 0 0);
|
| 63 |
+
--muted-foreground: oklch(0.556 0 0);
|
| 64 |
+
--accent: oklch(0.97 0 0);
|
| 65 |
+
--accent-foreground: oklch(0.205 0 0);
|
| 66 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 67 |
+
--border: oklch(0.922 0 0);
|
| 68 |
+
--input: oklch(0.922 0 0);
|
| 69 |
+
--ring: oklch(0.708 0 0);
|
| 70 |
+
--chart-1: oklch(0.646 0.222 41.116);
|
| 71 |
+
--chart-2: oklch(0.6 0.118 184.704);
|
| 72 |
+
--chart-3: oklch(0.398 0.07 227.392);
|
| 73 |
+
--chart-4: oklch(0.828 0.189 84.429);
|
| 74 |
+
--chart-5: oklch(0.769 0.188 70.08);
|
| 75 |
+
--sidebar: oklch(0.985 0 0);
|
| 76 |
+
--sidebar-foreground: oklch(0.145 0 0);
|
| 77 |
+
--sidebar-primary: oklch(0.205 0 0);
|
| 78 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 79 |
+
--sidebar-accent: oklch(0.97 0 0);
|
| 80 |
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
| 81 |
+
--sidebar-border: oklch(0.922 0 0);
|
| 82 |
+
--sidebar-ring: oklch(0.708 0 0);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
@layer base {
|
| 86 |
+
* {
|
| 87 |
+
@apply border-border outline-ring/50;
|
| 88 |
+
}
|
| 89 |
+
body {
|
| 90 |
+
@apply bg-background text-foreground;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* Custom scrollbar */
|
| 95 |
+
::-webkit-scrollbar {
|
| 96 |
+
width: 6px;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
::-webkit-scrollbar-track {
|
| 100 |
+
background: transparent;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
::-webkit-scrollbar-thumb {
|
| 104 |
+
background: #d1d5db;
|
| 105 |
+
border-radius: 3px;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
::-webkit-scrollbar-thumb:hover {
|
| 109 |
+
background: #9ca3af;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* Hide scrollbar on sidebar */
|
| 113 |
+
.overflow-y-auto {
|
| 114 |
+
scrollbar-width: thin;
|
| 115 |
+
scrollbar-color: #d1d5db transparent;
|
| 116 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const inter = Inter({
|
| 6 |
+
subsets: ["latin"],
|
| 7 |
+
variable: "--font-inter",
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
export const metadata: Metadata = {
|
| 11 |
+
title: "Open Chatbot",
|
| 12 |
+
description: "Open Chatbot — multi-provider AI assistant dengan analisis dokumen dan OCR",
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export default function RootLayout({
|
| 16 |
+
children,
|
| 17 |
+
}: Readonly<{
|
| 18 |
+
children: React.ReactNode;
|
| 19 |
+
}>) {
|
| 20 |
+
return (
|
| 21 |
+
<html lang="id">
|
| 22 |
+
<body className={`${inter.variable} font-sans antialiased`}>
|
| 23 |
+
{children}
|
| 24 |
+
</body>
|
| 25 |
+
</html>
|
| 26 |
+
);
|
| 27 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ChatPage } from "@/components/chat/chat-page";
|
| 2 |
+
|
| 3 |
+
export default function Home() {
|
| 4 |
+
return <ChatPage />;
|
| 5 |
+
}
|
src/components/chat/chat-area.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef, useEffect } from "react";
|
| 4 |
+
import { ChatMessage } from "./chat-message";
|
| 5 |
+
import { WelcomeScreen } from "./welcome-screen";
|
| 6 |
+
import { Loader2 } from "lucide-react";
|
| 7 |
+
|
| 8 |
+
interface Message {
|
| 9 |
+
id: string;
|
| 10 |
+
role: "user" | "assistant";
|
| 11 |
+
content: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
messages: Message[];
|
| 16 |
+
isLoading: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function ChatArea({ messages, isLoading }: Props) {
|
| 20 |
+
const bottomRef = useRef<HTMLDivElement>(null);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 24 |
+
}, [messages, isLoading]);
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div className="flex-1 overflow-y-auto">
|
| 28 |
+
<div className="max-w-[1088px] mx-auto px-5 py-6">
|
| 29 |
+
{messages.length === 0 ? (
|
| 30 |
+
<WelcomeScreen />
|
| 31 |
+
) : (
|
| 32 |
+
<div className="space-y-6">
|
| 33 |
+
{messages.map((msg) => (
|
| 34 |
+
<ChatMessage
|
| 35 |
+
key={msg.id}
|
| 36 |
+
role={msg.role}
|
| 37 |
+
content={msg.content}
|
| 38 |
+
/>
|
| 39 |
+
))}
|
| 40 |
+
{isLoading &&
|
| 41 |
+
messages[messages.length - 1]?.role === "user" && (
|
| 42 |
+
<div className="flex gap-4 justify-start">
|
| 43 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center flex-shrink-0 mt-1">
|
| 44 |
+
<Loader2 className="w-5 h-5 text-white animate-spin" />
|
| 45 |
+
</div>
|
| 46 |
+
<div className="flex items-center gap-1.5 py-2">
|
| 47 |
+
<div className="w-2 h-2 rounded-full bg-gray-300 animate-bounce [animation-delay:0ms]" />
|
| 48 |
+
<div className="w-2 h-2 rounded-full bg-gray-300 animate-bounce [animation-delay:150ms]" />
|
| 49 |
+
<div className="w-2 h-2 rounded-full bg-gray-300 animate-bounce [animation-delay:300ms]" />
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
)}
|
| 53 |
+
</div>
|
| 54 |
+
)}
|
| 55 |
+
<div ref={bottomRef} />
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
src/components/chat/chat-input.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef, useState, useCallback } from "react";
|
| 4 |
+
import { Paperclip, ArrowUp, Loader2 } from "lucide-react";
|
| 5 |
+
import { ACCEPT_FILE_TYPES } from "@/lib/constants";
|
| 6 |
+
import type { FileContext } from "@/lib/types";
|
| 7 |
+
import { FilePreview } from "./file-preview";
|
| 8 |
+
|
| 9 |
+
interface Props {
|
| 10 |
+
onSend: (message: string) => void;
|
| 11 |
+
onFilesUploaded: (files: FileContext[]) => void;
|
| 12 |
+
files: FileContext[];
|
| 13 |
+
onRemoveFile: (id: string) => void;
|
| 14 |
+
onClearFiles: () => void;
|
| 15 |
+
isLoading: boolean;
|
| 16 |
+
isUploading: boolean;
|
| 17 |
+
setIsUploading: (v: boolean) => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function ChatInput({
|
| 21 |
+
onSend,
|
| 22 |
+
onFilesUploaded,
|
| 23 |
+
files,
|
| 24 |
+
onRemoveFile,
|
| 25 |
+
onClearFiles,
|
| 26 |
+
isLoading,
|
| 27 |
+
isUploading,
|
| 28 |
+
setIsUploading,
|
| 29 |
+
}: Props) {
|
| 30 |
+
const [input, setInput] = useState("");
|
| 31 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 32 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 33 |
+
|
| 34 |
+
const adjustHeight = useCallback(() => {
|
| 35 |
+
const textarea = textareaRef.current;
|
| 36 |
+
if (textarea) {
|
| 37 |
+
textarea.style.height = "auto";
|
| 38 |
+
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px";
|
| 39 |
+
}
|
| 40 |
+
}, []);
|
| 41 |
+
|
| 42 |
+
const handleSend = () => {
|
| 43 |
+
const trimmed = input.trim();
|
| 44 |
+
if (!trimmed || isLoading) return;
|
| 45 |
+
onSend(trimmed);
|
| 46 |
+
setInput("");
|
| 47 |
+
if (textareaRef.current) {
|
| 48 |
+
textareaRef.current.style.height = "auto";
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 53 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 54 |
+
e.preventDefault();
|
| 55 |
+
handleSend();
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 60 |
+
const selectedFiles = e.target.files;
|
| 61 |
+
if (!selectedFiles || selectedFiles.length === 0) return;
|
| 62 |
+
|
| 63 |
+
setIsUploading(true);
|
| 64 |
+
try {
|
| 65 |
+
const formData = new FormData();
|
| 66 |
+
Array.from(selectedFiles).forEach((f) => formData.append("files", f));
|
| 67 |
+
|
| 68 |
+
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
| 69 |
+
if (!res.ok) {
|
| 70 |
+
console.error("Upload failed with status:", res.status);
|
| 71 |
+
alert("Gagal mengupload file. Silakan coba lagi.");
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
const data = await res.json();
|
| 75 |
+
|
| 76 |
+
if (data.files) {
|
| 77 |
+
onFilesUploaded(data.files);
|
| 78 |
+
}
|
| 79 |
+
} catch (err) {
|
| 80 |
+
console.error("Upload failed:", err);
|
| 81 |
+
alert("Gagal mengupload file. Periksa koneksi dan coba lagi.");
|
| 82 |
+
} finally {
|
| 83 |
+
setIsUploading(false);
|
| 84 |
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
| 85 |
+
}
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<div className="w-full max-w-[1088px] mx-auto px-5 pb-4">
|
| 90 |
+
<div className="bg-white border border-gray-200 rounded-2xl shadow-sm">
|
| 91 |
+
<FilePreview files={files} onRemove={onRemoveFile} onClearAll={onClearFiles} />
|
| 92 |
+
<div className="flex items-end gap-2 px-3 py-3">
|
| 93 |
+
<button
|
| 94 |
+
type="button"
|
| 95 |
+
onClick={() => fileInputRef.current?.click()}
|
| 96 |
+
disabled={isUploading}
|
| 97 |
+
className="p-2 rounded-xl hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0 mb-0.5"
|
| 98 |
+
title="Attach file"
|
| 99 |
+
>
|
| 100 |
+
{isUploading ? (
|
| 101 |
+
<Loader2 className="w-5 h-5 animate-spin" />
|
| 102 |
+
) : (
|
| 103 |
+
<Paperclip className="w-5 h-5" />
|
| 104 |
+
)}
|
| 105 |
+
</button>
|
| 106 |
+
<input
|
| 107 |
+
ref={fileInputRef}
|
| 108 |
+
type="file"
|
| 109 |
+
multiple
|
| 110 |
+
accept={ACCEPT_FILE_TYPES}
|
| 111 |
+
onChange={handleFileSelect}
|
| 112 |
+
className="hidden"
|
| 113 |
+
/>
|
| 114 |
+
<textarea
|
| 115 |
+
ref={textareaRef}
|
| 116 |
+
value={input}
|
| 117 |
+
onChange={(e) => {
|
| 118 |
+
setInput(e.target.value);
|
| 119 |
+
adjustHeight();
|
| 120 |
+
}}
|
| 121 |
+
onKeyDown={handleKeyDown}
|
| 122 |
+
placeholder="Tulis pesan..."
|
| 123 |
+
rows={1}
|
| 124 |
+
className="flex-1 resize-none bg-transparent text-gray-900 placeholder-gray-400 text-[15px] leading-relaxed focus:outline-none max-h-[200px] py-1.5"
|
| 125 |
+
/>
|
| 126 |
+
<button
|
| 127 |
+
type="button"
|
| 128 |
+
onClick={handleSend}
|
| 129 |
+
disabled={!input.trim() || isLoading}
|
| 130 |
+
className={`p-2 rounded-xl flex-shrink-0 mb-0.5 transition-colors ${
|
| 131 |
+
input.trim() && !isLoading
|
| 132 |
+
? "bg-gray-900 text-white hover:bg-gray-700"
|
| 133 |
+
: "bg-gray-100 text-gray-300 cursor-not-allowed"
|
| 134 |
+
}`}
|
| 135 |
+
>
|
| 136 |
+
{isLoading ? (
|
| 137 |
+
<Loader2 className="w-5 h-5 animate-spin" />
|
| 138 |
+
) : (
|
| 139 |
+
<ArrowUp className="w-5 h-5" />
|
| 140 |
+
)}
|
| 141 |
+
</button>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
<p className="text-[11px] text-purple-900 text-center mt-2">
|
| 145 |
+
<strong>Open Chatbot dapat membuat kesalahan. Periksa informasi penting.</strong>
|
| 146 |
+
</p>
|
| 147 |
+
</div>
|
| 148 |
+
);
|
| 149 |
+
}
|
src/components/chat/chat-message.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Bot, User } from "lucide-react";
|
| 4 |
+
import { MarkdownRenderer } from "./markdown-renderer";
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
role: "user" | "assistant";
|
| 8 |
+
content: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function ChatMessage({ role, content }: Props) {
|
| 12 |
+
const isUser = role === "user";
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className={`flex gap-4 ${isUser ? "justify-end" : "justify-start"}`}>
|
| 16 |
+
{!isUser && (
|
| 17 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center flex-shrink-0 mt-1">
|
| 18 |
+
<Bot className="w-5 h-5 text-white" />
|
| 19 |
+
</div>
|
| 20 |
+
)}
|
| 21 |
+
<div
|
| 22 |
+
className={`max-w-[90%] ${
|
| 23 |
+
isUser
|
| 24 |
+
? "bg-gray-100 rounded-2xl rounded-tr-sm px-4 py-3 text-gray-900"
|
| 25 |
+
: "text-gray-900 py-1"
|
| 26 |
+
}`}
|
| 27 |
+
>
|
| 28 |
+
{isUser ? (
|
| 29 |
+
<p className="text-[15px] leading-relaxed whitespace-pre-wrap">{content}</p>
|
| 30 |
+
) : (
|
| 31 |
+
<div className="text-[15px] leading-relaxed prose prose-sm max-w-none">
|
| 32 |
+
<MarkdownRenderer content={content} />
|
| 33 |
+
</div>
|
| 34 |
+
)}
|
| 35 |
+
</div>
|
| 36 |
+
{isUser && (
|
| 37 |
+
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center flex-shrink-0 mt-1">
|
| 38 |
+
<User className="w-5 h-5 text-white" />
|
| 39 |
+
</div>
|
| 40 |
+
)}
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
src/components/chat/chat-page.tsx
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
| 4 |
+
import { useChat } from "@ai-sdk/react";
|
| 5 |
+
import { DefaultChatTransport } from "ai";
|
| 6 |
+
import { useChatStore } from "@/hooks/use-chat-store";
|
| 7 |
+
import type { FileContext } from "@/lib/types";
|
| 8 |
+
import { Sidebar } from "./sidebar";
|
| 9 |
+
import { ChatArea } from "./chat-area";
|
| 10 |
+
import { ChatInput } from "./chat-input";
|
| 11 |
+
import { SettingsDialog } from "./settings-dialog";
|
| 12 |
+
|
| 13 |
+
function getTextFromParts(parts: { type: string; text?: string }[]): string {
|
| 14 |
+
return parts
|
| 15 |
+
.filter((p) => p.type === "text")
|
| 16 |
+
.map((p) => p.text ?? "")
|
| 17 |
+
.join("");
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function ChatPage() {
|
| 21 |
+
const {
|
| 22 |
+
sessions,
|
| 23 |
+
currentSessionId,
|
| 24 |
+
setCurrentSessionId,
|
| 25 |
+
settings,
|
| 26 |
+
updateSettings,
|
| 27 |
+
createSession,
|
| 28 |
+
deleteSession,
|
| 29 |
+
getMessages,
|
| 30 |
+
saveMessages,
|
| 31 |
+
getFileContexts,
|
| 32 |
+
saveFileContexts,
|
| 33 |
+
hydrated,
|
| 34 |
+
} = useChatStore();
|
| 35 |
+
|
| 36 |
+
const [files, setFiles] = useState<FileContext[]>([]);
|
| 37 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 38 |
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 39 |
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 40 |
+
const prevMessagesRef = useRef<string>("");
|
| 41 |
+
const saveMessagesRef = useRef(saveMessages);
|
| 42 |
+
saveMessagesRef.current = saveMessages;
|
| 43 |
+
|
| 44 |
+
// Always-current ref for files — synced every render
|
| 45 |
+
const filesRef = useRef<FileContext[]>(files);
|
| 46 |
+
filesRef.current = files;
|
| 47 |
+
|
| 48 |
+
// Refs for transport body — these are read asynchronously when body() is called
|
| 49 |
+
const settingsRef = useRef(settings);
|
| 50 |
+
settingsRef.current = settings;
|
| 51 |
+
|
| 52 |
+
// Pending files: set right before sendMessage, read by transport body()
|
| 53 |
+
const pendingFilesRef = useRef<FileContext[]>([]);
|
| 54 |
+
|
| 55 |
+
// Stored file contexts for the current session (persisted in localStorage)
|
| 56 |
+
const sessionFilesRef = useRef<FileContext[]>([]);
|
| 57 |
+
|
| 58 |
+
const saveFileContextsRef = useRef(saveFileContexts);
|
| 59 |
+
saveFileContextsRef.current = saveFileContexts;
|
| 60 |
+
const getFileContextsRef = useRef(getFileContexts);
|
| 61 |
+
getFileContextsRef.current = getFileContexts;
|
| 62 |
+
|
| 63 |
+
// Create transport ONCE — body is a function that reads refs at call time
|
| 64 |
+
const transport = useMemo(
|
| 65 |
+
() =>
|
| 66 |
+
new DefaultChatTransport({
|
| 67 |
+
api: "/api/chat",
|
| 68 |
+
body: () => {
|
| 69 |
+
// Merge: new uploaded files + stored session files (deduplicated by id)
|
| 70 |
+
// Only include files that have actual text content (skip restored-from-localStorage metadata-only files)
|
| 71 |
+
const newFiles = pendingFilesRef.current;
|
| 72 |
+
const storedFiles = sessionFilesRef.current;
|
| 73 |
+
const allFilesMap = new Map<string, FileContext>();
|
| 74 |
+
for (const f of storedFiles) if (f.text) allFilesMap.set(f.id, f);
|
| 75 |
+
for (const f of newFiles) if (f.text) allFilesMap.set(f.id, f);
|
| 76 |
+
|
| 77 |
+
// Clear pending after reading — prevents stale files on next message
|
| 78 |
+
// This is the safe place to clear (after body() has read the files),
|
| 79 |
+
// NOT in the session-switch useEffect (which races with body()).
|
| 80 |
+
pendingFilesRef.current = [];
|
| 81 |
+
|
| 82 |
+
const currentSettings = settingsRef.current;
|
| 83 |
+
return {
|
| 84 |
+
provider: currentSettings.provider,
|
| 85 |
+
model: currentSettings.model,
|
| 86 |
+
apiKey: currentSettings.apiKeys[currentSettings.provider] || "",
|
| 87 |
+
fileContexts: Array.from(allFilesMap.values()),
|
| 88 |
+
};
|
| 89 |
+
},
|
| 90 |
+
}),
|
| 91 |
+
[]
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
const [chatError, setChatError] = useState<string | null>(null);
|
| 95 |
+
|
| 96 |
+
const {
|
| 97 |
+
messages,
|
| 98 |
+
setMessages,
|
| 99 |
+
sendMessage,
|
| 100 |
+
status,
|
| 101 |
+
} = useChat({
|
| 102 |
+
transport,
|
| 103 |
+
onError: (error) => {
|
| 104 |
+
console.error("[chat] error:", error);
|
| 105 |
+
// Try to extract JSON error message from response
|
| 106 |
+
const msg = error.message || "Terjadi kesalahan. Coba lagi.";
|
| 107 |
+
setChatError(msg);
|
| 108 |
+
},
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
const isLoading = status === "streaming" || status === "submitted";
|
| 112 |
+
|
| 113 |
+
const simpleMessages = useMemo(
|
| 114 |
+
() =>
|
| 115 |
+
messages
|
| 116 |
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
| 117 |
+
.map((m) => ({
|
| 118 |
+
id: m.id,
|
| 119 |
+
role: m.role as "user" | "assistant",
|
| 120 |
+
content: getTextFromParts((m.parts || []) as { type: string; text?: string }[]),
|
| 121 |
+
})),
|
| 122 |
+
[messages]
|
| 123 |
+
);
|
| 124 |
+
|
| 125 |
+
const isNewSessionRef = useRef(false);
|
| 126 |
+
|
| 127 |
+
// Track expired files (restored from localStorage without text content)
|
| 128 |
+
const [expiredFileNames, setExpiredFileNames] = useState<string[]>([]);
|
| 129 |
+
|
| 130 |
+
useEffect(() => {
|
| 131 |
+
// DON'T clear pendingFilesRef here — it causes a race condition:
|
| 132 |
+
// handleSend() sets pendingFilesRef → createSession() changes currentSessionId →
|
| 133 |
+
// this effect fires and clears pendingFilesRef BEFORE transport.body() reads it.
|
| 134 |
+
// Instead, pendingFilesRef is cleared inside transport.body() after reading.
|
| 135 |
+
setExpiredFileNames([]);
|
| 136 |
+
|
| 137 |
+
if (currentSessionId && hydrated) {
|
| 138 |
+
if (isNewSessionRef.current) {
|
| 139 |
+
isNewSessionRef.current = false;
|
| 140 |
+
// Don't clear sessionFilesRef — handleSend() already populated it
|
| 141 |
+
// before calling createSession(), so clearing here would race.
|
| 142 |
+
} else {
|
| 143 |
+
const stored = getMessages(currentSessionId);
|
| 144 |
+
setMessages(
|
| 145 |
+
stored.map((m) => ({
|
| 146 |
+
id: m.id,
|
| 147 |
+
role: m.role,
|
| 148 |
+
parts: [{ type: "text" as const, text: m.content }],
|
| 149 |
+
}))
|
| 150 |
+
);
|
| 151 |
+
setFiles([]);
|
| 152 |
+
// Restore stored file contexts for this session
|
| 153 |
+
const restoredFiles = getFileContextsRef.current(currentSessionId);
|
| 154 |
+
sessionFilesRef.current = restoredFiles;
|
| 155 |
+
// Check if any restored files have lost their text content
|
| 156 |
+
const expired = restoredFiles.filter((f) => !f.text).map((f) => f.filename);
|
| 157 |
+
if (expired.length > 0) {
|
| 158 |
+
setExpiredFileNames(expired);
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
} else {
|
| 162 |
+
setMessages([]);
|
| 163 |
+
setFiles([]);
|
| 164 |
+
sessionFilesRef.current = [];
|
| 165 |
+
}
|
| 166 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 167 |
+
}, [currentSessionId, hydrated]);
|
| 168 |
+
|
| 169 |
+
// Save messages: only when status is "ready" (not during streaming) to avoid
|
| 170 |
+
// localStorage thrashing on every text delta. Also save on unmount via ref.
|
| 171 |
+
useEffect(() => {
|
| 172 |
+
if (!currentSessionId || !hydrated) return;
|
| 173 |
+
if (status !== "ready") return; // Don't save during streaming
|
| 174 |
+
const serialized = JSON.stringify(simpleMessages);
|
| 175 |
+
if (serialized !== prevMessagesRef.current && simpleMessages.length > 0) {
|
| 176 |
+
prevMessagesRef.current = serialized;
|
| 177 |
+
saveMessagesRef.current(currentSessionId, simpleMessages);
|
| 178 |
+
}
|
| 179 |
+
}, [simpleMessages, currentSessionId, hydrated, status]);
|
| 180 |
+
|
| 181 |
+
// Auto-continue: detect truncated responses and auto-send "lanjutkan"
|
| 182 |
+
const autoContinueCountRef = useRef(0);
|
| 183 |
+
|
| 184 |
+
useEffect(() => {
|
| 185 |
+
if (status !== "ready" || simpleMessages.length === 0) return;
|
| 186 |
+
const lastMsg = simpleMessages[simpleMessages.length - 1];
|
| 187 |
+
if (lastMsg.role !== "assistant") return;
|
| 188 |
+
|
| 189 |
+
const content = lastMsg.content.trim();
|
| 190 |
+
|
| 191 |
+
// Primary: server appends "[LANJUT]" when finishReason === "length"
|
| 192 |
+
// This is the most reliable signal — trust it above all else.
|
| 193 |
+
const endsWithLanjut = content.endsWith("[LANJUT]");
|
| 194 |
+
|
| 195 |
+
// Secondary heuristics (only for edge cases where server marker might be missing):
|
| 196 |
+
// - Must be long enough to plausibly be truncated (> 2000 chars)
|
| 197 |
+
// - Must NOT end with natural sentence-ending punctuation or markdown closers
|
| 198 |
+
const lastLine = content.split("\n").pop() || "";
|
| 199 |
+
const naturalEnd = /[.!?。))」】::]$/.test(content) || /```$/.test(content) || content.endsWith("|");
|
| 200 |
+
const endsInTable = !naturalEnd && lastLine.includes("|") && content.length > 2000;
|
| 201 |
+
const isTruncatedMid = !naturalEnd && content.length > 2000 && !/\n$/.test(content);
|
| 202 |
+
|
| 203 |
+
const shouldContinue = endsWithLanjut || endsInTable || isTruncatedMid;
|
| 204 |
+
|
| 205 |
+
if (shouldContinue) {
|
| 206 |
+
// Max 5 auto-continues to prevent infinite loop
|
| 207 |
+
if (autoContinueCountRef.current >= 5) {
|
| 208 |
+
autoContinueCountRef.current = 0;
|
| 209 |
+
return;
|
| 210 |
+
}
|
| 211 |
+
autoContinueCountRef.current += 1;
|
| 212 |
+
const timer = setTimeout(() => {
|
| 213 |
+
sendMessage({ text: "lanjutkan" });
|
| 214 |
+
}, 800);
|
| 215 |
+
return () => clearTimeout(timer);
|
| 216 |
+
} else {
|
| 217 |
+
// Reset counter when response finishes naturally
|
| 218 |
+
autoContinueCountRef.current = 0;
|
| 219 |
+
}
|
| 220 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 221 |
+
}, [status, simpleMessages]);
|
| 222 |
+
|
| 223 |
+
const handleSend = useCallback(
|
| 224 |
+
(content: string) => {
|
| 225 |
+
setChatError(null);
|
| 226 |
+
// Use ref (always current) instead of closure `files` which may be stale
|
| 227 |
+
const newFiles = [...filesRef.current];
|
| 228 |
+
pendingFilesRef.current = newFiles;
|
| 229 |
+
|
| 230 |
+
let sessionId = currentSessionId;
|
| 231 |
+
if (!sessionId) {
|
| 232 |
+
isNewSessionRef.current = true;
|
| 233 |
+
sessionId = createSession();
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// If there are new files, merge with session files and persist
|
| 237 |
+
if (newFiles.length > 0) {
|
| 238 |
+
const merged = new Map<string, FileContext>();
|
| 239 |
+
for (const f of sessionFilesRef.current) merged.set(f.id, f);
|
| 240 |
+
for (const f of newFiles) merged.set(f.id, f);
|
| 241 |
+
sessionFilesRef.current = Array.from(merged.values());
|
| 242 |
+
saveFileContextsRef.current(sessionId, sessionFilesRef.current);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// sendMessage triggers transport.sendMessages which calls body()
|
| 246 |
+
// body() reads pendingFilesRef + sessionFilesRef at that point
|
| 247 |
+
sendMessage({ text: content });
|
| 248 |
+
|
| 249 |
+
// Clear UI files after send
|
| 250 |
+
setFiles([]);
|
| 251 |
+
},
|
| 252 |
+
[currentSessionId, createSession, sendMessage]
|
| 253 |
+
);
|
| 254 |
+
|
| 255 |
+
const handleNewChat = useCallback(() => {
|
| 256 |
+
setCurrentSessionId(null);
|
| 257 |
+
setMessages([]);
|
| 258 |
+
setFiles([]);
|
| 259 |
+
pendingFilesRef.current = [];
|
| 260 |
+
sessionFilesRef.current = [];
|
| 261 |
+
}, [setCurrentSessionId, setMessages]);
|
| 262 |
+
|
| 263 |
+
const handleSelectSession = useCallback(
|
| 264 |
+
(id: string) => {
|
| 265 |
+
setCurrentSessionId(id);
|
| 266 |
+
},
|
| 267 |
+
[setCurrentSessionId]
|
| 268 |
+
);
|
| 269 |
+
|
| 270 |
+
const handleRemoveFile = useCallback((id: string) => {
|
| 271 |
+
setFiles((prev) => prev.filter((f) => f.id !== id));
|
| 272 |
+
}, []);
|
| 273 |
+
|
| 274 |
+
const handleClearFiles = useCallback(() => {
|
| 275 |
+
setFiles([]);
|
| 276 |
+
}, []);
|
| 277 |
+
|
| 278 |
+
const handleFilesUploaded = useCallback((newFiles: FileContext[]) => {
|
| 279 |
+
setFiles((prev) => [...prev, ...newFiles]);
|
| 280 |
+
}, []);
|
| 281 |
+
|
| 282 |
+
if (!hydrated) {
|
| 283 |
+
return (
|
| 284 |
+
<div className="flex h-screen items-center justify-center bg-white">
|
| 285 |
+
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-500 rounded-full animate-spin" />
|
| 286 |
+
</div>
|
| 287 |
+
);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
return (
|
| 291 |
+
<div className="flex h-screen bg-white">
|
| 292 |
+
<Sidebar
|
| 293 |
+
sessions={sessions}
|
| 294 |
+
currentSessionId={currentSessionId}
|
| 295 |
+
onSelectSession={handleSelectSession}
|
| 296 |
+
onNewChat={handleNewChat}
|
| 297 |
+
onDeleteSession={deleteSession}
|
| 298 |
+
onOpenSettings={() => setSettingsOpen(true)}
|
| 299 |
+
isOpen={sidebarOpen}
|
| 300 |
+
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
| 301 |
+
settings={settings}
|
| 302 |
+
/>
|
| 303 |
+
|
| 304 |
+
<div
|
| 305 |
+
className={`flex-1 flex flex-col transition-all duration-300 ${
|
| 306 |
+
sidebarOpen ? "ml-[280px]" : "ml-0"
|
| 307 |
+
}`}
|
| 308 |
+
>
|
| 309 |
+
<ChatArea
|
| 310 |
+
messages={simpleMessages}
|
| 311 |
+
isLoading={isLoading}
|
| 312 |
+
/>
|
| 313 |
+
{chatError && (
|
| 314 |
+
<div className="max-w-[1088px] mx-auto px-5 w-full">
|
| 315 |
+
<div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-sm text-red-800 mb-2">
|
| 316 |
+
<span>{chatError}</span>
|
| 317 |
+
<button
|
| 318 |
+
onClick={() => setChatError(null)}
|
| 319 |
+
className="ml-auto text-red-400 hover:text-red-600 text-xs"
|
| 320 |
+
>
|
| 321 |
+
✕
|
| 322 |
+
</button>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
)}
|
| 326 |
+
{expiredFileNames.length > 0 && (
|
| 327 |
+
<div className="max-w-[1088px] mx-auto px-5 w-full">
|
| 328 |
+
<div className="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm text-amber-800 mb-2">
|
| 329 |
+
<span>⚠️</span>
|
| 330 |
+
<span>
|
| 331 |
+
File <strong>{expiredFileNames.join(", ")}</strong> perlu di-upload ulang untuk konteks penuh.
|
| 332 |
+
</span>
|
| 333 |
+
<button
|
| 334 |
+
onClick={() => setExpiredFileNames([])}
|
| 335 |
+
className="ml-auto text-amber-500 hover:text-amber-700 text-xs"
|
| 336 |
+
>
|
| 337 |
+
✕
|
| 338 |
+
</button>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
)}
|
| 342 |
+
<ChatInput
|
| 343 |
+
onSend={handleSend}
|
| 344 |
+
onFilesUploaded={handleFilesUploaded}
|
| 345 |
+
files={files}
|
| 346 |
+
onRemoveFile={handleRemoveFile}
|
| 347 |
+
onClearFiles={handleClearFiles}
|
| 348 |
+
isLoading={isLoading}
|
| 349 |
+
isUploading={isUploading}
|
| 350 |
+
setIsUploading={setIsUploading}
|
| 351 |
+
/>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<SettingsDialog
|
| 355 |
+
open={settingsOpen}
|
| 356 |
+
onOpenChange={setSettingsOpen}
|
| 357 |
+
settings={settings}
|
| 358 |
+
onSettingsChange={updateSettings}
|
| 359 |
+
/>
|
| 360 |
+
</div>
|
| 361 |
+
);
|
| 362 |
+
}
|
src/components/chat/file-badge.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { X } from "lucide-react";
|
| 4 |
+
import { BADGE_COLORS } from "@/lib/constants";
|
| 5 |
+
import type { FileContext } from "@/lib/types";
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
file: FileContext;
|
| 9 |
+
onRemove?: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function FileBadge({ file, onRemove }: Props) {
|
| 13 |
+
const bgColor = BADGE_COLORS[file.extension] || "bg-gray-500";
|
| 14 |
+
const label = file.extension.toUpperCase().slice(0, 4);
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div
|
| 18 |
+
className={`inline-flex items-center gap-2 bg-white border rounded-xl px-3 py-2 shadow-sm group ${file.error ? "border-red-300" : "border-gray-200"}`}
|
| 19 |
+
title={file.error || undefined}
|
| 20 |
+
>
|
| 21 |
+
<div
|
| 22 |
+
className={`w-9 h-9 rounded-lg flex items-center justify-center text-white text-xs font-bold ${file.error ? "bg-red-400" : bgColor}`}
|
| 23 |
+
>
|
| 24 |
+
{label}
|
| 25 |
+
</div>
|
| 26 |
+
<div className="min-w-0">
|
| 27 |
+
<div className="text-sm font-medium text-gray-900 truncate max-w-[140px]">
|
| 28 |
+
{file.filename}
|
| 29 |
+
</div>
|
| 30 |
+
<div className={`text-xs ${file.error ? "text-red-500" : "text-gray-400"}`}>
|
| 31 |
+
{file.error
|
| 32 |
+
? "Error - hover untuk detail"
|
| 33 |
+
: `${(file.size / 1024).toFixed(1)} KB`}
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
{onRemove && (
|
| 37 |
+
<button
|
| 38 |
+
onClick={onRemove}
|
| 39 |
+
className="ml-1 p-0.5 rounded-full hover:bg-gray-100 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
| 40 |
+
>
|
| 41 |
+
<X className="w-3.5 h-3.5" />
|
| 42 |
+
</button>
|
| 43 |
+
)}
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
src/components/chat/file-preview.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { FileBadge } from "./file-badge";
|
| 4 |
+
import type { FileContext } from "@/lib/types";
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
files: FileContext[];
|
| 8 |
+
onRemove: (id: string) => void;
|
| 9 |
+
onClearAll: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function FilePreview({ files, onRemove, onClearAll }: Props) {
|
| 13 |
+
if (files.length === 0) return null;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="flex flex-wrap items-center gap-2 px-4 py-2">
|
| 17 |
+
{files.map((file) => (
|
| 18 |
+
<FileBadge
|
| 19 |
+
key={file.id}
|
| 20 |
+
file={file}
|
| 21 |
+
onRemove={() => onRemove(file.id)}
|
| 22 |
+
/>
|
| 23 |
+
))}
|
| 24 |
+
{files.length > 1 && (
|
| 25 |
+
<button
|
| 26 |
+
onClick={onClearAll}
|
| 27 |
+
className="text-xs text-gray-400 hover:text-gray-600 ml-1"
|
| 28 |
+
>
|
| 29 |
+
Hapus semua
|
| 30 |
+
</button>
|
| 31 |
+
)}
|
| 32 |
+
</div>
|
| 33 |
+
);
|
| 34 |
+
}
|
src/components/chat/markdown-renderer.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import ReactMarkdown from "react-markdown";
|
| 4 |
+
import remarkGfm from "remark-gfm";
|
| 5 |
+
import remarkMath from "remark-math";
|
| 6 |
+
import rehypeKatex from "rehype-katex";
|
| 7 |
+
import "katex/dist/katex.min.css";
|
| 8 |
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
| 9 |
+
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
content: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Pre-process content to normalize LaTeX delimiters for remark-math.
|
| 17 |
+
* Fallback: converts \[...\] → $$...$$ and \(...\) → $...$
|
| 18 |
+
* System prompt already instructs model to use $...$ and $$...$$.
|
| 19 |
+
*/
|
| 20 |
+
function preprocessLaTeX(content: string): string {
|
| 21 |
+
// Step 1: Convert \[...\] to $$...$$ (block math)
|
| 22 |
+
let result = content.replace(
|
| 23 |
+
/\\\[([\s\S]*?)\\\]/g,
|
| 24 |
+
(_match, inner) => `$$${inner}$$`
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
// Step 2: Convert \(...\) to $...$ (inline math)
|
| 28 |
+
result = result.replace(
|
| 29 |
+
/\\\(([\s\S]*?)\\\)/g,
|
| 30 |
+
(_match, inner) => `$${inner}$`
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
return result;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export function MarkdownRenderer({ content }: Props) {
|
| 37 |
+
const processed = preprocessLaTeX(content);
|
| 38 |
+
return (
|
| 39 |
+
<ReactMarkdown
|
| 40 |
+
remarkPlugins={[remarkGfm, remarkMath]}
|
| 41 |
+
rehypePlugins={[rehypeKatex]}
|
| 42 |
+
components={{
|
| 43 |
+
code({ className, children, ...props }) {
|
| 44 |
+
const match = /language-(\w+)/.exec(className || "");
|
| 45 |
+
const codeStr = String(children).replace(/\n$/, "");
|
| 46 |
+
// Block code: has language class OR contains newlines (fenced block without lang)
|
| 47 |
+
const isBlock = !!match || codeStr.includes("\n");
|
| 48 |
+
const language = match ? match[1] : "text";
|
| 49 |
+
return isBlock ? (
|
| 50 |
+
<div className="my-3 rounded-lg overflow-hidden border border-gray-200">
|
| 51 |
+
<div className="bg-gray-100 px-4 py-1.5 text-xs text-gray-500 font-mono border-b border-gray-200">
|
| 52 |
+
{language}
|
| 53 |
+
</div>
|
| 54 |
+
<SyntaxHighlighter
|
| 55 |
+
style={oneLight}
|
| 56 |
+
language={language}
|
| 57 |
+
PreTag="div"
|
| 58 |
+
customStyle={{
|
| 59 |
+
margin: 0,
|
| 60 |
+
padding: "1rem",
|
| 61 |
+
fontSize: "13px",
|
| 62 |
+
background: "#fafafa",
|
| 63 |
+
}}
|
| 64 |
+
>
|
| 65 |
+
{codeStr}
|
| 66 |
+
</SyntaxHighlighter>
|
| 67 |
+
</div>
|
| 68 |
+
) : (
|
| 69 |
+
<code
|
| 70 |
+
className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono"
|
| 71 |
+
{...props}
|
| 72 |
+
>
|
| 73 |
+
{children}
|
| 74 |
+
</code>
|
| 75 |
+
);
|
| 76 |
+
},
|
| 77 |
+
p({ children }) {
|
| 78 |
+
return <p className="mb-3 last:mb-0 leading-relaxed">{children}</p>;
|
| 79 |
+
},
|
| 80 |
+
ul({ children }) {
|
| 81 |
+
return <ul className="list-disc pl-6 mb-3 space-y-1">{children}</ul>;
|
| 82 |
+
},
|
| 83 |
+
ol({ children }) {
|
| 84 |
+
return <ol className="list-decimal pl-6 mb-3 space-y-1">{children}</ol>;
|
| 85 |
+
},
|
| 86 |
+
h1({ children }) {
|
| 87 |
+
return <h1 className="text-xl font-bold mb-3 mt-4">{children}</h1>;
|
| 88 |
+
},
|
| 89 |
+
h2({ children }) {
|
| 90 |
+
return <h2 className="text-lg font-bold mb-2 mt-3">{children}</h2>;
|
| 91 |
+
},
|
| 92 |
+
h3({ children }) {
|
| 93 |
+
return <h3 className="text-base font-semibold mb-2 mt-3">{children}</h3>;
|
| 94 |
+
},
|
| 95 |
+
table({ children }) {
|
| 96 |
+
return (
|
| 97 |
+
<div className="my-3 overflow-x-auto rounded-lg border border-gray-200 max-w-full">
|
| 98 |
+
<table className="w-max min-w-full text-sm">{children}</table>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
},
|
| 102 |
+
th({ children }) {
|
| 103 |
+
return (
|
| 104 |
+
<th className="bg-gray-50 px-4 py-2 text-left font-medium text-gray-700 border-b">
|
| 105 |
+
{children}
|
| 106 |
+
</th>
|
| 107 |
+
);
|
| 108 |
+
},
|
| 109 |
+
td({ children }) {
|
| 110 |
+
return (
|
| 111 |
+
<td className="px-4 py-2 border-b border-gray-100">{children}</td>
|
| 112 |
+
);
|
| 113 |
+
},
|
| 114 |
+
img({ src, alt, ...props }) {
|
| 115 |
+
// Skip rendering if src is empty — prevents browser error
|
| 116 |
+
if (!src) return null;
|
| 117 |
+
// eslint-disable-next-line @next/next/no-img-element
|
| 118 |
+
return <img src={src} alt={alt || ""} className="max-w-full rounded my-2" {...props} />;
|
| 119 |
+
},
|
| 120 |
+
blockquote({ children }) {
|
| 121 |
+
return (
|
| 122 |
+
<blockquote className="border-l-4 border-gray-300 pl-4 my-3 text-gray-600 italic">
|
| 123 |
+
{children}
|
| 124 |
+
</blockquote>
|
| 125 |
+
);
|
| 126 |
+
},
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
+
{processed}
|
| 130 |
+
</ReactMarkdown>
|
| 131 |
+
);
|
| 132 |
+
}
|
src/components/chat/settings-dialog.tsx
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Eye, EyeOff } from "lucide-react";
|
| 5 |
+
import {
|
| 6 |
+
Dialog,
|
| 7 |
+
DialogContent,
|
| 8 |
+
DialogHeader,
|
| 9 |
+
DialogTitle,
|
| 10 |
+
} from "@/components/ui/dialog";
|
| 11 |
+
import { ANTHROPIC_MODELS, OPENAI_MODELS, DEEPSEEK_MODELS } from "@/lib/constants";
|
| 12 |
+
import type { AppSettings } from "@/lib/types";
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
open: boolean;
|
| 16 |
+
onOpenChange: (open: boolean) => void;
|
| 17 |
+
settings: AppSettings;
|
| 18 |
+
onSettingsChange: (settings: AppSettings) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const PROVIDER_LABELS: Record<string, string> = {
|
| 22 |
+
deepseek: "DeepSeek",
|
| 23 |
+
openai: "OpenAI",
|
| 24 |
+
anthropic: "Claude",
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
export function SettingsDialog({
|
| 28 |
+
open,
|
| 29 |
+
onOpenChange,
|
| 30 |
+
settings,
|
| 31 |
+
onSettingsChange,
|
| 32 |
+
}: Props) {
|
| 33 |
+
const [showKey, setShowKey] = useState(false);
|
| 34 |
+
|
| 35 |
+
const models =
|
| 36 |
+
settings.provider === "anthropic"
|
| 37 |
+
? ANTHROPIC_MODELS
|
| 38 |
+
: settings.provider === "deepseek"
|
| 39 |
+
? DEEPSEEK_MODELS
|
| 40 |
+
: OPENAI_MODELS;
|
| 41 |
+
|
| 42 |
+
const currentKey = settings.apiKeys[settings.provider] || "";
|
| 43 |
+
|
| 44 |
+
const handleKeyChange = (value: string) => {
|
| 45 |
+
onSettingsChange({
|
| 46 |
+
...settings,
|
| 47 |
+
apiKeys: {
|
| 48 |
+
...settings.apiKeys,
|
| 49 |
+
[settings.provider]: value,
|
| 50 |
+
},
|
| 51 |
+
});
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
| 56 |
+
<DialogContent className="sm:max-w-[420px]">
|
| 57 |
+
<DialogHeader>
|
| 58 |
+
<DialogTitle>Pengaturan</DialogTitle>
|
| 59 |
+
</DialogHeader>
|
| 60 |
+
|
| 61 |
+
<div className="space-y-5 mt-2">
|
| 62 |
+
{/* Provider */}
|
| 63 |
+
<div>
|
| 64 |
+
<label className="text-sm font-medium text-gray-700 block mb-2">
|
| 65 |
+
Provider
|
| 66 |
+
</label>
|
| 67 |
+
<div className="grid grid-cols-3 gap-2">
|
| 68 |
+
{(["deepseek", "openai", "anthropic"] as const).map((p) => {
|
| 69 |
+
const hasKey = !!(settings.apiKeys[p]);
|
| 70 |
+
return (
|
| 71 |
+
<button
|
| 72 |
+
key={p}
|
| 73 |
+
onClick={() => {
|
| 74 |
+
const newModels =
|
| 75 |
+
p === "anthropic"
|
| 76 |
+
? ANTHROPIC_MODELS
|
| 77 |
+
: p === "deepseek"
|
| 78 |
+
? DEEPSEEK_MODELS
|
| 79 |
+
: OPENAI_MODELS;
|
| 80 |
+
onSettingsChange({
|
| 81 |
+
...settings,
|
| 82 |
+
provider: p,
|
| 83 |
+
model: newModels[0].id,
|
| 84 |
+
});
|
| 85 |
+
setShowKey(false);
|
| 86 |
+
}}
|
| 87 |
+
className={`relative px-4 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
| 88 |
+
settings.provider === p
|
| 89 |
+
? "bg-gray-900 text-white"
|
| 90 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 91 |
+
}`}
|
| 92 |
+
>
|
| 93 |
+
{PROVIDER_LABELS[p]}
|
| 94 |
+
{/* Key status dot */}
|
| 95 |
+
<span
|
| 96 |
+
className={`absolute top-1.5 right-1.5 w-2 h-2 rounded-full ${
|
| 97 |
+
hasKey ? "bg-green-400" : "bg-red-400"
|
| 98 |
+
}`}
|
| 99 |
+
/>
|
| 100 |
+
</button>
|
| 101 |
+
);
|
| 102 |
+
})}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* API Key */}
|
| 107 |
+
<div>
|
| 108 |
+
<label className="text-sm font-medium text-gray-700 block mb-2">
|
| 109 |
+
API Key — {PROVIDER_LABELS[settings.provider]}
|
| 110 |
+
</label>
|
| 111 |
+
<div className="relative">
|
| 112 |
+
<input
|
| 113 |
+
type={showKey ? "text" : "password"}
|
| 114 |
+
value={currentKey}
|
| 115 |
+
onChange={(e) => handleKeyChange(e.target.value)}
|
| 116 |
+
placeholder={`Masukkan ${PROVIDER_LABELS[settings.provider]} API key...`}
|
| 117 |
+
className="w-full px-4 py-2.5 pr-10 rounded-xl border border-gray-200 bg-gray-50 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
| 118 |
+
/>
|
| 119 |
+
<button
|
| 120 |
+
type="button"
|
| 121 |
+
onClick={() => setShowKey(!showKey)}
|
| 122 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
| 123 |
+
>
|
| 124 |
+
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
<p className="text-xs text-gray-400 mt-1.5">
|
| 128 |
+
Key disimpan di browser. Tidak dikirim ke server lain.
|
| 129 |
+
</p>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
{/* Model */}
|
| 133 |
+
<div>
|
| 134 |
+
<label className="text-sm font-medium text-gray-700 block mb-2">
|
| 135 |
+
Model
|
| 136 |
+
</label>
|
| 137 |
+
<div className="space-y-2">
|
| 138 |
+
{models.map((m) => (
|
| 139 |
+
<button
|
| 140 |
+
key={m.id}
|
| 141 |
+
onClick={() =>
|
| 142 |
+
onSettingsChange({ ...settings, model: m.id })
|
| 143 |
+
}
|
| 144 |
+
className={`w-full text-left px-4 py-3 rounded-xl text-sm transition-colors ${
|
| 145 |
+
settings.model === m.id
|
| 146 |
+
? "bg-indigo-50 border-2 border-indigo-500 text-indigo-700 font-medium"
|
| 147 |
+
: "bg-gray-50 border-2 border-transparent text-gray-600 hover:bg-gray-100"
|
| 148 |
+
}`}
|
| 149 |
+
>
|
| 150 |
+
{m.name}
|
| 151 |
+
</button>
|
| 152 |
+
))}
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</DialogContent>
|
| 157 |
+
</Dialog>
|
| 158 |
+
);
|
| 159 |
+
}
|
src/components/chat/sidebar.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Bot, Plus, Settings, Trash2, MessageSquare, PanelLeftClose, PanelLeft, Zap, ZapOff } from "lucide-react";
|
| 4 |
+
import type { ChatSession, AppSettings } from "@/lib/types";
|
| 5 |
+
import { ANTHROPIC_MODELS, OPENAI_MODELS, DEEPSEEK_MODELS } from "@/lib/constants";
|
| 6 |
+
|
| 7 |
+
const PROVIDER_LABELS: Record<string, string> = {
|
| 8 |
+
deepseek: "DeepSeek",
|
| 9 |
+
openai: "OpenAI",
|
| 10 |
+
anthropic: "Claude",
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
interface Props {
|
| 14 |
+
sessions: ChatSession[];
|
| 15 |
+
currentSessionId: string | null;
|
| 16 |
+
onSelectSession: (id: string) => void;
|
| 17 |
+
onNewChat: () => void;
|
| 18 |
+
onDeleteSession: (id: string) => void;
|
| 19 |
+
onOpenSettings: () => void;
|
| 20 |
+
isOpen: boolean;
|
| 21 |
+
onToggle: () => void;
|
| 22 |
+
settings: AppSettings;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function Sidebar({
|
| 26 |
+
sessions,
|
| 27 |
+
currentSessionId,
|
| 28 |
+
onSelectSession,
|
| 29 |
+
onNewChat,
|
| 30 |
+
onDeleteSession,
|
| 31 |
+
onOpenSettings,
|
| 32 |
+
isOpen,
|
| 33 |
+
onToggle,
|
| 34 |
+
settings,
|
| 35 |
+
}: Props) {
|
| 36 |
+
const hasKey = !!(settings.apiKeys[settings.provider]);
|
| 37 |
+
const providerLabel = PROVIDER_LABELS[settings.provider] || settings.provider;
|
| 38 |
+
const allModels = settings.provider === "anthropic"
|
| 39 |
+
? ANTHROPIC_MODELS
|
| 40 |
+
: settings.provider === "deepseek"
|
| 41 |
+
? DEEPSEEK_MODELS
|
| 42 |
+
: OPENAI_MODELS;
|
| 43 |
+
const modelLabel = allModels.find((m) => m.id === settings.model)?.name || settings.model;
|
| 44 |
+
return (
|
| 45 |
+
<>
|
| 46 |
+
{/* Toggle button when sidebar is closed */}
|
| 47 |
+
{!isOpen && (
|
| 48 |
+
<button
|
| 49 |
+
onClick={onToggle}
|
| 50 |
+
className="fixed top-4 left-4 z-50 p-2 rounded-xl bg-white border border-gray-200 shadow-sm hover:bg-gray-50 transition-colors"
|
| 51 |
+
>
|
| 52 |
+
<PanelLeft className="w-5 h-5 text-gray-600" />
|
| 53 |
+
</button>
|
| 54 |
+
)}
|
| 55 |
+
|
| 56 |
+
{/* Sidebar */}
|
| 57 |
+
<div
|
| 58 |
+
className={`fixed inset-y-0 left-0 z-40 w-[280px] bg-gray-50 border-r border-gray-200 flex flex-col transition-transform duration-300 ${
|
| 59 |
+
isOpen ? "translate-x-0" : "-translate-x-full"
|
| 60 |
+
}`}
|
| 61 |
+
>
|
| 62 |
+
{/* Header */}
|
| 63 |
+
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-200">
|
| 64 |
+
<div className="flex items-center gap-2">
|
| 65 |
+
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center">
|
| 66 |
+
<Bot className="w-5 h-5 text-white" />
|
| 67 |
+
</div>
|
| 68 |
+
<span className="font-semibold text-gray-900 text-sm">
|
| 69 |
+
Open Chatbot
|
| 70 |
+
</span>
|
| 71 |
+
</div>
|
| 72 |
+
<button
|
| 73 |
+
onClick={onToggle}
|
| 74 |
+
className="p-1.5 rounded-lg hover:bg-gray-200 text-gray-500 transition-colors"
|
| 75 |
+
>
|
| 76 |
+
<PanelLeftClose className="w-5 h-5" />
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{/* API connection indicator */}
|
| 81 |
+
<button
|
| 82 |
+
onClick={onOpenSettings}
|
| 83 |
+
className={`mx-3 mt-3 flex items-center gap-2.5 px-3 py-2 rounded-xl text-xs transition-colors ${
|
| 84 |
+
hasKey
|
| 85 |
+
? "bg-emerald-50 border border-emerald-200 text-emerald-700 hover:bg-emerald-100"
|
| 86 |
+
: "bg-red-50 border border-red-200 text-red-600 hover:bg-red-100"
|
| 87 |
+
}`}
|
| 88 |
+
>
|
| 89 |
+
{hasKey ? (
|
| 90 |
+
<Zap className="w-3.5 h-3.5 flex-shrink-0" />
|
| 91 |
+
) : (
|
| 92 |
+
<ZapOff className="w-3.5 h-3.5 flex-shrink-0" />
|
| 93 |
+
)}
|
| 94 |
+
<div className="flex flex-col items-start min-w-0">
|
| 95 |
+
<span className="font-medium">
|
| 96 |
+
{hasKey ? `Terhubung — ${providerLabel}` : "Tidak terhubung"}
|
| 97 |
+
</span>
|
| 98 |
+
<span className={`truncate w-full ${hasKey ? "text-emerald-500" : "text-red-400"}`}>
|
| 99 |
+
{hasKey ? modelLabel : "API key belum diisi"}
|
| 100 |
+
</span>
|
| 101 |
+
</div>
|
| 102 |
+
</button>
|
| 103 |
+
|
| 104 |
+
{/* New chat button */}
|
| 105 |
+
<div className="px-3 py-3">
|
| 106 |
+
<button
|
| 107 |
+
onClick={onNewChat}
|
| 108 |
+
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-gray-200 bg-white hover:bg-gray-100 text-gray-700 text-sm font-medium transition-colors"
|
| 109 |
+
>
|
| 110 |
+
<Plus className="w-4 h-4" />
|
| 111 |
+
Chat Baru
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
{/* Chat history */}
|
| 116 |
+
<div className="flex-1 overflow-y-auto px-3 space-y-1">
|
| 117 |
+
{sessions.length === 0 && (
|
| 118 |
+
<p className="text-xs text-gray-400 text-center mt-8">
|
| 119 |
+
Belum ada riwayat chat
|
| 120 |
+
</p>
|
| 121 |
+
)}
|
| 122 |
+
{sessions.map((session) => (
|
| 123 |
+
<div
|
| 124 |
+
key={session.id}
|
| 125 |
+
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-colors ${
|
| 126 |
+
currentSessionId === session.id
|
| 127 |
+
? "bg-gray-200 text-gray-900"
|
| 128 |
+
: "hover:bg-gray-100 text-gray-600"
|
| 129 |
+
}`}
|
| 130 |
+
onClick={() => onSelectSession(session.id)}
|
| 131 |
+
>
|
| 132 |
+
<MessageSquare className="w-4 h-4 flex-shrink-0" />
|
| 133 |
+
<span className="text-sm truncate flex-1">{session.title}</span>
|
| 134 |
+
<button
|
| 135 |
+
onClick={(e) => {
|
| 136 |
+
e.stopPropagation();
|
| 137 |
+
onDeleteSession(session.id);
|
| 138 |
+
}}
|
| 139 |
+
className="p-1 rounded-lg hover:bg-gray-300 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
| 140 |
+
>
|
| 141 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 142 |
+
</button>
|
| 143 |
+
</div>
|
| 144 |
+
))}
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
{/* Settings button */}
|
| 148 |
+
<div className="px-3 py-3 border-t border-gray-200">
|
| 149 |
+
<button
|
| 150 |
+
onClick={onOpenSettings}
|
| 151 |
+
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl hover:bg-gray-100 text-gray-600 text-sm transition-colors"
|
| 152 |
+
>
|
| 153 |
+
<Settings className="w-4 h-4" />
|
| 154 |
+
Pengaturan
|
| 155 |
+
</button>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</>
|
| 159 |
+
);
|
| 160 |
+
}
|
src/components/chat/welcome-screen.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Bot } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
export function WelcomeScreen() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center select-none">
|
| 8 |
+
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center mb-5 shadow-lg">
|
| 9 |
+
<Bot className="w-8 h-8 text-white" />
|
| 10 |
+
</div>
|
| 11 |
+
<h1 className="text-2xl font-semibold text-gray-900 mb-1">
|
| 12 |
+
Open Chatbot
|
| 13 |
+
</h1>
|
| 14 |
+
<p className="text-[15px] text-gray-400">
|
| 15 |
+
Ada yang bisa saya bantu hari ini?
|
| 16 |
+
</p>
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
src/components/ui/avatar.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Avatar as AvatarPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Avatar({
|
| 9 |
+
className,
|
| 10 |
+
size = "default",
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
| 13 |
+
size?: "default" | "sm" | "lg"
|
| 14 |
+
}) {
|
| 15 |
+
return (
|
| 16 |
+
<AvatarPrimitive.Root
|
| 17 |
+
data-slot="avatar"
|
| 18 |
+
data-size={size}
|
| 19 |
+
className={cn(
|
| 20 |
+
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function AvatarImage({
|
| 29 |
+
className,
|
| 30 |
+
...props
|
| 31 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
| 32 |
+
return (
|
| 33 |
+
<AvatarPrimitive.Image
|
| 34 |
+
data-slot="avatar-image"
|
| 35 |
+
className={cn("aspect-square size-full", className)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function AvatarFallback({
|
| 42 |
+
className,
|
| 43 |
+
...props
|
| 44 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
| 45 |
+
return (
|
| 46 |
+
<AvatarPrimitive.Fallback
|
| 47 |
+
data-slot="avatar-fallback"
|
| 48 |
+
className={cn(
|
| 49 |
+
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
| 50 |
+
className
|
| 51 |
+
)}
|
| 52 |
+
{...props}
|
| 53 |
+
/>
|
| 54 |
+
)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
| 58 |
+
return (
|
| 59 |
+
<span
|
| 60 |
+
data-slot="avatar-badge"
|
| 61 |
+
className={cn(
|
| 62 |
+
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
|
| 63 |
+
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
| 64 |
+
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
| 65 |
+
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
| 66 |
+
className
|
| 67 |
+
)}
|
| 68 |
+
{...props}
|
| 69 |
+
/>
|
| 70 |
+
)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
| 74 |
+
return (
|
| 75 |
+
<div
|
| 76 |
+
data-slot="avatar-group"
|
| 77 |
+
className={cn(
|
| 78 |
+
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
| 79 |
+
className
|
| 80 |
+
)}
|
| 81 |
+
{...props}
|
| 82 |
+
/>
|
| 83 |
+
)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function AvatarGroupCount({
|
| 87 |
+
className,
|
| 88 |
+
...props
|
| 89 |
+
}: React.ComponentProps<"div">) {
|
| 90 |
+
return (
|
| 91 |
+
<div
|
| 92 |
+
data-slot="avatar-group-count"
|
| 93 |
+
className={cn(
|
| 94 |
+
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
| 95 |
+
className
|
| 96 |
+
)}
|
| 97 |
+
{...props}
|
| 98 |
+
/>
|
| 99 |
+
)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export {
|
| 103 |
+
Avatar,
|
| 104 |
+
AvatarImage,
|
| 105 |
+
AvatarFallback,
|
| 106 |
+
AvatarBadge,
|
| 107 |
+
AvatarGroup,
|
| 108 |
+
AvatarGroupCount,
|
| 109 |
+
}
|
src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
import { Slot } from "radix-ui"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const badgeVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
| 13 |
+
secondary:
|
| 14 |
+
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
| 15 |
+
destructive:
|
| 16 |
+
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 17 |
+
outline:
|
| 18 |
+
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
| 19 |
+
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
| 20 |
+
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
defaultVariants: {
|
| 24 |
+
variant: "default",
|
| 25 |
+
},
|
| 26 |
+
}
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
function Badge({
|
| 30 |
+
className,
|
| 31 |
+
variant = "default",
|
| 32 |
+
asChild = false,
|
| 33 |
+
...props
|
| 34 |
+
}: React.ComponentProps<"span"> &
|
| 35 |
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
| 36 |
+
const Comp = asChild ? Slot.Root : "span"
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<Comp
|
| 40 |
+
data-slot="badge"
|
| 41 |
+
data-variant={variant}
|
| 42 |
+
className={cn(badgeVariants({ variant }), className)}
|
| 43 |
+
{...props}
|
| 44 |
+
/>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export { Badge, badgeVariants }
|
src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
import { Slot } from "radix-ui"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 13 |
+
destructive:
|
| 14 |
+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 15 |
+
outline:
|
| 16 |
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
| 17 |
+
secondary:
|
| 18 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 19 |
+
ghost:
|
| 20 |
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
| 21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
+
},
|
| 23 |
+
size: {
|
| 24 |
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
| 25 |
+
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
| 26 |
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
| 27 |
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
| 28 |
+
icon: "size-9",
|
| 29 |
+
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
| 30 |
+
"icon-sm": "size-8",
|
| 31 |
+
"icon-lg": "size-10",
|
| 32 |
+
},
|
| 33 |
+
},
|
| 34 |
+
defaultVariants: {
|
| 35 |
+
variant: "default",
|
| 36 |
+
size: "default",
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
function Button({
|
| 42 |
+
className,
|
| 43 |
+
variant = "default",
|
| 44 |
+
size = "default",
|
| 45 |
+
asChild = false,
|
| 46 |
+
...props
|
| 47 |
+
}: React.ComponentProps<"button"> &
|
| 48 |
+
VariantProps<typeof buttonVariants> & {
|
| 49 |
+
asChild?: boolean
|
| 50 |
+
}) {
|
| 51 |
+
const Comp = asChild ? Slot.Root : "button"
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<Comp
|
| 55 |
+
data-slot="button"
|
| 56 |
+
data-variant={variant}
|
| 57 |
+
data-size={size}
|
| 58 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 59 |
+
{...props}
|
| 60 |
+
/>
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export { Button, buttonVariants }
|
src/components/ui/dialog.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { XIcon } from "lucide-react"
|
| 5 |
+
import { Dialog as DialogPrimitive } from "radix-ui"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
import { Button } from "@/components/ui/button"
|
| 9 |
+
|
| 10 |
+
function Dialog({
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
| 13 |
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function DialogTrigger({
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
| 19 |
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function DialogPortal({
|
| 23 |
+
...props
|
| 24 |
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
| 25 |
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function DialogClose({
|
| 29 |
+
...props
|
| 30 |
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
| 31 |
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function DialogOverlay({
|
| 35 |
+
className,
|
| 36 |
+
...props
|
| 37 |
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
| 38 |
+
return (
|
| 39 |
+
<DialogPrimitive.Overlay
|
| 40 |
+
data-slot="dialog-overlay"
|
| 41 |
+
className={cn(
|
| 42 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
/>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function DialogContent({
|
| 51 |
+
className,
|
| 52 |
+
children,
|
| 53 |
+
showCloseButton = true,
|
| 54 |
+
...props
|
| 55 |
+
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
| 56 |
+
showCloseButton?: boolean
|
| 57 |
+
}) {
|
| 58 |
+
return (
|
| 59 |
+
<DialogPortal data-slot="dialog-portal">
|
| 60 |
+
<DialogOverlay />
|
| 61 |
+
<DialogPrimitive.Content
|
| 62 |
+
data-slot="dialog-content"
|
| 63 |
+
className={cn(
|
| 64 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
| 65 |
+
className
|
| 66 |
+
)}
|
| 67 |
+
{...props}
|
| 68 |
+
>
|
| 69 |
+
{children}
|
| 70 |
+
{showCloseButton && (
|
| 71 |
+
<DialogPrimitive.Close
|
| 72 |
+
data-slot="dialog-close"
|
| 73 |
+
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
| 74 |
+
>
|
| 75 |
+
<XIcon />
|
| 76 |
+
<span className="sr-only">Close</span>
|
| 77 |
+
</DialogPrimitive.Close>
|
| 78 |
+
)}
|
| 79 |
+
</DialogPrimitive.Content>
|
| 80 |
+
</DialogPortal>
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 85 |
+
return (
|
| 86 |
+
<div
|
| 87 |
+
data-slot="dialog-header"
|
| 88 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
| 89 |
+
{...props}
|
| 90 |
+
/>
|
| 91 |
+
)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function DialogFooter({
|
| 95 |
+
className,
|
| 96 |
+
showCloseButton = false,
|
| 97 |
+
children,
|
| 98 |
+
...props
|
| 99 |
+
}: React.ComponentProps<"div"> & {
|
| 100 |
+
showCloseButton?: boolean
|
| 101 |
+
}) {
|
| 102 |
+
return (
|
| 103 |
+
<div
|
| 104 |
+
data-slot="dialog-footer"
|
| 105 |
+
className={cn(
|
| 106 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 107 |
+
className
|
| 108 |
+
)}
|
| 109 |
+
{...props}
|
| 110 |
+
>
|
| 111 |
+
{children}
|
| 112 |
+
{showCloseButton && (
|
| 113 |
+
<DialogPrimitive.Close asChild>
|
| 114 |
+
<Button variant="outline">Close</Button>
|
| 115 |
+
</DialogPrimitive.Close>
|
| 116 |
+
)}
|
| 117 |
+
</div>
|
| 118 |
+
)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function DialogTitle({
|
| 122 |
+
className,
|
| 123 |
+
...props
|
| 124 |
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
| 125 |
+
return (
|
| 126 |
+
<DialogPrimitive.Title
|
| 127 |
+
data-slot="dialog-title"
|
| 128 |
+
className={cn("text-lg leading-none font-semibold", className)}
|
| 129 |
+
{...props}
|
| 130 |
+
/>
|
| 131 |
+
)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function DialogDescription({
|
| 135 |
+
className,
|
| 136 |
+
...props
|
| 137 |
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
| 138 |
+
return (
|
| 139 |
+
<DialogPrimitive.Description
|
| 140 |
+
data-slot="dialog-description"
|
| 141 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 142 |
+
{...props}
|
| 143 |
+
/>
|
| 144 |
+
)
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export {
|
| 148 |
+
Dialog,
|
| 149 |
+
DialogClose,
|
| 150 |
+
DialogContent,
|
| 151 |
+
DialogDescription,
|
| 152 |
+
DialogFooter,
|
| 153 |
+
DialogHeader,
|
| 154 |
+
DialogOverlay,
|
| 155 |
+
DialogPortal,
|
| 156 |
+
DialogTitle,
|
| 157 |
+
DialogTrigger,
|
| 158 |
+
}
|
src/components/ui/scroll-area.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function ScrollArea({
|
| 9 |
+
className,
|
| 10 |
+
children,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<ScrollAreaPrimitive.Root
|
| 15 |
+
data-slot="scroll-area"
|
| 16 |
+
className={cn("relative", className)}
|
| 17 |
+
{...props}
|
| 18 |
+
>
|
| 19 |
+
<ScrollAreaPrimitive.Viewport
|
| 20 |
+
data-slot="scroll-area-viewport"
|
| 21 |
+
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
| 22 |
+
>
|
| 23 |
+
{children}
|
| 24 |
+
</ScrollAreaPrimitive.Viewport>
|
| 25 |
+
<ScrollBar />
|
| 26 |
+
<ScrollAreaPrimitive.Corner />
|
| 27 |
+
</ScrollAreaPrimitive.Root>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function ScrollBar({
|
| 32 |
+
className,
|
| 33 |
+
orientation = "vertical",
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
| 36 |
+
return (
|
| 37 |
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
| 38 |
+
data-slot="scroll-area-scrollbar"
|
| 39 |
+
orientation={orientation}
|
| 40 |
+
className={cn(
|
| 41 |
+
"flex touch-none p-px transition-colors select-none",
|
| 42 |
+
orientation === "vertical" &&
|
| 43 |
+
"h-full w-2.5 border-l border-l-transparent",
|
| 44 |
+
orientation === "horizontal" &&
|
| 45 |
+
"h-2.5 flex-col border-t border-t-transparent",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
<ScrollAreaPrimitive.ScrollAreaThumb
|
| 51 |
+
data-slot="scroll-area-thumb"
|
| 52 |
+
className="bg-border relative flex-1 rounded-full"
|
| 53 |
+
/>
|
| 54 |
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 55 |
+
)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export { ScrollArea, ScrollBar }
|
src/components/ui/separator.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Separator as SeparatorPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Separator({
|
| 9 |
+
className,
|
| 10 |
+
orientation = "horizontal",
|
| 11 |
+
decorative = true,
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
| 14 |
+
return (
|
| 15 |
+
<SeparatorPrimitive.Root
|
| 16 |
+
data-slot="separator"
|
| 17 |
+
decorative={decorative}
|
| 18 |
+
orientation={orientation}
|
| 19 |
+
className={cn(
|
| 20 |
+
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export { Separator }
|
src/components/ui/sheet.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { XIcon } from "lucide-react"
|
| 5 |
+
import { Dialog as SheetPrimitive } from "radix-ui"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
| 10 |
+
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function SheetTrigger({
|
| 14 |
+
...props
|
| 15 |
+
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
| 16 |
+
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function SheetClose({
|
| 20 |
+
...props
|
| 21 |
+
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
| 22 |
+
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function SheetPortal({
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
| 28 |
+
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function SheetOverlay({
|
| 32 |
+
className,
|
| 33 |
+
...props
|
| 34 |
+
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
| 35 |
+
return (
|
| 36 |
+
<SheetPrimitive.Overlay
|
| 37 |
+
data-slot="sheet-overlay"
|
| 38 |
+
className={cn(
|
| 39 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
| 40 |
+
className
|
| 41 |
+
)}
|
| 42 |
+
{...props}
|
| 43 |
+
/>
|
| 44 |
+
)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function SheetContent({
|
| 48 |
+
className,
|
| 49 |
+
children,
|
| 50 |
+
side = "right",
|
| 51 |
+
showCloseButton = true,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
| 54 |
+
side?: "top" | "right" | "bottom" | "left"
|
| 55 |
+
showCloseButton?: boolean
|
| 56 |
+
}) {
|
| 57 |
+
return (
|
| 58 |
+
<SheetPortal>
|
| 59 |
+
<SheetOverlay />
|
| 60 |
+
<SheetPrimitive.Content
|
| 61 |
+
data-slot="sheet-content"
|
| 62 |
+
className={cn(
|
| 63 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
| 64 |
+
side === "right" &&
|
| 65 |
+
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
| 66 |
+
side === "left" &&
|
| 67 |
+
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
| 68 |
+
side === "top" &&
|
| 69 |
+
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
| 70 |
+
side === "bottom" &&
|
| 71 |
+
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
| 72 |
+
className
|
| 73 |
+
)}
|
| 74 |
+
{...props}
|
| 75 |
+
>
|
| 76 |
+
{children}
|
| 77 |
+
{showCloseButton && (
|
| 78 |
+
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
| 79 |
+
<XIcon className="size-4" />
|
| 80 |
+
<span className="sr-only">Close</span>
|
| 81 |
+
</SheetPrimitive.Close>
|
| 82 |
+
)}
|
| 83 |
+
</SheetPrimitive.Content>
|
| 84 |
+
</SheetPortal>
|
| 85 |
+
)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 89 |
+
return (
|
| 90 |
+
<div
|
| 91 |
+
data-slot="sheet-header"
|
| 92 |
+
className={cn("flex flex-col gap-1.5 p-4", className)}
|
| 93 |
+
{...props}
|
| 94 |
+
/>
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 99 |
+
return (
|
| 100 |
+
<div
|
| 101 |
+
data-slot="sheet-footer"
|
| 102 |
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
| 103 |
+
{...props}
|
| 104 |
+
/>
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function SheetTitle({
|
| 109 |
+
className,
|
| 110 |
+
...props
|
| 111 |
+
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
| 112 |
+
return (
|
| 113 |
+
<SheetPrimitive.Title
|
| 114 |
+
data-slot="sheet-title"
|
| 115 |
+
className={cn("text-foreground font-semibold", className)}
|
| 116 |
+
{...props}
|
| 117 |
+
/>
|
| 118 |
+
)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function SheetDescription({
|
| 122 |
+
className,
|
| 123 |
+
...props
|
| 124 |
+
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
| 125 |
+
return (
|
| 126 |
+
<SheetPrimitive.Description
|
| 127 |
+
data-slot="sheet-description"
|
| 128 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 129 |
+
{...props}
|
| 130 |
+
/>
|
| 131 |
+
)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export {
|
| 135 |
+
Sheet,
|
| 136 |
+
SheetTrigger,
|
| 137 |
+
SheetClose,
|
| 138 |
+
SheetContent,
|
| 139 |
+
SheetHeader,
|
| 140 |
+
SheetFooter,
|
| 141 |
+
SheetTitle,
|
| 142 |
+
SheetDescription,
|
| 143 |
+
}
|
src/components/ui/textarea.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
| 6 |
+
return (
|
| 7 |
+
<textarea
|
| 8 |
+
data-slot="textarea"
|
| 9 |
+
className={cn(
|
| 10 |
+
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export { Textarea }
|
src/components/ui/tooltip.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function TooltipProvider({
|
| 9 |
+
delayDuration = 0,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
| 12 |
+
return (
|
| 13 |
+
<TooltipPrimitive.Provider
|
| 14 |
+
data-slot="tooltip-provider"
|
| 15 |
+
delayDuration={delayDuration}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function Tooltip({
|
| 22 |
+
...props
|
| 23 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
| 24 |
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function TooltipTrigger({
|
| 28 |
+
...props
|
| 29 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
| 30 |
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function TooltipContent({
|
| 34 |
+
className,
|
| 35 |
+
sideOffset = 0,
|
| 36 |
+
children,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
| 39 |
+
return (
|
| 40 |
+
<TooltipPrimitive.Portal>
|
| 41 |
+
<TooltipPrimitive.Content
|
| 42 |
+
data-slot="tooltip-content"
|
| 43 |
+
sideOffset={sideOffset}
|
| 44 |
+
className={cn(
|
| 45 |
+
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
{children}
|
| 51 |
+
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
| 52 |
+
</TooltipPrimitive.Content>
|
| 53 |
+
</TooltipPrimitive.Portal>
|
| 54 |
+
)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
src/hooks/use-chat-store.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
| 4 |
+
import type { ChatSession, AppSettings, FileContext } from "@/lib/types";
|
| 5 |
+
import { DEFAULT_SETTINGS } from "@/lib/constants";
|
| 6 |
+
import { v4 as uuid } from "uuid";
|
| 7 |
+
|
| 8 |
+
const SESSIONS_KEY = "chatbot-sessions";
|
| 9 |
+
const MESSAGES_PREFIX = "chatbot-msgs-";
|
| 10 |
+
const FILES_PREFIX = "chatbot-files-";
|
| 11 |
+
const SETTINGS_KEY = "chatbot-settings";
|
| 12 |
+
|
| 13 |
+
export interface StoredMessage {
|
| 14 |
+
id: string;
|
| 15 |
+
role: "user" | "assistant";
|
| 16 |
+
content: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function readLocalStorage<T>(key: string, fallback: T): T {
|
| 20 |
+
if (typeof window === "undefined") return fallback;
|
| 21 |
+
try {
|
| 22 |
+
const stored = localStorage.getItem(key);
|
| 23 |
+
return stored ? JSON.parse(stored) : fallback;
|
| 24 |
+
} catch {
|
| 25 |
+
return fallback;
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function readSettings(): AppSettings {
|
| 30 |
+
const stored = readLocalStorage<AppSettings | null>(SETTINGS_KEY, null);
|
| 31 |
+
if (!stored) return DEFAULT_SETTINGS;
|
| 32 |
+
// Deep merge: preserves user choices while adding new fields from defaults
|
| 33 |
+
return {
|
| 34 |
+
...DEFAULT_SETTINGS,
|
| 35 |
+
...stored,
|
| 36 |
+
apiKeys: {
|
| 37 |
+
...DEFAULT_SETTINGS.apiKeys,
|
| 38 |
+
...(stored.apiKeys || {}),
|
| 39 |
+
},
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// SSR-safe hydration detection using useSyncExternalStore
|
| 44 |
+
const emptySubscribe = () => () => {};
|
| 45 |
+
const getClientSnapshot = () => true;
|
| 46 |
+
const getServerSnapshot = () => false;
|
| 47 |
+
|
| 48 |
+
function useHydrated(): boolean {
|
| 49 |
+
return useSyncExternalStore(emptySubscribe, getClientSnapshot, getServerSnapshot);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export function useChatStore() {
|
| 53 |
+
const [sessions, setSessions] = useState<ChatSession[]>(() => readLocalStorage(SESSIONS_KEY, []));
|
| 54 |
+
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
| 55 |
+
const [settings, setSettings] = useState<AppSettings>(() => readSettings());
|
| 56 |
+
const hydrated = useHydrated();
|
| 57 |
+
|
| 58 |
+
// Persist sessions (skip first render to avoid writing hydrated state back)
|
| 59 |
+
const sessionsInitRef = useRef(false);
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
if (!sessionsInitRef.current) {
|
| 62 |
+
sessionsInitRef.current = true;
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
try {
|
| 66 |
+
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
|
| 67 |
+
} catch {
|
| 68 |
+
console.warn("[store] Failed to persist sessions to localStorage (quota?)");
|
| 69 |
+
}
|
| 70 |
+
}, [sessions]);
|
| 71 |
+
|
| 72 |
+
// Persist settings
|
| 73 |
+
const settingsInitRef = useRef(false);
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
if (!settingsInitRef.current) {
|
| 76 |
+
settingsInitRef.current = true;
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
try {
|
| 80 |
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
| 81 |
+
} catch {
|
| 82 |
+
console.warn("[store] Failed to persist settings to localStorage (quota?)");
|
| 83 |
+
}
|
| 84 |
+
}, [settings]);
|
| 85 |
+
|
| 86 |
+
const createSession = useCallback((): string => {
|
| 87 |
+
const id = uuid();
|
| 88 |
+
const session: ChatSession = {
|
| 89 |
+
id,
|
| 90 |
+
title: "Chat Baru",
|
| 91 |
+
createdAt: new Date().toISOString(),
|
| 92 |
+
updatedAt: new Date().toISOString(),
|
| 93 |
+
};
|
| 94 |
+
setSessions((prev) => [session, ...prev]);
|
| 95 |
+
setCurrentSessionId(id);
|
| 96 |
+
return id;
|
| 97 |
+
}, []);
|
| 98 |
+
|
| 99 |
+
const deleteSession = useCallback(
|
| 100 |
+
(id: string) => {
|
| 101 |
+
setSessions((prev) => prev.filter((s) => s.id !== id));
|
| 102 |
+
localStorage.removeItem(MESSAGES_PREFIX + id);
|
| 103 |
+
localStorage.removeItem(FILES_PREFIX + id);
|
| 104 |
+
if (currentSessionId === id) {
|
| 105 |
+
setCurrentSessionId(null);
|
| 106 |
+
}
|
| 107 |
+
},
|
| 108 |
+
[currentSessionId]
|
| 109 |
+
);
|
| 110 |
+
|
| 111 |
+
const updateSessionTitle = useCallback((id: string, title: string) => {
|
| 112 |
+
setSessions((prev) => {
|
| 113 |
+
const session = prev.find((s) => s.id === id);
|
| 114 |
+
// Skip update if title is already the same — prevents infinite re-render loop
|
| 115 |
+
if (session && session.title === title) return prev;
|
| 116 |
+
return prev.map((s) =>
|
| 117 |
+
s.id === id ? { ...s, title, updatedAt: new Date().toISOString() } : s
|
| 118 |
+
);
|
| 119 |
+
});
|
| 120 |
+
}, []);
|
| 121 |
+
|
| 122 |
+
const getMessages = useCallback((sessionId: string): StoredMessage[] => {
|
| 123 |
+
try {
|
| 124 |
+
const stored = localStorage.getItem(MESSAGES_PREFIX + sessionId);
|
| 125 |
+
return stored ? JSON.parse(stored) : [];
|
| 126 |
+
} catch {
|
| 127 |
+
return [];
|
| 128 |
+
}
|
| 129 |
+
}, []);
|
| 130 |
+
|
| 131 |
+
const saveFileContexts = useCallback((sessionId: string, files: FileContext[]) => {
|
| 132 |
+
if (files.length > 0) {
|
| 133 |
+
try {
|
| 134 |
+
// Only save metadata (no text) — text is too large for localStorage.
|
| 135 |
+
// Files restored from localStorage will have empty text and need re-upload for full context.
|
| 136 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 137 |
+
const metadata = files.map(({ text: _t, ...rest }) => ({
|
| 138 |
+
...rest,
|
| 139 |
+
text: "",
|
| 140 |
+
}));
|
| 141 |
+
localStorage.setItem(FILES_PREFIX + sessionId, JSON.stringify(metadata));
|
| 142 |
+
} catch {
|
| 143 |
+
console.warn("[store] Failed to persist file contexts to localStorage (quota?)");
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
}, []);
|
| 147 |
+
|
| 148 |
+
const getFileContexts = useCallback((sessionId: string): FileContext[] => {
|
| 149 |
+
try {
|
| 150 |
+
const stored = localStorage.getItem(FILES_PREFIX + sessionId);
|
| 151 |
+
return stored ? JSON.parse(stored) : [];
|
| 152 |
+
} catch {
|
| 153 |
+
return [];
|
| 154 |
+
}
|
| 155 |
+
}, []);
|
| 156 |
+
|
| 157 |
+
const saveMessages = useCallback((sessionId: string, messages: StoredMessage[]) => {
|
| 158 |
+
try {
|
| 159 |
+
localStorage.setItem(MESSAGES_PREFIX + sessionId, JSON.stringify(messages));
|
| 160 |
+
} catch {
|
| 161 |
+
console.warn("[store] Failed to persist messages to localStorage (quota?)");
|
| 162 |
+
}
|
| 163 |
+
// Update title from first user message
|
| 164 |
+
if (messages.length >= 1) {
|
| 165 |
+
const firstUserMsg = messages.find((m) => m.role === "user");
|
| 166 |
+
if (firstUserMsg) {
|
| 167 |
+
const title = firstUserMsg.content.slice(0, 50) + (firstUserMsg.content.length > 50 ? "..." : "");
|
| 168 |
+
updateSessionTitle(sessionId, title);
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
}, [updateSessionTitle]);
|
| 172 |
+
|
| 173 |
+
const updateSettings = useCallback((newSettings: AppSettings) => {
|
| 174 |
+
setSettings(newSettings);
|
| 175 |
+
}, []);
|
| 176 |
+
|
| 177 |
+
return {
|
| 178 |
+
sessions,
|
| 179 |
+
currentSessionId,
|
| 180 |
+
setCurrentSessionId,
|
| 181 |
+
settings,
|
| 182 |
+
updateSettings,
|
| 183 |
+
createSession,
|
| 184 |
+
deleteSession,
|
| 185 |
+
getMessages,
|
| 186 |
+
saveMessages,
|
| 187 |
+
getFileContexts,
|
| 188 |
+
saveFileContexts,
|
| 189 |
+
hydrated,
|
| 190 |
+
};
|
| 191 |
+
}
|
src/lib/constants.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const SUPPORTED_EXTENSIONS = [
|
| 2 |
+
"pdf", "doc", "docx", "xlsx", "xls", "csv",
|
| 3 |
+
"png", "jpg", "jpeg", "bmp", "tiff", "tif", "webp",
|
| 4 |
+
"txt", "md", "json", "xml", "html", "log", "py", "js", "ts", "java", "c", "cpp",
|
| 5 |
+
];
|
| 6 |
+
|
| 7 |
+
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "bmp", "tiff", "tif", "webp"];
|
| 8 |
+
export const TEXT_EXTENSIONS = ["txt", "md", "json", "xml", "html", "log", "py", "js", "ts", "java", "c", "cpp"];
|
| 9 |
+
|
| 10 |
+
export const ACCEPT_FILE_TYPES =
|
| 11 |
+
".pdf,.doc,.docx,.xlsx,.xls,.csv,.png,.jpg,.jpeg,.bmp,.tiff,.tif,.webp,.txt,.md,.json,.xml,.html,.log,.py,.js,.ts,.java,.c,.cpp";
|
| 12 |
+
|
| 13 |
+
export const ANTHROPIC_MODELS = [
|
| 14 |
+
{ id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" },
|
| 15 |
+
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
| 16 |
+
];
|
| 17 |
+
|
| 18 |
+
export const OPENAI_MODELS = [
|
| 19 |
+
{ id: "gpt-4o", name: "GPT-4o" },
|
| 20 |
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
| 21 |
+
{ id: "gpt-4.1", name: "GPT-4.1" },
|
| 22 |
+
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
export const DEEPSEEK_MODELS = [
|
| 26 |
+
{ id: "deepseek-chat", name: "DeepSeek Chat" },
|
| 27 |
+
{ id: "deepseek-reasoner", name: "DeepSeek Reasoner" },
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
export const SYSTEM_PROMPT = `Kamu adalah asisten AI bernama Open Chatbot yang membantu menganalisis dokumen dan menjawab pertanyaan. Jawab dalam bahasa yang sama dengan pertanyaan user.
|
| 31 |
+
|
| 32 |
+
PENTING: Jawaban kamu memiliki batas panjang. Jika membuat tabel perbandingan, buat ringkas dan padat. Gunakan singkatan jika perlu. Jika tabel terlalu panjang, bagi menjadi beberapa bagian dan selesaikan bagian pertama dulu. Di akhir, tulis "[LANJUT]" jika masih ada bagian yang belum disampaikan, sehingga user bisa meminta kelanjutannya.
|
| 33 |
+
|
| 34 |
+
FORMAT MATEMATIKA: Saat menulis rumus atau ekspresi matematika, SELALU gunakan format LaTeX dengan delimiter:
|
| 35 |
+
- Inline math: $rumus$ (contoh: $x^2 + y^2 = z^2$)
|
| 36 |
+
- Block/display math: $$rumus$$ (contoh: $$\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$$)
|
| 37 |
+
JANGAN pernah menulis LaTeX tanpa delimiter $ atau $$. JANGAN gunakan \\[...\\] atau \\(...\\).`;
|
| 38 |
+
|
| 39 |
+
export const DEFAULT_SETTINGS = {
|
| 40 |
+
provider: "deepseek" as const,
|
| 41 |
+
model: "deepseek-chat",
|
| 42 |
+
apiKeys: {
|
| 43 |
+
openai: "",
|
| 44 |
+
anthropic: "",
|
| 45 |
+
deepseek: "",
|
| 46 |
+
},
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
export const BADGE_COLORS: Record<string, string> = {
|
| 50 |
+
pdf: "bg-red-500",
|
| 51 |
+
docx: "bg-blue-500",
|
| 52 |
+
doc: "bg-blue-500",
|
| 53 |
+
xlsx: "bg-green-500",
|
| 54 |
+
xls: "bg-green-500",
|
| 55 |
+
csv: "bg-amber-500",
|
| 56 |
+
png: "bg-purple-500",
|
| 57 |
+
jpg: "bg-purple-500",
|
| 58 |
+
jpeg: "bg-purple-500",
|
| 59 |
+
bmp: "bg-purple-500",
|
| 60 |
+
tiff: "bg-purple-500",
|
| 61 |
+
tif: "bg-purple-500",
|
| 62 |
+
webp: "bg-purple-500",
|
| 63 |
+
txt: "bg-gray-500",
|
| 64 |
+
md: "bg-gray-500",
|
| 65 |
+
json: "bg-gray-600",
|
| 66 |
+
xml: "bg-gray-600",
|
| 67 |
+
html: "bg-orange-500",
|
| 68 |
+
py: "bg-yellow-600",
|
| 69 |
+
js: "bg-yellow-500",
|
| 70 |
+
ts: "bg-blue-600",
|
| 71 |
+
};
|
src/lib/file-processor.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
| 2 |
+
import { IMAGE_EXTENSIONS, TEXT_EXTENSIONS, SUPPORTED_EXTENSIONS } from "./constants";
|
| 3 |
+
import type { FileContext } from "./types";
|
| 4 |
+
import { v4 as uuidv4 } from "uuid";
|
| 5 |
+
|
| 6 |
+
function getExtension(filename: string): string {
|
| 7 |
+
const parts = filename.split(".");
|
| 8 |
+
return parts.length > 1 ? parts.pop()!.toLowerCase() : "";
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export async function processFile(
|
| 12 |
+
buffer: Buffer,
|
| 13 |
+
filename: string
|
| 14 |
+
): Promise<FileContext> {
|
| 15 |
+
const ext = getExtension(filename);
|
| 16 |
+
const result: FileContext = {
|
| 17 |
+
id: uuidv4(),
|
| 18 |
+
filename,
|
| 19 |
+
extension: ext,
|
| 20 |
+
text: "",
|
| 21 |
+
error: null,
|
| 22 |
+
size: buffer.length,
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
| 26 |
+
result.error = `Format '.${ext}' belum didukung.`;
|
| 27 |
+
return result;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
try {
|
| 31 |
+
if (ext === "pdf") {
|
| 32 |
+
result.text = await processPdf(buffer);
|
| 33 |
+
} else if (ext === "doc") {
|
| 34 |
+
result.text = await processDoc(buffer);
|
| 35 |
+
} else if (ext === "docx") {
|
| 36 |
+
result.text = await processDocx(buffer);
|
| 37 |
+
} else if (ext === "xlsx" || ext === "xls") {
|
| 38 |
+
result.text = processExcel(buffer);
|
| 39 |
+
} else if (ext === "csv") {
|
| 40 |
+
result.text = processCsv(buffer);
|
| 41 |
+
} else if (IMAGE_EXTENSIONS.includes(ext)) {
|
| 42 |
+
result.text = await processImage(buffer);
|
| 43 |
+
} else if (TEXT_EXTENSIONS.includes(ext)) {
|
| 44 |
+
result.text = processText(buffer);
|
| 45 |
+
} else {
|
| 46 |
+
result.text = processText(buffer);
|
| 47 |
+
}
|
| 48 |
+
} catch (e: unknown) {
|
| 49 |
+
result.error = `Error memproses '${filename}': ${e instanceof Error ? e.message : String(e)}`;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return result;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function processPdf(buffer: Buffer): Promise<string> {
|
| 56 |
+
const { writeFile, readFile } = require("fs/promises");
|
| 57 |
+
const { mkdtemp, rm } = require("fs/promises");
|
| 58 |
+
const { tmpdir } = require("os");
|
| 59 |
+
const path = require("path");
|
| 60 |
+
const { execFile } = require("child_process");
|
| 61 |
+
|
| 62 |
+
// Step 1: Try fast text extraction with pdftotext CLI (poppler)
|
| 63 |
+
const tmpDir = await mkdtemp(path.join(tmpdir(), "pdf-txt-"));
|
| 64 |
+
const pdfPath = path.join(tmpDir, "input.pdf");
|
| 65 |
+
const txtPath = path.join(tmpDir, "output.txt");
|
| 66 |
+
|
| 67 |
+
try {
|
| 68 |
+
await writeFile(pdfPath, buffer);
|
| 69 |
+
|
| 70 |
+
await new Promise<void>((resolve, reject) => {
|
| 71 |
+
execFile(
|
| 72 |
+
"pdftotext",
|
| 73 |
+
["-layout", pdfPath, txtPath],
|
| 74 |
+
{ timeout: 15000 },
|
| 75 |
+
(error: Error | null) => {
|
| 76 |
+
if (error) reject(error);
|
| 77 |
+
else resolve();
|
| 78 |
+
}
|
| 79 |
+
);
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
const text = (await readFile(txtPath, "utf-8")).trim();
|
| 83 |
+
console.log(`[pdf] pdftotext extracted ${text.length} chars`);
|
| 84 |
+
|
| 85 |
+
if (text.length > 50) {
|
| 86 |
+
return text;
|
| 87 |
+
}
|
| 88 |
+
} catch (e) {
|
| 89 |
+
console.log(`[pdf] pdftotext failed: ${e instanceof Error ? e.message : String(e)}`);
|
| 90 |
+
} finally {
|
| 91 |
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Step 2: Fallback — convert PDF pages to images with pdftoppm, then OCR
|
| 95 |
+
console.log("[pdf] Text extraction empty, starting OCR fallback...");
|
| 96 |
+
return await ocrPdf(buffer);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
async function ocrPdf(buffer: Buffer): Promise<string> {
|
| 100 |
+
const { writeFile, readFile, readdir } = require("fs/promises");
|
| 101 |
+
const { mkdtemp, rm } = require("fs/promises");
|
| 102 |
+
const { tmpdir } = require("os");
|
| 103 |
+
const path = require("path");
|
| 104 |
+
const { execFile } = require("child_process");
|
| 105 |
+
|
| 106 |
+
const tmpDir = await mkdtemp(path.join(tmpdir(), "pdf-ocr-"));
|
| 107 |
+
const pdfPath = path.join(tmpDir, "input.pdf");
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
await writeFile(pdfPath, buffer);
|
| 111 |
+
|
| 112 |
+
// Convert PDF to PNG images using pdftoppm (poppler)
|
| 113 |
+
// Always limit to 20 pages max to avoid excessive processing
|
| 114 |
+
const args = ["-png", "-r", "300", "-l", "20", pdfPath, path.join(tmpDir, "page")];
|
| 115 |
+
await new Promise<void>((resolve, reject) => {
|
| 116 |
+
execFile(
|
| 117 |
+
"pdftoppm",
|
| 118 |
+
args,
|
| 119 |
+
{ timeout: 120000 },
|
| 120 |
+
(error: Error | null) => {
|
| 121 |
+
if (error) reject(error);
|
| 122 |
+
else resolve();
|
| 123 |
+
}
|
| 124 |
+
);
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
// Find all generated page images
|
| 128 |
+
const files = await readdir(tmpDir);
|
| 129 |
+
const pageFiles = files
|
| 130 |
+
.filter((f: string) => f.startsWith("page") && f.endsWith(".png"))
|
| 131 |
+
.sort();
|
| 132 |
+
|
| 133 |
+
if (pageFiles.length === 0) {
|
| 134 |
+
return "(PDF berisi gambar tapi tidak dapat di-OCR)";
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// OCR each page
|
| 138 |
+
const results: string[] = [];
|
| 139 |
+
for (const pageFile of pageFiles) {
|
| 140 |
+
const imgPath = path.join(tmpDir, pageFile);
|
| 141 |
+
const ocrBase = path.join(tmpDir, `ocr-${pageFile}`);
|
| 142 |
+
const ocrPath = ocrBase + ".txt";
|
| 143 |
+
|
| 144 |
+
try {
|
| 145 |
+
await new Promise<void>((resolve, reject) => {
|
| 146 |
+
execFile(
|
| 147 |
+
"tesseract",
|
| 148 |
+
[imgPath, ocrBase, "-l", "eng+ind"],
|
| 149 |
+
{ timeout: 60000 },
|
| 150 |
+
(error: Error | null) => {
|
| 151 |
+
if (error) reject(error);
|
| 152 |
+
else resolve();
|
| 153 |
+
}
|
| 154 |
+
);
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
const pageText = await readFile(ocrPath, "utf-8");
|
| 158 |
+
if (pageText.trim()) {
|
| 159 |
+
results.push(`--- Halaman ${results.length + 1} ---\n${pageText.trim()}`);
|
| 160 |
+
}
|
| 161 |
+
} catch {
|
| 162 |
+
// Skip pages that fail OCR
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
return results.length > 0
|
| 167 |
+
? results.join("\n\n")
|
| 168 |
+
: "(PDF berisi gambar tapi tidak dapat di-OCR)";
|
| 169 |
+
} finally {
|
| 170 |
+
// Clean up temp directory
|
| 171 |
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
async function processDoc(buffer: Buffer): Promise<string> {
|
| 176 |
+
const { writeFile, unlink } = require("fs/promises");
|
| 177 |
+
const { tmpdir } = require("os");
|
| 178 |
+
const path = require("path");
|
| 179 |
+
const tmpPath = path.join(tmpdir(), `doc-${uuidv4()}.doc`);
|
| 180 |
+
try {
|
| 181 |
+
await writeFile(tmpPath, buffer);
|
| 182 |
+
const WordExtractor = require("word-extractor");
|
| 183 |
+
const extractor = new WordExtractor();
|
| 184 |
+
const doc = await extractor.extract(tmpPath);
|
| 185 |
+
return doc.getBody().trim();
|
| 186 |
+
} finally {
|
| 187 |
+
await unlink(tmpPath).catch(() => {});
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
async function processDocx(buffer: Buffer): Promise<string> {
|
| 192 |
+
const mammoth = await import("mammoth");
|
| 193 |
+
const result = await mammoth.extractRawText({ buffer });
|
| 194 |
+
return result.value.trim();
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function processExcel(buffer: Buffer): string {
|
| 198 |
+
const XLSX = require("xlsx");
|
| 199 |
+
const workbook = XLSX.read(buffer, { type: "buffer" });
|
| 200 |
+
const texts: string[] = [];
|
| 201 |
+
|
| 202 |
+
for (const sheetName of workbook.SheetNames) {
|
| 203 |
+
const sheet = workbook.Sheets[sheetName];
|
| 204 |
+
const csv = XLSX.utils.sheet_to_csv(sheet);
|
| 205 |
+
texts.push(`--- Sheet: ${sheetName} ---\n${csv}`);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
return texts.join("\n\n");
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
function processCsv(buffer: Buffer): string {
|
| 212 |
+
const XLSX = require("xlsx");
|
| 213 |
+
const workbook = XLSX.read(buffer, { type: "buffer" });
|
| 214 |
+
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
| 215 |
+
return XLSX.utils.sheet_to_csv(sheet);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
async function processImage(buffer: Buffer): Promise<string> {
|
| 219 |
+
const { writeFile, readFile, unlink } = require("fs/promises");
|
| 220 |
+
const { tmpdir } = require("os");
|
| 221 |
+
const path = require("path");
|
| 222 |
+
const { execFile } = require("child_process");
|
| 223 |
+
|
| 224 |
+
const inputPath = path.join(tmpdir(), `ocr-${uuidv4()}`);
|
| 225 |
+
const outputBase = path.join(tmpdir(), `ocr-out-${uuidv4()}`);
|
| 226 |
+
const outputPath = outputBase + ".txt";
|
| 227 |
+
|
| 228 |
+
try {
|
| 229 |
+
await writeFile(inputPath, buffer);
|
| 230 |
+
|
| 231 |
+
// Use system tesseract CLI — avoids Turbopack module resolution issues
|
| 232 |
+
await new Promise<void>((resolve, reject) => {
|
| 233 |
+
execFile(
|
| 234 |
+
"tesseract",
|
| 235 |
+
[inputPath, outputBase, "-l", "eng+ind"],
|
| 236 |
+
{ timeout: 60000 },
|
| 237 |
+
(error: Error | null) => {
|
| 238 |
+
if (error) reject(error);
|
| 239 |
+
else resolve();
|
| 240 |
+
}
|
| 241 |
+
);
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
const text = await readFile(outputPath, "utf-8");
|
| 245 |
+
return text.trim();
|
| 246 |
+
} finally {
|
| 247 |
+
await unlink(inputPath).catch(() => {});
|
| 248 |
+
await unlink(outputPath).catch(() => {});
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function processText(buffer: Buffer): string {
|
| 253 |
+
try {
|
| 254 |
+
return buffer.toString("utf-8");
|
| 255 |
+
} catch {
|
| 256 |
+
return buffer.toString("latin1");
|
| 257 |
+
}
|
| 258 |
+
}
|
src/lib/types.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface FileContext {
|
| 2 |
+
id: string;
|
| 3 |
+
filename: string;
|
| 4 |
+
extension: string;
|
| 5 |
+
text: string;
|
| 6 |
+
error: string | null;
|
| 7 |
+
size: number;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export interface ChatSession {
|
| 11 |
+
id: string;
|
| 12 |
+
title: string;
|
| 13 |
+
createdAt: string;
|
| 14 |
+
updatedAt: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface AppSettings {
|
| 18 |
+
provider: "anthropic" | "openai" | "deepseek";
|
| 19 |
+
model: string;
|
| 20 |
+
apiKeys: {
|
| 21 |
+
openai: string;
|
| 22 |
+
anthropic: string;
|
| 23 |
+
deepseek: string;
|
| 24 |
+
};
|
| 25 |
+
}
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from "clsx"
|
| 2 |
+
import { twMerge } from "tailwind-merge"
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs))
|
| 6 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|