romizone commited on
Commit
c730f0b
·
verified ·
1 Parent(s): 92d5336

Upload folder using huggingface_hub

Browse files
Files changed (48) hide show
  1. .dockerignore +9 -0
  2. .gitignore +41 -0
  3. Dockerfile +24 -0
  4. LICENSE +21 -0
  5. README.md +418 -5
  6. components.json +23 -0
  7. eslint.config.mjs +18 -0
  8. next.config.ts +7 -0
  9. package-lock.json +0 -0
  10. package.json +51 -0
  11. postcss.config.mjs +7 -0
  12. public/file.svg +1 -0
  13. public/globe.svg +1 -0
  14. public/icon.svg +25 -0
  15. public/next.svg +1 -0
  16. public/vercel.svg +1 -0
  17. public/window.svg +1 -0
  18. src/app/api/chat/route.ts +172 -0
  19. src/app/api/upload/route.ts +48 -0
  20. src/app/favicon.ico +0 -0
  21. src/app/globals.css +116 -0
  22. src/app/layout.tsx +27 -0
  23. src/app/page.tsx +5 -0
  24. src/components/chat/chat-area.tsx +59 -0
  25. src/components/chat/chat-input.tsx +149 -0
  26. src/components/chat/chat-message.tsx +43 -0
  27. src/components/chat/chat-page.tsx +362 -0
  28. src/components/chat/file-badge.tsx +46 -0
  29. src/components/chat/file-preview.tsx +34 -0
  30. src/components/chat/markdown-renderer.tsx +132 -0
  31. src/components/chat/settings-dialog.tsx +159 -0
  32. src/components/chat/sidebar.tsx +160 -0
  33. src/components/chat/welcome-screen.tsx +19 -0
  34. src/components/ui/avatar.tsx +109 -0
  35. src/components/ui/badge.tsx +48 -0
  36. src/components/ui/button.tsx +64 -0
  37. src/components/ui/dialog.tsx +158 -0
  38. src/components/ui/scroll-area.tsx +58 -0
  39. src/components/ui/separator.tsx +28 -0
  40. src/components/ui/sheet.tsx +143 -0
  41. src/components/ui/textarea.tsx +18 -0
  42. src/components/ui/tooltip.tsx +57 -0
  43. src/hooks/use-chat-store.ts +191 -0
  44. src/lib/constants.ts +71 -0
  45. src/lib/file-processor.ts +258 -0
  46. src/lib/types.ts +25 -0
  47. src/lib/utils.ts +6 -0
  48. 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: red
5
- colorTo: gray
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ [![Next.js](https://img.shields.io/badge/Next.js-16-black?style=for-the-badge&logo=next.js)](https://nextjs.org/)
21
+ [![React](https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react)](https://react.dev/)
22
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
23
+ [![AI SDK](https://img.shields.io/badge/AI_SDK-6-FF6B35?style=for-the-badge)](https://sdk.vercel.ai/)
24
+ [![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](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
+ }