Commit ·
ca51841
0
Parent(s):
init this repo
Browse files- .gitattributes +1 -0
- .gitignore +38 -0
- README.md +159 -0
- assets/settings.png +3 -0
- index.html +14 -0
- package-lock.json +0 -0
- package.json +26 -0
- postcss.config.js +6 -0
- public/favicon.svg +79 -0
- public/logo.svg +143 -0
- src/api.js +216 -0
- src/capabilities.js +35 -0
- src/components/app.js +502 -0
- src/components/chat.js +334 -0
- src/components/input-bar.js +411 -0
- src/components/model-picker.js +176 -0
- src/components/settings-modal.js +243 -0
- src/components/sidebar.js +134 -0
- src/context.js +56 -0
- src/icons.js +43 -0
- src/main.js +5 -0
- src/markdown.js +124 -0
- src/store.js +231 -0
- src/style.css +310 -0
- tailwind.config.js +24 -0
- tests/api.test.js +193 -0
- tests/app.test.js +215 -0
- tests/capabilities.test.js +97 -0
- tests/context.test.js +64 -0
- tests/input-bar.test.js +277 -0
- tests/model-picker.test.js +54 -0
- tests/settings-modal.test.js +77 -0
- tests/setup.js +12 -0
- tests/store.test.js +150 -0
- vite.config.js +86 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
|
| 4 |
+
# Build output
|
| 5 |
+
dist/
|
| 6 |
+
|
| 7 |
+
# Environment & secrets
|
| 8 |
+
.env
|
| 9 |
+
.env.*
|
| 10 |
+
!.env.example
|
| 11 |
+
|
| 12 |
+
# Vite local config
|
| 13 |
+
*.local
|
| 14 |
+
vite.config.*.local
|
| 15 |
+
|
| 16 |
+
# OS
|
| 17 |
+
.DS_Store
|
| 18 |
+
Thumbs.db
|
| 19 |
+
|
| 20 |
+
# Editor
|
| 21 |
+
.vscode/
|
| 22 |
+
.idea/
|
| 23 |
+
*.swp
|
| 24 |
+
*.swo
|
| 25 |
+
|
| 26 |
+
# Logs
|
| 27 |
+
*.log
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
|
| 30 |
+
# Coverage
|
| 31 |
+
coverage/
|
| 32 |
+
|
| 33 |
+
# Local markdown docs
|
| 34 |
+
/*.md
|
| 35 |
+
!/README.md
|
| 36 |
+
|
| 37 |
+
# Local scratch data
|
| 38 |
+
tmp/
|
README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AXERA Lite WebUI
|
| 2 |
+
|
| 3 |
+
A lightweight, local-first chat UI for OpenAI-compatible APIs.
|
| 4 |
+
|
| 5 |
+
It runs entirely in the browser, supports streaming responses, and stores conversations locally.
|
| 6 |
+
|
| 7 |
+
## Features
|
| 8 |
+
|
| 9 |
+
- OpenAI-compatible chat interface
|
| 10 |
+
- Streaming responses
|
| 11 |
+
- Markdown rendering with code highlighting
|
| 12 |
+
- Local conversation history in the browser
|
| 13 |
+
- Image upload and paste for vision-capable models
|
| 14 |
+
- Audio transcription workflow for compatible APIs
|
| 15 |
+
- Configurable context window and auto-reset threshold
|
| 16 |
+
- Light and dark themes
|
| 17 |
+
|
| 18 |
+
## Requirements
|
| 19 |
+
|
| 20 |
+
- Node.js 18+
|
| 21 |
+
- An OpenAI-compatible API that supports:
|
| 22 |
+
- `GET /v1/models`
|
| 23 |
+
- `POST /v1/chat/completions`
|
| 24 |
+
- Optional audio support:
|
| 25 |
+
- `POST /v1/audio/transcriptions`
|
| 26 |
+
|
| 27 |
+
## Quick Start
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
npm install
|
| 31 |
+
npm run dev
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Open:
|
| 35 |
+
|
| 36 |
+
```text
|
| 37 |
+
http://localhost:5173
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## First-Time Setup
|
| 41 |
+
|
| 42 |
+
1. Open **Settings**.
|
| 43 |
+
2. Enter your **API Base URL**.
|
| 44 |
+
3. Enter your **API Key** if your provider requires one.
|
| 45 |
+
4. Set **Max Context Tokens** to the real limit of your model.
|
| 46 |
+
5. Adjust **Auto-reset Threshold (%)** if needed.
|
| 47 |
+
6. Click **Save Settings**.
|
| 48 |
+
7. Click **Fetch Models**.
|
| 49 |
+
8. Select a model from the top bar.
|
| 50 |
+
|
| 51 |
+

|
| 52 |
+
|
| 53 |
+
### API Base URL
|
| 54 |
+
|
| 55 |
+
Use the server root URL. Do **not** append `/v1`.
|
| 56 |
+
|
| 57 |
+
Correct:
|
| 58 |
+
|
| 59 |
+
```text
|
| 60 |
+
http://127.0.0.1:8000
|
| 61 |
+
http://127.0.0.1:11434
|
| 62 |
+
https://your-api.example.com
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
Incorrect:
|
| 66 |
+
|
| 67 |
+
```text
|
| 68 |
+
http://127.0.0.1:8000/v1
|
| 69 |
+
http://127.0.0.1:11434/v1
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
## Usage
|
| 73 |
+
|
| 74 |
+
- **Send:** `Enter`
|
| 75 |
+
- **New line:** `Shift+Enter`
|
| 76 |
+
- **Reset API context only:** `/reset`
|
| 77 |
+
- **Clear current conversation and API context:** `/clean`
|
| 78 |
+
|
| 79 |
+
### Images
|
| 80 |
+
|
| 81 |
+
- Upload an image with the image button, or paste an image into the input box.
|
| 82 |
+
- The current model must have **Vision** enabled.
|
| 83 |
+
|
| 84 |
+
### Audio
|
| 85 |
+
|
| 86 |
+
- Attach an audio file from the input bar.
|
| 87 |
+
- The current model must have **Audio** enabled.
|
| 88 |
+
- Your API must support `POST /v1/audio/transcriptions`.
|
| 89 |
+
|
| 90 |
+
### Context Badge
|
| 91 |
+
|
| 92 |
+
The `ctx x/y` badge in the input bar shows:
|
| 93 |
+
|
| 94 |
+
- current estimated context usage
|
| 95 |
+
- configured context window limit
|
| 96 |
+
|
| 97 |
+
When usage approaches the configured threshold, the app automatically resets API context before the next send.
|
| 98 |
+
|
| 99 |
+
## Development and Deployment
|
| 100 |
+
|
| 101 |
+
### Development
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
npm run dev
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
Development mode includes a built-in proxy, which is useful when your API does not allow browser CORS requests during local development.
|
| 108 |
+
|
| 109 |
+
### Production Preview
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
npm run build
|
| 113 |
+
npm run preview
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
The production build is static. Your API must allow browser access directly, be served behind a reverse proxy, or share the same origin as the frontend.
|
| 117 |
+
|
| 118 |
+
## Commands
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
npm run dev
|
| 122 |
+
npm run build
|
| 123 |
+
npm run preview
|
| 124 |
+
npm test
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
## Data Storage
|
| 128 |
+
|
| 129 |
+
Settings, model capability overrides, theme preference, and conversations are stored in your browser with `localStorage`.
|
| 130 |
+
|
| 131 |
+
Clearing site storage resets the app.
|
| 132 |
+
|
| 133 |
+
## Troubleshooting
|
| 134 |
+
|
| 135 |
+
### Fetch Models fails
|
| 136 |
+
|
| 137 |
+
- Make sure you clicked **Save Settings** first.
|
| 138 |
+
- Make sure **API Base URL** does not include `/v1`.
|
| 139 |
+
- Confirm your API supports `GET /v1/models`.
|
| 140 |
+
- If it works in `npm run dev` but fails in preview or production, check CORS.
|
| 141 |
+
|
| 142 |
+
### Model list is empty
|
| 143 |
+
|
| 144 |
+
- Click **Fetch Models**.
|
| 145 |
+
- Verify the request succeeded.
|
| 146 |
+
- Confirm your API returns models from `GET /v1/models`.
|
| 147 |
+
|
| 148 |
+
### Image button is disabled
|
| 149 |
+
|
| 150 |
+
- Enable **Vision** for the selected model in **Settings**.
|
| 151 |
+
|
| 152 |
+
### Audio button is disabled
|
| 153 |
+
|
| 154 |
+
- Enable **Audio** for the selected model in **Settings**.
|
| 155 |
+
- Confirm your backend supports audio transcription.
|
| 156 |
+
|
| 157 |
+
### Requests fail even though the server is reachable
|
| 158 |
+
|
| 159 |
+
Make sure your backend is compatible with the OpenAI Chat Completions format, especially `model`, `messages`, and streaming responses.
|
assets/settings.png
ADDED
|
Git LFS Details
|
index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>AXERA Lite WebUI</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 8 |
+
<script>(function(){var t=localStorage.getItem('theme')||'dark';if(t==='dark')document.documentElement.classList.add('dark');})()</script>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div id="app"></div>
|
| 12 |
+
<script type="module" src="/src/main.js"></script>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "lite-webui",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"test": "vitest run",
|
| 11 |
+
"test:watch": "vitest"
|
| 12 |
+
},
|
| 13 |
+
"devDependencies": {
|
| 14 |
+
"@vitest/coverage-v8": "^4.1.5",
|
| 15 |
+
"autoprefixer": "^10.4.0",
|
| 16 |
+
"jsdom": "^29.1.1",
|
| 17 |
+
"postcss": "^8.4.0",
|
| 18 |
+
"tailwindcss": "^3.4.0",
|
| 19 |
+
"vite": "^5.0.0",
|
| 20 |
+
"vitest": "^4.1.5"
|
| 21 |
+
},
|
| 22 |
+
"dependencies": {
|
| 23 |
+
"highlight.js": "^11.9.0",
|
| 24 |
+
"marked": "^12.0.0"
|
| 25 |
+
}
|
| 26 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
public/favicon.svg
ADDED
|
|
public/logo.svg
ADDED
|
|
src/api.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const DEV_PROXY_BASE = '/lw-proxy';
|
| 2 |
+
const NON_ASCII_TOKEN_ESTIMATE = 1;
|
| 3 |
+
const ASCII_CHARS_PER_TOKEN = 4;
|
| 4 |
+
const IMAGE_MESSAGE_TOKEN_ESTIMATE = 256;
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Builds the fetch URL and headers for a given API call.
|
| 8 |
+
* In dev mode: routes through the Vite CORS proxy (/lw-proxy) using X-LW-Target.
|
| 9 |
+
* In prod mode: calls the baseUrl directly (server must have CORS configured).
|
| 10 |
+
*
|
| 11 |
+
* Exported for unit testing with an explicit isDev parameter.
|
| 12 |
+
*/
|
| 13 |
+
export function buildRequestConfig(baseUrl, apiKey, isDev = import.meta.env.DEV, options = {}) {
|
| 14 |
+
const cleanBase = String(baseUrl || '').trim().replace(/\/+$/, '');
|
| 15 |
+
const { contentType = 'application/json' } = options;
|
| 16 |
+
const headers = {};
|
| 17 |
+
if (contentType) headers['Content-Type'] = contentType;
|
| 18 |
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
| 19 |
+
|
| 20 |
+
if (isDev) {
|
| 21 |
+
headers['X-LW-Target'] = cleanBase;
|
| 22 |
+
return { urlBase: DEV_PROXY_BASE, headers };
|
| 23 |
+
}
|
| 24 |
+
return { urlBase: cleanBase, headers };
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export async function fetchModels(baseUrl, apiKey) {
|
| 28 |
+
const { urlBase, headers } = buildRequestConfig(baseUrl, apiKey);
|
| 29 |
+
const url = `${urlBase}/v1/models`;
|
| 30 |
+
const res = await fetch(url, { headers });
|
| 31 |
+
if (!res.ok) {
|
| 32 |
+
const text = await res.text().catch(() => '');
|
| 33 |
+
throw new Error(`Failed to fetch models (${res.status}): ${text.slice(0, 200)}`);
|
| 34 |
+
}
|
| 35 |
+
const data = await res.json();
|
| 36 |
+
// OpenAI returns { data: [{ id, ... }] }
|
| 37 |
+
const list = data.data || data.models || data;
|
| 38 |
+
return Array.isArray(list) ? list.map(m => (typeof m === 'string' ? m : m.id)).filter(Boolean).sort() : [];
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function extractAudioText(payload) {
|
| 42 |
+
if (typeof payload === 'string') return payload.trim();
|
| 43 |
+
if (typeof payload?.text === 'string') return payload.text.trim();
|
| 44 |
+
if (typeof payload?.transcript === 'string') return payload.transcript.trim();
|
| 45 |
+
if (typeof payload?.output_text === 'string') return payload.output_text.trim();
|
| 46 |
+
if (Array.isArray(payload?.segments)) {
|
| 47 |
+
return payload.segments
|
| 48 |
+
.map((segment) => String(segment?.text || '').trim())
|
| 49 |
+
.filter(Boolean)
|
| 50 |
+
.join(' ')
|
| 51 |
+
.trim();
|
| 52 |
+
}
|
| 53 |
+
return '';
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async function submitAudioTextRequest(baseUrl, apiKey, model, file, endpoint, options = {}) {
|
| 57 |
+
const { prompt = '', language = '' } = options;
|
| 58 |
+
const { urlBase, headers } = buildRequestConfig(baseUrl, apiKey, import.meta.env.DEV, { contentType: null });
|
| 59 |
+
const url = `${urlBase}${endpoint}`;
|
| 60 |
+
const body = new FormData();
|
| 61 |
+
|
| 62 |
+
body.append('file', file, file?.name || 'audio.wav');
|
| 63 |
+
if (model) body.append('model', model);
|
| 64 |
+
if (prompt) body.append('prompt', prompt);
|
| 65 |
+
if (language) body.append('language', language);
|
| 66 |
+
body.append('response_format', 'json');
|
| 67 |
+
|
| 68 |
+
const res = await fetch(url, {
|
| 69 |
+
method: 'POST',
|
| 70 |
+
headers,
|
| 71 |
+
body,
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
if (!res.ok) {
|
| 75 |
+
const text = await res.text().catch(() => '');
|
| 76 |
+
let errMsg = `API error (${res.status})`;
|
| 77 |
+
try {
|
| 78 |
+
const json = JSON.parse(text);
|
| 79 |
+
errMsg = json.error?.message || errMsg;
|
| 80 |
+
} catch {
|
| 81 |
+
if (text) errMsg += ': ' + text.slice(0, 200);
|
| 82 |
+
}
|
| 83 |
+
throw new Error(errMsg);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const payload = await res.json().catch(async () => {
|
| 87 |
+
const text = await res.text().catch(() => '');
|
| 88 |
+
return { text };
|
| 89 |
+
});
|
| 90 |
+
const text = extractAudioText(payload);
|
| 91 |
+
if (!text) throw new Error('Audio API returned an empty text result');
|
| 92 |
+
return text;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export async function transcribeAudio(baseUrl, apiKey, model, file, options = {}) {
|
| 96 |
+
return submitAudioTextRequest(baseUrl, apiKey, model, file, '/v1/audio/transcriptions', options);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export function estimateTextTokens(text) {
|
| 100 |
+
const value = String(text || '');
|
| 101 |
+
let asciiChars = 0;
|
| 102 |
+
let nonAsciiTokens = 0;
|
| 103 |
+
|
| 104 |
+
for (const char of value) {
|
| 105 |
+
if (/\s/.test(char)) continue;
|
| 106 |
+
if (char.charCodeAt(0) <= 0x7f) {
|
| 107 |
+
asciiChars += 1;
|
| 108 |
+
} else {
|
| 109 |
+
nonAsciiTokens += NON_ASCII_TOKEN_ESTIMATE;
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return nonAsciiTokens + Math.ceil(asciiChars / ASCII_CHARS_PER_TOKEN);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function estimateContentTokens(content) {
|
| 117 |
+
if (typeof content === 'string') return estimateTextTokens(content);
|
| 118 |
+
if (!Array.isArray(content)) return 0;
|
| 119 |
+
|
| 120 |
+
return content.reduce((total, part) => {
|
| 121 |
+
if (part?.type === 'text') return total + estimateTextTokens(part.text);
|
| 122 |
+
if (part?.type === 'image_url') return total + IMAGE_MESSAGE_TOKEN_ESTIMATE;
|
| 123 |
+
if (part?.type === 'video_url') return total + IMAGE_MESSAGE_TOKEN_ESTIMATE * 8; // rough: ~8 keyframes
|
| 124 |
+
return total;
|
| 125 |
+
}, 0);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export function estimateMessageTokens(messages) {
|
| 129 |
+
if (!Array.isArray(messages) || messages.length === 0) return 0;
|
| 130 |
+
|
| 131 |
+
return messages.reduce((total, message) => {
|
| 132 |
+
return total + 4 + estimateContentTokens(message.content);
|
| 133 |
+
}, 2);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export function formatCompactTokenCount(tokens) {
|
| 137 |
+
const safe = Math.max(0, Math.round(Number(tokens) || 0));
|
| 138 |
+
|
| 139 |
+
if (safe < 1000) return String(safe);
|
| 140 |
+
if (safe < 100000) return `${(safe / 1000).toFixed(1).replace(/\.0$/, '')}k`;
|
| 141 |
+
return `${Math.round(safe / 1000)}k`;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
export async function* streamCompletion(baseUrl, apiKey, model, messages, options = {}) {
|
| 145 |
+
const { onUsage } = options;
|
| 146 |
+
const { urlBase, headers } = buildRequestConfig(baseUrl, apiKey);
|
| 147 |
+
const url = `${urlBase}/v1/chat/completions`;
|
| 148 |
+
const res = await fetch(url, {
|
| 149 |
+
method: 'POST',
|
| 150 |
+
headers,
|
| 151 |
+
body: JSON.stringify({ model, messages, stream: true }),
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
if (!res.ok) {
|
| 155 |
+
const text = await res.text().catch(() => '');
|
| 156 |
+
let errMsg = `API error (${res.status})`;
|
| 157 |
+
try {
|
| 158 |
+
const json = JSON.parse(text);
|
| 159 |
+
errMsg = json.error?.message || errMsg;
|
| 160 |
+
} catch {
|
| 161 |
+
if (text) errMsg += ': ' + text.slice(0, 200);
|
| 162 |
+
}
|
| 163 |
+
throw new Error(errMsg);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const reader = res.body.getReader();
|
| 167 |
+
const decoder = new TextDecoder();
|
| 168 |
+
let buffer = '';
|
| 169 |
+
|
| 170 |
+
while (true) {
|
| 171 |
+
const { done, value } = await reader.read();
|
| 172 |
+
if (done) break;
|
| 173 |
+
|
| 174 |
+
buffer += decoder.decode(value, { stream: true });
|
| 175 |
+
const lines = buffer.split('\n');
|
| 176 |
+
buffer = lines.pop(); // keep incomplete line
|
| 177 |
+
|
| 178 |
+
for (const line of lines) {
|
| 179 |
+
const trimmed = line.trim();
|
| 180 |
+
if (!trimmed) continue;
|
| 181 |
+
if (!trimmed.startsWith('data: ')) continue;
|
| 182 |
+
|
| 183 |
+
const data = trimmed.slice(6);
|
| 184 |
+
if (data === '[DONE]') return;
|
| 185 |
+
|
| 186 |
+
try {
|
| 187 |
+
const json = JSON.parse(data);
|
| 188 |
+
if (json.usage?.total_tokens != null) onUsage?.(json.usage);
|
| 189 |
+
const delta = json.choices?.[0]?.delta;
|
| 190 |
+
if (delta?.content) yield delta.content;
|
| 191 |
+
} catch {
|
| 192 |
+
// ignore malformed SSE lines
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// flush any remaining buffer
|
| 198 |
+
if (buffer.trim().startsWith('data: ')) {
|
| 199 |
+
const data = buffer.trim().slice(6);
|
| 200 |
+
if (data && data !== '[DONE]') {
|
| 201 |
+
try {
|
| 202 |
+
const json = JSON.parse(data);
|
| 203 |
+
if (json.usage?.total_tokens != null) onUsage?.(json.usage);
|
| 204 |
+
const delta = json.choices?.[0]?.delta;
|
| 205 |
+
if (delta?.content) yield delta.content;
|
| 206 |
+
} catch { /* ignore */ }
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
export function formatMessagesForApi(messages) {
|
| 212 |
+
return messages.map(msg => {
|
| 213 |
+
if (msg.role === 'assistant') return { role: 'assistant', content: msg.content };
|
| 214 |
+
return { role: 'user', content: msg.content };
|
| 215 |
+
});
|
| 216 |
+
}
|
src/capabilities.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const DEFAULT_CAPABILITIES = {
|
| 2 |
+
'gpt-4o': { text: true, image: true, audio: false },
|
| 3 |
+
'gpt-4o-mini': { text: true, image: true, audio: false },
|
| 4 |
+
'gpt-4-turbo': { text: true, image: true, audio: false },
|
| 5 |
+
'gpt-4-vision-preview': { text: true, image: true, audio: false },
|
| 6 |
+
'gpt-4': { text: true, image: false, audio: false },
|
| 7 |
+
'gpt-3.5-turbo': { text: true, image: false, audio: false },
|
| 8 |
+
'deepseek-v3': { text: true, image: false, audio: false },
|
| 9 |
+
'deepseek-chat': { text: true, image: false, audio: false },
|
| 10 |
+
'qwen2.5-7b-instruct': { text: true, image: false, audio: false },
|
| 11 |
+
'claude-3-5-sonnet': { text: true, image: true, audio: false },
|
| 12 |
+
'claude-3-opus': { text: true, image: true, audio: false },
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
const UNKNOWN_DEFAULT = { text: true, image: false, audio: false };
|
| 16 |
+
|
| 17 |
+
export function getCapabilities(modelId, userOverrides = {}) {
|
| 18 |
+
const base = DEFAULT_CAPABILITIES[modelId] || UNKNOWN_DEFAULT;
|
| 19 |
+
const override = userOverrides[modelId];
|
| 20 |
+
if (!override) return { ...base };
|
| 21 |
+
return { ...base, ...override };
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function supportsImage(modelId, userOverrides = {}) {
|
| 25 |
+
return getCapabilities(modelId, userOverrides).image;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Video is treated as part of the Vision capability — same toggle, same gate.
|
| 29 |
+
export function supportsVideo(modelId, userOverrides = {}) {
|
| 30 |
+
return supportsImage(modelId, userOverrides);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function supportsAudio(modelId, userOverrides = {}) {
|
| 34 |
+
return getCapabilities(modelId, userOverrides).audio;
|
| 35 |
+
}
|
src/components/app.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store } from '../store.js';
|
| 2 |
+
import {
|
| 3 |
+
streamCompletion,
|
| 4 |
+
formatMessagesForApi,
|
| 5 |
+
transcribeAudio,
|
| 6 |
+
} from '../api.js';
|
| 7 |
+
import { estimateThreadTokens, resolveContextConfig } from '../context.js';
|
| 8 |
+
import { Sidebar } from './sidebar.js';
|
| 9 |
+
import { Chat } from './chat.js';
|
| 10 |
+
import { InputBar } from './input-bar.js';
|
| 11 |
+
import { ModelPicker } from './model-picker.js';
|
| 12 |
+
import { SettingsModal } from './settings-modal.js';
|
| 13 |
+
import { icon } from '../icons.js';
|
| 14 |
+
|
| 15 |
+
export class App {
|
| 16 |
+
constructor(rootEl) {
|
| 17 |
+
this.root = rootEl;
|
| 18 |
+
this.sidebar = new Sidebar();
|
| 19 |
+
this.chat = new Chat();
|
| 20 |
+
this.inputBar = new InputBar();
|
| 21 |
+
this.modelPicker = new ModelPicker();
|
| 22 |
+
this.settingsModal = new SettingsModal();
|
| 23 |
+
this._sidebarOpen = false;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
init() {
|
| 27 |
+
this._render();
|
| 28 |
+
this._initTheme();
|
| 29 |
+
this._syncSidebarLayout();
|
| 30 |
+
this._bindEvents();
|
| 31 |
+
this._loadCurrentConversation();
|
| 32 |
+
this.settingsModal.render(); // pre-render (portal pattern)
|
| 33 |
+
this._updateContextInfo();
|
| 34 |
+
this.inputBar.focus();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
_render() {
|
| 38 |
+
this.root.className = 'flex h-screen overflow-hidden bg-[var(--c-bg)]';
|
| 39 |
+
|
| 40 |
+
// Sidebar
|
| 41 |
+
const sidebarEl = this.sidebar.render();
|
| 42 |
+
this.root.appendChild(sidebarEl);
|
| 43 |
+
|
| 44 |
+
// Mobile overlay
|
| 45 |
+
this._mobileOverlay = document.createElement('div');
|
| 46 |
+
this._mobileOverlay.className = 'fixed inset-0 bg-black/50 z-20 hidden md:hidden';
|
| 47 |
+
this._mobileOverlay.addEventListener('click', () => this._closeMobileSidebar());
|
| 48 |
+
document.body.appendChild(this._mobileOverlay);
|
| 49 |
+
|
| 50 |
+
// Main content
|
| 51 |
+
const main = document.createElement('div');
|
| 52 |
+
main.className = 'flex flex-col flex-1 min-w-0 h-full';
|
| 53 |
+
main.id = 'main-content';
|
| 54 |
+
|
| 55 |
+
// Header
|
| 56 |
+
const header = document.createElement('header');
|
| 57 |
+
header.className = 'flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-top-bd)] bg-[var(--c-top)] flex-shrink-0 gap-3';
|
| 58 |
+
header.innerHTML = `
|
| 59 |
+
<div class="flex items-center gap-3">
|
| 60 |
+
<button id="mobile-menu-btn" class="md:hidden p-1.5 rounded text-[var(--c-tx3)] hover:text-[var(--c-tx)] transition-colors" aria-label="Toggle sidebar">
|
| 61 |
+
${icon('menu')}
|
| 62 |
+
</button>
|
| 63 |
+
<div id="model-picker-mount"></div>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="flex items-center gap-2">
|
| 66 |
+
<button id="theme-toggle-btn" class="p-1.5 rounded-lg text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-top-el)] border border-transparent hover:border-[var(--c-top-bd)] transition-all" aria-label="Toggle theme">
|
| 67 |
+
${icon('sun')}
|
| 68 |
+
</button>
|
| 69 |
+
<button id="settings-btn" class="p-1.5 rounded-lg text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-top-el)] border border-transparent hover:border-[var(--c-top-bd)] transition-all" aria-label="Settings">
|
| 70 |
+
${icon('settings')}
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
`;
|
| 74 |
+
|
| 75 |
+
// Mount model picker
|
| 76 |
+
header.querySelector('#model-picker-mount').appendChild(this.modelPicker.render());
|
| 77 |
+
main.appendChild(header);
|
| 78 |
+
|
| 79 |
+
// Chat area
|
| 80 |
+
const chatWrapper = document.createElement('div');
|
| 81 |
+
chatWrapper.className = 'flex-1 min-h-0 overflow-hidden';
|
| 82 |
+
chatWrapper.appendChild(this.chat.render());
|
| 83 |
+
main.appendChild(chatWrapper);
|
| 84 |
+
|
| 85 |
+
// Input bar
|
| 86 |
+
main.appendChild(this.inputBar.render());
|
| 87 |
+
|
| 88 |
+
this.root.appendChild(main);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
_bindEvents() {
|
| 92 |
+
// Settings button
|
| 93 |
+
this.root.querySelector('#settings-btn').addEventListener('click', () => {
|
| 94 |
+
this.settingsModal.toggle();
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
// Theme toggle
|
| 98 |
+
this.root.querySelector('#theme-toggle-btn')?.addEventListener('click', () => {
|
| 99 |
+
this._toggleTheme();
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
// Mobile menu
|
| 103 |
+
this.root.querySelector('#mobile-menu-btn')?.addEventListener('click', () => {
|
| 104 |
+
this._toggleMobileSidebar();
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
window.addEventListener('resize', () => {
|
| 108 |
+
this._syncSidebarLayout();
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// New chat
|
| 112 |
+
document.addEventListener('sidebar:newchat', () => this._newChat());
|
| 113 |
+
|
| 114 |
+
// Select conversation
|
| 115 |
+
document.addEventListener('sidebar:select', (e) => {
|
| 116 |
+
this._selectConversation(e.detail.convId);
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
// Delete conversation
|
| 120 |
+
document.addEventListener('sidebar:deleted', () => {
|
| 121 |
+
const currentId = store.getCurrentConversationId();
|
| 122 |
+
if (currentId) {
|
| 123 |
+
this._selectConversation(currentId);
|
| 124 |
+
} else {
|
| 125 |
+
this.chat.clear();
|
| 126 |
+
this.sidebar.update();
|
| 127 |
+
}
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// Send message
|
| 131 |
+
document.addEventListener('inputbar:send', (e) => {
|
| 132 |
+
this._handleSend(e.detail.text, e.detail.image, e.detail.video, e.detail.audio);
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// Model change
|
| 136 |
+
document.addEventListener('model:changed', () => {
|
| 137 |
+
this.inputBar.setModel(this.modelPicker.getModel());
|
| 138 |
+
this._updateContextInfo();
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
document.addEventListener('settings:changed', () => {
|
| 142 |
+
this.inputBar.setModel(this.modelPicker.getModel());
|
| 143 |
+
this._updateContextInfo();
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
document.addEventListener('models:changed', () => {
|
| 147 |
+
this.modelPicker.setModels(store.getAvailableModels());
|
| 148 |
+
this.inputBar.setModel(this.modelPicker.getModel());
|
| 149 |
+
this._updateContextInfo();
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
_loadCurrentConversation() {
|
| 154 |
+
const currentId = store.getCurrentConversationId();
|
| 155 |
+
if (currentId) {
|
| 156 |
+
const conv = store.getCurrentConversation();
|
| 157 |
+
if (conv) {
|
| 158 |
+
this.chat.loadConversation(conv);
|
| 159 |
+
this.modelPicker.syncToConversation(conv);
|
| 160 |
+
this.inputBar.setModel(this.modelPicker.getModel());
|
| 161 |
+
this._updateContextInfo();
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
// No current or invalid current — pick first if exists
|
| 166 |
+
const convs = store.getConversations();
|
| 167 |
+
if (convs.length > 0) {
|
| 168 |
+
this._selectConversation(convs[0].id);
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
_newChat() {
|
| 173 |
+
const model = this.modelPicker.getModel();
|
| 174 |
+
const conv = store.createConversation(model);
|
| 175 |
+
store.setCurrentConversationId(conv.id);
|
| 176 |
+
this.chat.loadConversation(conv);
|
| 177 |
+
this.sidebar.update();
|
| 178 |
+
this._updateContextInfo();
|
| 179 |
+
this.inputBar.focus();
|
| 180 |
+
this._syncSidebarLayout();
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
_selectConversation(convId) {
|
| 184 |
+
store.setCurrentConversationId(convId);
|
| 185 |
+
const conv = store.getCurrentConversation();
|
| 186 |
+
if (!conv) {
|
| 187 |
+
this.chat.clear();
|
| 188 |
+
this._updateContextInfo();
|
| 189 |
+
return;
|
| 190 |
+
}
|
| 191 |
+
this.chat.loadConversation(conv);
|
| 192 |
+
this.modelPicker.syncToConversation(conv);
|
| 193 |
+
this.inputBar.setModel(this.modelPicker.getModel());
|
| 194 |
+
this.sidebar.update();
|
| 195 |
+
this._updateContextInfo();
|
| 196 |
+
this.inputBar.focus();
|
| 197 |
+
this._closeMobileSidebar();
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
async _handleSend(text, image, video, audio) {
|
| 201 |
+
const settings = store.getSettings();
|
| 202 |
+
const trimmedText = text.trim();
|
| 203 |
+
|
| 204 |
+
// /clean — wipes both the API context and the visual chat display
|
| 205 |
+
if (trimmedText === '/clean' && !image && !video && !audio) {
|
| 206 |
+
const convId = store.getCurrentConversationId();
|
| 207 |
+
if (convId) {
|
| 208 |
+
store.clearMessages(convId);
|
| 209 |
+
this.chat.clear();
|
| 210 |
+
this._updateContextInfo();
|
| 211 |
+
}
|
| 212 |
+
return;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
if (trimmedText === '/reset' && !image && !video && !audio) {
|
| 216 |
+
this._resetConversationContext(store.getCurrentConversationId(), 'manual /reset command');
|
| 217 |
+
return;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// Ensure we have a conversation
|
| 221 |
+
let convId = store.getCurrentConversationId();
|
| 222 |
+
if (!convId) {
|
| 223 |
+
const conv = store.createConversation(this.modelPicker.getModel());
|
| 224 |
+
convId = conv.id;
|
| 225 |
+
store.setCurrentConversationId(convId);
|
| 226 |
+
this.sidebar.update();
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
const model = this.modelPicker.getModel();
|
| 230 |
+
if (!model) {
|
| 231 |
+
this.chat.showError('Error: no model selected for the current API Base URL');
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if (audio) {
|
| 236 |
+
await this._handleAudioTask({
|
| 237 |
+
convId,
|
| 238 |
+
model,
|
| 239 |
+
settings,
|
| 240 |
+
instruction: trimmedText,
|
| 241 |
+
audio,
|
| 242 |
+
});
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const contextConfig = resolveContextConfig(settings, model);
|
| 247 |
+
const userMessage = this._buildUserMessage(text, image, video);
|
| 248 |
+
const preConv = store.getCurrentConversation();
|
| 249 |
+
const projectedTokens = estimateThreadTokens(preConv?.messages, userMessage);
|
| 250 |
+
|
| 251 |
+
if (preConv?.messages?.length && projectedTokens >= contextConfig.resetTokens) {
|
| 252 |
+
this._resetConversationContext(
|
| 253 |
+
convId,
|
| 254 |
+
`estimated ${projectedTokens.toLocaleString()} tokens reached the ${contextConfig.resetPercent}% auto-reset threshold`
|
| 255 |
+
);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// Add to store & render
|
| 259 |
+
store.addMessage(convId, userMessage);
|
| 260 |
+
this.chat.appendUserMessage(userMessage);
|
| 261 |
+
this._updateContextInfo();
|
| 262 |
+
|
| 263 |
+
// Update title if first message
|
| 264 |
+
const conv = store.getCurrentConversation();
|
| 265 |
+
if (conv && conv.messages.length === 1) {
|
| 266 |
+
const title = text.slice(0, 40) || (video ? 'Video message' : 'Image message');
|
| 267 |
+
store.updateConversationTitle(convId, title);
|
| 268 |
+
this.sidebar.update();
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Disable input
|
| 272 |
+
this.inputBar.setSending(true);
|
| 273 |
+
this.chat.showTypingIndicator();
|
| 274 |
+
|
| 275 |
+
// Get conversation history for API
|
| 276 |
+
const currentConv = store.getCurrentConversation();
|
| 277 |
+
const apiMessages = formatMessagesForApi(currentConv.messages);
|
| 278 |
+
|
| 279 |
+
try {
|
| 280 |
+
this.chat.startAssistantMessage();
|
| 281 |
+
let fullText = '';
|
| 282 |
+
|
| 283 |
+
for await (const chunk of streamCompletion(settings.baseUrl, settings.apiKey, model, apiMessages)) {
|
| 284 |
+
fullText += chunk;
|
| 285 |
+
this.chat.appendToAssistantMessage(chunk);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// Finalize
|
| 289 |
+
this.chat.finalizeAssistantMessage(fullText);
|
| 290 |
+
store.addMessage(convId, {
|
| 291 |
+
role: 'assistant',
|
| 292 |
+
content: fullText,
|
| 293 |
+
timestamp: new Date().toISOString(),
|
| 294 |
+
});
|
| 295 |
+
this._updateContextInfo();
|
| 296 |
+
this.sidebar.update();
|
| 297 |
+
} catch (err) {
|
| 298 |
+
this.chat.showError(`Error: ${err.message}`);
|
| 299 |
+
} finally {
|
| 300 |
+
this.inputBar.setSending(false);
|
| 301 |
+
this.inputBar.focus();
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
_buildUserMessage(text, image, video) {
|
| 306 |
+
let content;
|
| 307 |
+
if (image) {
|
| 308 |
+
content = [
|
| 309 |
+
{ type: 'text', text: text || '' },
|
| 310 |
+
{ type: 'image_url', image_url: { url: image.dataUrl } },
|
| 311 |
+
];
|
| 312 |
+
} else if (video) {
|
| 313 |
+
content = [
|
| 314 |
+
{ type: 'text', text: text || '' },
|
| 315 |
+
{ type: 'video_url', video_url: { url: video.dataUrl } },
|
| 316 |
+
];
|
| 317 |
+
} else {
|
| 318 |
+
content = text;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
return {
|
| 322 |
+
role: 'user',
|
| 323 |
+
content,
|
| 324 |
+
timestamp: new Date().toISOString(),
|
| 325 |
+
};
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
_buildAudioUserMessage(audio, instruction = '') {
|
| 329 |
+
const summary = `Audio upload: ${audio.file?.name || 'audio'}`;
|
| 330 |
+
const suffix = instruction ? `\nInstruction: ${instruction}` : '';
|
| 331 |
+
return {
|
| 332 |
+
role: 'user',
|
| 333 |
+
content: `${summary}${suffix}`,
|
| 334 |
+
timestamp: new Date().toISOString(),
|
| 335 |
+
meta: {
|
| 336 |
+
type: 'audio',
|
| 337 |
+
fileName: audio.file?.name || 'audio',
|
| 338 |
+
},
|
| 339 |
+
};
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
async _handleAudioTask({ convId, model, settings, instruction, audio }) {
|
| 343 |
+
const userMessage = this._buildAudioUserMessage(audio, instruction);
|
| 344 |
+
store.addMessage(convId, userMessage);
|
| 345 |
+
this.chat.appendUserMessage(userMessage);
|
| 346 |
+
this._updateContextInfo();
|
| 347 |
+
|
| 348 |
+
const conv = store.getCurrentConversation();
|
| 349 |
+
if (conv && conv.messages.length === 1) {
|
| 350 |
+
const title = (instruction || userMessage.content).slice(0, 40) || 'Audio task';
|
| 351 |
+
store.updateConversationTitle(convId, title);
|
| 352 |
+
this.sidebar.update();
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
this.inputBar.setSending(true);
|
| 356 |
+
this.chat.showTypingIndicator();
|
| 357 |
+
|
| 358 |
+
try {
|
| 359 |
+
const transcript = await transcribeAudio(settings.baseUrl, settings.apiKey, model, audio.file);
|
| 360 |
+
|
| 361 |
+
if (!instruction) {
|
| 362 |
+
this.chat.hideTypingIndicator();
|
| 363 |
+
this.chat.startAssistantMessage();
|
| 364 |
+
this.chat.finalizeAssistantMessage(transcript);
|
| 365 |
+
store.addMessage(convId, {
|
| 366 |
+
role: 'assistant',
|
| 367 |
+
content: transcript,
|
| 368 |
+
timestamp: new Date().toISOString(),
|
| 369 |
+
});
|
| 370 |
+
this._updateContextInfo();
|
| 371 |
+
this.sidebar.update();
|
| 372 |
+
return;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
const followUpMessage = {
|
| 376 |
+
role: 'user',
|
| 377 |
+
content: [
|
| 378 |
+
`The following text came from an uploaded audio file.`,
|
| 379 |
+
`Task: ${instruction}`,
|
| 380 |
+
`Audio text:`,
|
| 381 |
+
transcript,
|
| 382 |
+
].join('\n\n'),
|
| 383 |
+
timestamp: new Date().toISOString(),
|
| 384 |
+
};
|
| 385 |
+
|
| 386 |
+
const currentConv = store.getCurrentConversation();
|
| 387 |
+
const apiMessages = [
|
| 388 |
+
...formatMessagesForApi(currentConv.messages),
|
| 389 |
+
{ role: 'user', content: followUpMessage.content },
|
| 390 |
+
];
|
| 391 |
+
|
| 392 |
+
this.chat.hideTypingIndicator();
|
| 393 |
+
this.chat.showSystemMessage('Audio transcribed — applying instruction');
|
| 394 |
+
this.chat.startAssistantMessage();
|
| 395 |
+
|
| 396 |
+
let fullText = '';
|
| 397 |
+
for await (const chunk of streamCompletion(settings.baseUrl, settings.apiKey, model, apiMessages)) {
|
| 398 |
+
fullText += chunk;
|
| 399 |
+
this.chat.appendToAssistantMessage(chunk);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
this.chat.finalizeAssistantMessage(fullText);
|
| 403 |
+
store.addMessage(convId, {
|
| 404 |
+
role: 'assistant',
|
| 405 |
+
content: fullText,
|
| 406 |
+
timestamp: new Date().toISOString(),
|
| 407 |
+
});
|
| 408 |
+
this._updateContextInfo();
|
| 409 |
+
this.sidebar.update();
|
| 410 |
+
} catch (err) {
|
| 411 |
+
this.chat.showError(`Error: ${err.message}`);
|
| 412 |
+
} finally {
|
| 413 |
+
this.inputBar.setSending(false);
|
| 414 |
+
this.inputBar.focus();
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
_initTheme() {
|
| 419 |
+
const saved = localStorage.getItem('theme') || 'dark';
|
| 420 |
+
document.documentElement.classList.toggle('dark', saved === 'dark');
|
| 421 |
+
this._updateThemeBtn();
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
_toggleTheme() {
|
| 425 |
+
const isDark = document.documentElement.classList.toggle('dark');
|
| 426 |
+
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
| 427 |
+
this._updateThemeBtn();
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
_updateThemeBtn() {
|
| 431 |
+
const btn = this.root.querySelector('#theme-toggle-btn');
|
| 432 |
+
if (!btn) return;
|
| 433 |
+
const isDark = document.documentElement.classList.contains('dark');
|
| 434 |
+
btn.innerHTML = isDark ? icon('sun') : icon('moon');
|
| 435 |
+
btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
_resetConversationContext(convId, reason) {
|
| 439 |
+
if (!convId) return;
|
| 440 |
+
const conv = store.getConversations().find((item) => item.id === convId);
|
| 441 |
+
if (!conv) return;
|
| 442 |
+
|
| 443 |
+
const { maxTokens } = resolveContextConfig(store.getSettings(), this.modelPicker.getModel());
|
| 444 |
+
const currentTokens = estimateThreadTokens(conv.messages);
|
| 445 |
+
store.clearMessages(convId);
|
| 446 |
+
this.chat.showSystemMessage(
|
| 447 |
+
`Context /reset — cleared ${currentTokens.toLocaleString()} estimated tokens (${reason}; window ${maxTokens.toLocaleString()})`
|
| 448 |
+
);
|
| 449 |
+
this._updateContextInfo();
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
_updateContextInfo() {
|
| 453 |
+
const conv = store.getCurrentConversation();
|
| 454 |
+
const model = this.modelPicker.getModel();
|
| 455 |
+
const { maxTokens, warnTokens } = resolveContextConfig(store.getSettings(), model);
|
| 456 |
+
const currentTokens = estimateThreadTokens(conv?.messages);
|
| 457 |
+
this.inputBar.setContextInfo(currentTokens, maxTokens, warnTokens);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
_toggleMobileSidebar() {
|
| 461 |
+
if (this._isDesktopLayout()) return;
|
| 462 |
+
this._sidebarOpen = !this._sidebarOpen;
|
| 463 |
+
this._syncSidebarLayout();
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
_closeMobileSidebar() {
|
| 467 |
+
if (this._isDesktopLayout()) {
|
| 468 |
+
this._syncSidebarLayout();
|
| 469 |
+
return;
|
| 470 |
+
}
|
| 471 |
+
this._sidebarOpen = false;
|
| 472 |
+
this._syncSidebarLayout();
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
_isDesktopLayout() {
|
| 476 |
+
return window.innerWidth >= 768;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
_syncSidebarLayout() {
|
| 480 |
+
const sidebar = this.root.querySelector('#sidebar');
|
| 481 |
+
if (!sidebar) return;
|
| 482 |
+
|
| 483 |
+
if (this._isDesktopLayout()) {
|
| 484 |
+
sidebar.classList.remove('-translate-x-full', 'translate-x-0', 'fixed', 'inset-y-0', 'left-0', 'z-30');
|
| 485 |
+
this._mobileOverlay?.classList.add('hidden');
|
| 486 |
+
this._sidebarOpen = true;
|
| 487 |
+
return;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
sidebar.classList.add('fixed', 'inset-y-0', 'left-0', 'z-30');
|
| 491 |
+
|
| 492 |
+
if (this._sidebarOpen) {
|
| 493 |
+
sidebar.classList.remove('-translate-x-full');
|
| 494 |
+
sidebar.classList.add('translate-x-0');
|
| 495 |
+
this._mobileOverlay?.classList.remove('hidden');
|
| 496 |
+
} else {
|
| 497 |
+
sidebar.classList.add('-translate-x-full');
|
| 498 |
+
sidebar.classList.remove('translate-x-0');
|
| 499 |
+
this._mobileOverlay?.classList.add('hidden');
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
}
|
src/components/chat.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { renderMarkdown, attachCopyButtons } from '../markdown.js';
|
| 2 |
+
import { icon } from '../icons.js';
|
| 3 |
+
|
| 4 |
+
const MAX_VISIBLE = 50;
|
| 5 |
+
|
| 6 |
+
function escapeHtml(str) {
|
| 7 |
+
return String(str)
|
| 8 |
+
.replace(/&/g, '&')
|
| 9 |
+
.replace(/</g, '<')
|
| 10 |
+
.replace(/>/g, '>')
|
| 11 |
+
.replace(/"/g, '"');
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function getTextContent(message) {
|
| 15 |
+
if (typeof message.content === 'string') return message.content;
|
| 16 |
+
if (Array.isArray(message.content)) {
|
| 17 |
+
const textParts = message.content.filter(p => p.type === 'text').map(p => p.text);
|
| 18 |
+
return textParts.join('\n');
|
| 19 |
+
}
|
| 20 |
+
return '';
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function getImageParts(message) {
|
| 24 |
+
if (!Array.isArray(message.content)) return [];
|
| 25 |
+
return message.content.filter(p => p.type === 'image_url');
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function getVideoParts(message) {
|
| 29 |
+
if (!Array.isArray(message.content)) return [];
|
| 30 |
+
return message.content.filter(p => p.type === 'video_url');
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function formatTime(iso) {
|
| 34 |
+
try {
|
| 35 |
+
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
| 36 |
+
} catch {
|
| 37 |
+
return '';
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export class Chat {
|
| 42 |
+
constructor() {
|
| 43 |
+
this.el = null;
|
| 44 |
+
this._messages = [];
|
| 45 |
+
this._offset = 0; // how many messages we've hidden
|
| 46 |
+
this._streamingEl = null;
|
| 47 |
+
this._typingEl = null;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
render() {
|
| 51 |
+
const el = document.createElement('div');
|
| 52 |
+
el.className = 'flex flex-col h-full';
|
| 53 |
+
el.innerHTML = `
|
| 54 |
+
<div id="chat-messages" class="flex-1 overflow-y-auto py-6" role="log" aria-live="polite" aria-label="Chat messages">
|
| 55 |
+
${this._welcomeScreen()}
|
| 56 |
+
</div>
|
| 57 |
+
`;
|
| 58 |
+
this.el = el;
|
| 59 |
+
return this.el;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
_welcomeScreen() {
|
| 63 |
+
return `
|
| 64 |
+
<div id="welcome-screen" class="flex h-full items-center justify-center px-6 py-16">
|
| 65 |
+
<div class="w-full max-w-2xl text-center">
|
| 66 |
+
<h2 class="text-3xl font-semibold tracking-tight text-[var(--c-tx)] md:text-4xl">需要我为你做些什么?</h2>
|
| 67 |
+
<p class="mx-auto mt-4 max-w-xl text-sm leading-7 text-[var(--c-tx2)] md:text-[15px]">
|
| 68 |
+
直接开始提问,或先在 Settings 中配置模型与上下文限制。
|
| 69 |
+
</p>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
`;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
loadConversation(conversation) {
|
| 76 |
+
this._messages = conversation?.messages || [];
|
| 77 |
+
this._streamingEl = null;
|
| 78 |
+
this._typingEl = null;
|
| 79 |
+
this._rerender();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
_rerender() {
|
| 83 |
+
const container = this.el.querySelector('#chat-messages');
|
| 84 |
+
if (!container) return;
|
| 85 |
+
|
| 86 |
+
container.innerHTML = '';
|
| 87 |
+
|
| 88 |
+
if (this._messages.length === 0) {
|
| 89 |
+
container.innerHTML = this._welcomeScreen();
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const msgs = this._messages;
|
| 94 |
+
const total = msgs.length;
|
| 95 |
+
this._offset = Math.max(0, total - MAX_VISIBLE);
|
| 96 |
+
const visible = msgs.slice(this._offset);
|
| 97 |
+
|
| 98 |
+
if (this._offset > 0) {
|
| 99 |
+
const loadMore = document.createElement('div');
|
| 100 |
+
loadMore.className = 'max-w-4xl mx-auto w-full px-6 flex justify-center py-4';
|
| 101 |
+
loadMore.innerHTML = `<button id="load-older-btn" class="text-[12px] text-[var(--c-tx3)] border border-[var(--c-bd)] rounded-full px-4 py-1.5 hover:text-[var(--c-tx2)] hover:border-[var(--c-bd-hi)] transition-all">Load ${this._offset} older messages</button>`;
|
| 102 |
+
loadMore.querySelector('#load-older-btn').addEventListener('click', () => this._loadOlder(container));
|
| 103 |
+
container.appendChild(loadMore);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
visible.forEach(msg => {
|
| 107 |
+
const el = this._buildMessageEl(msg);
|
| 108 |
+
container.appendChild(el);
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
this._scrollToBottom();
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
_loadOlder(container) {
|
| 115 |
+
const loadMoreDiv = container.querySelector('#load-older-btn')?.parentElement;
|
| 116 |
+
const msgs = this._messages;
|
| 117 |
+
const newOffset = Math.max(0, this._offset - MAX_VISIBLE);
|
| 118 |
+
const olderMsgs = msgs.slice(newOffset, this._offset);
|
| 119 |
+
this._offset = newOffset;
|
| 120 |
+
|
| 121 |
+
const fragment = document.createDocumentFragment();
|
| 122 |
+
if (newOffset > 0) {
|
| 123 |
+
const newLoadMore = document.createElement('div');
|
| 124 |
+
newLoadMore.className = 'max-w-4xl mx-auto w-full px-6 flex justify-center py-4';
|
| 125 |
+
newLoadMore.innerHTML = `<button id="load-older-btn" class="text-[12px] text-[var(--c-tx3)] border border-[var(--c-bd)] rounded-full px-4 py-1.5 hover:text-[var(--c-tx2)] hover:border-[var(--c-bd-hi)] transition-all">Load ${newOffset} older messages</button>`;
|
| 126 |
+
newLoadMore.querySelector('#load-older-btn').addEventListener('click', () => this._loadOlder(container));
|
| 127 |
+
fragment.appendChild(newLoadMore);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
olderMsgs.forEach(msg => {
|
| 131 |
+
fragment.appendChild(this._buildMessageEl(msg));
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
if (loadMoreDiv) {
|
| 135 |
+
container.insertBefore(fragment, loadMoreDiv);
|
| 136 |
+
loadMoreDiv.remove();
|
| 137 |
+
} else {
|
| 138 |
+
container.insertBefore(fragment, container.firstChild);
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
_buildMessageEl(msg) {
|
| 143 |
+
const isUser = msg.role === 'user';
|
| 144 |
+
const text = getTextContent(msg);
|
| 145 |
+
const images = getImageParts(msg);
|
| 146 |
+
const videos = getVideoParts(msg);
|
| 147 |
+
const time = formatTime(msg.timestamp);
|
| 148 |
+
|
| 149 |
+
const wrapper = document.createElement('div');
|
| 150 |
+
wrapper.className = 'message-enter max-w-4xl mx-auto w-full px-6 mb-4';
|
| 151 |
+
wrapper.dataset.msgId = msg.timestamp || Math.random();
|
| 152 |
+
|
| 153 |
+
if (isUser) {
|
| 154 |
+
const imageHtml = images.map(img => `
|
| 155 |
+
<img src="${img.image_url?.url || ''}" alt="Attached image" class="max-w-xs max-h-48 rounded-xl border border-[var(--c-bd)] object-cover mb-2" />
|
| 156 |
+
`).join('');
|
| 157 |
+
|
| 158 |
+
const videoHtml = videos.map(() => `
|
| 159 |
+
<video src="${escapeHtml(videos[0]?.video_url?.url || '')}" controls muted playsinline
|
| 160 |
+
class="max-w-xs max-h-48 rounded-xl border border-[var(--c-bd)] bg-black mb-2"></video>
|
| 161 |
+
`).join('');
|
| 162 |
+
|
| 163 |
+
wrapper.innerHTML = `
|
| 164 |
+
<div class="flex justify-end">
|
| 165 |
+
<div class="max-w-[65%]">
|
| 166 |
+
${imageHtml}
|
| 167 |
+
${videoHtml}
|
| 168 |
+
<div class="user-bubble px-4 py-3 text-[13.5px] text-[var(--c-utx)] whitespace-pre-wrap break-words leading-relaxed">${escapeHtml(text)}</div>
|
| 169 |
+
${time ? `<div class="text-[11px] text-[var(--c-tx3)] mt-1.5 text-right">${time}</div>` : ''}
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
`;
|
| 173 |
+
} else {
|
| 174 |
+
const msgDiv = document.createElement('div');
|
| 175 |
+
msgDiv.className = 'w-full';
|
| 176 |
+
|
| 177 |
+
const bubble = document.createElement('div');
|
| 178 |
+
bubble.className = 'text-[13.5px] prose-dark leading-relaxed';
|
| 179 |
+
bubble.innerHTML = renderMarkdown(text);
|
| 180 |
+
attachCopyButtons(bubble);
|
| 181 |
+
|
| 182 |
+
msgDiv.appendChild(bubble);
|
| 183 |
+
|
| 184 |
+
if (time) {
|
| 185 |
+
const timeEl = document.createElement('div');
|
| 186 |
+
timeEl.className = 'text-[11px] text-[var(--c-tx3)] mt-2';
|
| 187 |
+
timeEl.textContent = time;
|
| 188 |
+
msgDiv.appendChild(timeEl);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
wrapper.appendChild(msgDiv);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
return wrapper;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
appendUserMessage(message) {
|
| 198 |
+
this._messages.push(message);
|
| 199 |
+
const container = this.el.querySelector('#chat-messages');
|
| 200 |
+
|
| 201 |
+
// Remove welcome screen if present
|
| 202 |
+
const welcome = container.querySelector('#welcome-screen');
|
| 203 |
+
if (welcome) welcome.remove();
|
| 204 |
+
|
| 205 |
+
const el = this._buildMessageEl(message);
|
| 206 |
+
container.appendChild(el);
|
| 207 |
+
this._scrollToBottom();
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
showTypingIndicator() {
|
| 211 |
+
const container = this.el.querySelector('#chat-messages');
|
| 212 |
+
this.hideTypingIndicator();
|
| 213 |
+
|
| 214 |
+
this._typingEl = document.createElement('div');
|
| 215 |
+
this._typingEl.className = 'max-w-4xl mx-auto w-full px-6 mb-4';
|
| 216 |
+
this._typingEl.innerHTML = `
|
| 217 |
+
<div class="flex justify-start">
|
| 218 |
+
<div class="flex items-center gap-1.5 py-3 px-1">
|
| 219 |
+
<span class="typing-dot"></span>
|
| 220 |
+
<span class="typing-dot"></span>
|
| 221 |
+
<span class="typing-dot"></span>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
`;
|
| 225 |
+
container.appendChild(this._typingEl);
|
| 226 |
+
this._scrollToBottom();
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
hideTypingIndicator() {
|
| 230 |
+
if (this._typingEl) {
|
| 231 |
+
this._typingEl.remove();
|
| 232 |
+
this._typingEl = null;
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
startAssistantMessage() {
|
| 237 |
+
this.hideTypingIndicator();
|
| 238 |
+
const container = this.el.querySelector('#chat-messages');
|
| 239 |
+
|
| 240 |
+
const wrapper = document.createElement('div');
|
| 241 |
+
wrapper.className = 'message-enter max-w-4xl mx-auto w-full px-6 mb-4';
|
| 242 |
+
|
| 243 |
+
const msgDiv = document.createElement('div');
|
| 244 |
+
msgDiv.className = 'w-full';
|
| 245 |
+
|
| 246 |
+
const bubble = document.createElement('div');
|
| 247 |
+
bubble.className = 'text-[13.5px] prose-dark leading-relaxed';
|
| 248 |
+
bubble.innerHTML = '<span class="streaming-cursor opacity-60">▋</span>';
|
| 249 |
+
|
| 250 |
+
msgDiv.appendChild(bubble);
|
| 251 |
+
wrapper.appendChild(msgDiv);
|
| 252 |
+
container.appendChild(wrapper);
|
| 253 |
+
this._scrollToBottom();
|
| 254 |
+
this._streamingEl = bubble;
|
| 255 |
+
this._streamingText = '';
|
| 256 |
+
return bubble;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
appendToAssistantMessage(chunk) {
|
| 260 |
+
if (!this._streamingEl) return;
|
| 261 |
+
this._streamingText = (this._streamingText || '') + chunk;
|
| 262 |
+
// Re-render markdown during streaming for better UX
|
| 263 |
+
this._streamingEl.innerHTML = renderMarkdown(this._streamingText) + '<span class="streaming-cursor opacity-60 animate-pulse">▋</span>';
|
| 264 |
+
this._scrollToBottom();
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
finalizeAssistantMessage(fullText) {
|
| 268 |
+
if (!this._streamingEl) return;
|
| 269 |
+
this._streamingEl.innerHTML = renderMarkdown(fullText);
|
| 270 |
+
attachCopyButtons(this._streamingEl);
|
| 271 |
+
this._streamingEl = null;
|
| 272 |
+
this._streamingText = '';
|
| 273 |
+
this._messages.push({
|
| 274 |
+
role: 'assistant',
|
| 275 |
+
content: fullText,
|
| 276 |
+
timestamp: new Date().toISOString(),
|
| 277 |
+
});
|
| 278 |
+
this._scrollToBottom();
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
showError(message) {
|
| 282 |
+
const container = this.el.querySelector('#chat-messages');
|
| 283 |
+
this.hideTypingIndicator();
|
| 284 |
+
if (this._streamingEl) {
|
| 285 |
+
this._streamingEl.innerHTML = `<span class="text-red-400">${escapeHtml(message)}</span>`;
|
| 286 |
+
this._streamingEl = null;
|
| 287 |
+
this._streamingText = '';
|
| 288 |
+
return;
|
| 289 |
+
}
|
| 290 |
+
const errEl = document.createElement('div');
|
| 291 |
+
errEl.className = 'max-w-4xl mx-auto w-full px-6 mb-4 message-enter';
|
| 292 |
+
errEl.innerHTML = `
|
| 293 |
+
<div class="bg-red-950/40 border border-red-900/40 rounded-xl px-4 py-3 text-[13px] text-red-400 flex items-center gap-2">
|
| 294 |
+
${icon('x')} ${escapeHtml(message)}
|
| 295 |
+
</div>
|
| 296 |
+
`;
|
| 297 |
+
container.appendChild(errEl);
|
| 298 |
+
this._scrollToBottom();
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
showSystemMessage(text) {
|
| 302 |
+
const container = this.el.querySelector('#chat-messages');
|
| 303 |
+
if (!container) return;
|
| 304 |
+
const welcome = container.querySelector('#welcome-screen');
|
| 305 |
+
if (welcome) welcome.remove();
|
| 306 |
+
const el = document.createElement('div');
|
| 307 |
+
el.className = 'max-w-4xl mx-auto w-full px-6 my-3 message-enter flex justify-center';
|
| 308 |
+
el.innerHTML = `
|
| 309 |
+
<div class="text-[11px] text-[var(--c-tx3)] border border-[var(--c-bd)] rounded-full px-3 py-1 bg-[var(--c-ho)] select-none">
|
| 310 |
+
${escapeHtml(text)}
|
| 311 |
+
</div>
|
| 312 |
+
`;
|
| 313 |
+
container.appendChild(el);
|
| 314 |
+
this._scrollToBottom();
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
_scrollToBottom() {
|
| 318 |
+
const container = this.el.querySelector('#chat-messages');
|
| 319 |
+
if (container) {
|
| 320 |
+
requestAnimationFrame(() => {
|
| 321 |
+
container.scrollTop = container.scrollHeight;
|
| 322 |
+
});
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
clear() {
|
| 327 |
+
this._messages = [];
|
| 328 |
+
this._streamingEl = null;
|
| 329 |
+
this._streamingText = '';
|
| 330 |
+
this._typingEl = null;
|
| 331 |
+
const container = this.el.querySelector('#chat-messages');
|
| 332 |
+
if (container) container.innerHTML = this._welcomeScreen();
|
| 333 |
+
}
|
| 334 |
+
}
|
src/components/input-bar.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store } from '../store.js';
|
| 2 |
+
import { formatCompactTokenCount } from '../api.js';
|
| 3 |
+
import { supportsAudio, supportsImage, supportsVideo } from '../capabilities.js';
|
| 4 |
+
import { icon } from '../icons.js';
|
| 5 |
+
|
| 6 |
+
const VIDEO_SIZE_LIMIT_MB = 50;
|
| 7 |
+
|
| 8 |
+
export class InputBar {
|
| 9 |
+
constructor() {
|
| 10 |
+
this.el = null;
|
| 11 |
+
this._pendingImage = null; // { dataUrl, file }
|
| 12 |
+
this._pendingVideo = null; // { dataUrl, file }
|
| 13 |
+
this._pendingAudio = null; // { file }
|
| 14 |
+
this._currentModel = '';
|
| 15 |
+
this._sending = false;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
render() {
|
| 19 |
+
const el = document.createElement('div');
|
| 20 |
+
el.className = 'border-t border-[var(--c-bd)] bg-[var(--c-bg)]';
|
| 21 |
+
el.innerHTML = this._template();
|
| 22 |
+
this.el = el;
|
| 23 |
+
this._bindEvents();
|
| 24 |
+
return this.el;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
_template() {
|
| 28 |
+
const imageSupported = supportsImage(this._currentModel, store.getModelCapabilities());
|
| 29 |
+
const videoSupported = supportsVideo(this._currentModel, store.getModelCapabilities());
|
| 30 |
+
const audioSupported = supportsAudio(this._currentModel, store.getModelCapabilities());
|
| 31 |
+
const mediaSupported = imageSupported || videoSupported;
|
| 32 |
+
const mediaAccept = [imageSupported && 'image/*', videoSupported && 'video/*'].filter(Boolean).join(',') || 'image/*,video/*';
|
| 33 |
+
const mediaTitle = mediaSupported
|
| 34 |
+
? `Attach ${[imageSupported && 'image', videoSupported && 'video'].filter(Boolean).join(' / ')} (or paste)`
|
| 35 |
+
: 'Vision not enabled for this model';
|
| 36 |
+
return `
|
| 37 |
+
<div class="px-4 pb-4 pt-3 max-w-4xl mx-auto w-full">
|
| 38 |
+
<input id="media-file-input" type="file" accept="${mediaAccept}" class="hidden" aria-label="Attach image or video" />
|
| 39 |
+
<input id="audio-file-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.aac,.ogg,.flac,.webm" class="hidden" aria-label="Attach audio" />
|
| 40 |
+
<div class="flex flex-col bg-[var(--c-card)] border border-[var(--c-bd)] rounded-2xl focus-within:border-[var(--c-bd-hi)] transition-all duration-200 shadow-lg shadow-black/10">
|
| 41 |
+
|
| 42 |
+
<div id="image-preview-area" class="hidden px-3 pt-3 pb-1">
|
| 43 |
+
<div class="relative inline-block">
|
| 44 |
+
<img id="image-preview-thumb" src="" alt="Attached image"
|
| 45 |
+
class="max-h-24 max-w-[200px] rounded-xl border border-[var(--c-bd)] object-cover" />
|
| 46 |
+
<button id="remove-image-btn"
|
| 47 |
+
class="absolute -top-1.5 -right-1.5 bg-[var(--c-card)] border border-[var(--c-bd)] rounded-full w-5 h-5 flex items-center justify-center text-[var(--c-tx3)] hover:text-[var(--c-tx)] transition-colors"
|
| 48 |
+
aria-label="Remove image">
|
| 49 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div id="video-preview-area" class="hidden px-3 pt-3 pb-1">
|
| 55 |
+
<div class="relative inline-block">
|
| 56 |
+
<video id="video-preview-player" src="" controls muted playsinline
|
| 57 |
+
class="max-h-36 max-w-xs rounded-xl border border-[var(--c-bd)] bg-black"></video>
|
| 58 |
+
<button id="remove-video-btn"
|
| 59 |
+
class="absolute -top-1.5 -right-1.5 bg-[var(--c-card)] border border-[var(--c-bd)] rounded-full w-5 h-5 flex items-center justify-center text-[var(--c-tx3)] hover:text-[var(--c-tx)] transition-colors"
|
| 60 |
+
aria-label="Remove video">
|
| 61 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 62 |
+
</button>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div id="audio-preview-area" class="hidden px-3 pt-3 pb-1">
|
| 67 |
+
<div class="flex items-center gap-2 rounded-xl border border-[var(--c-bd)] bg-[var(--c-ho)] px-3 py-2">
|
| 68 |
+
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--c-hi)] text-[var(--c-tx3)]">
|
| 69 |
+
${icon('audio')}
|
| 70 |
+
</div>
|
| 71 |
+
<div class="min-w-0 flex-1">
|
| 72 |
+
<div id="audio-file-name" class="truncate text-[13px] text-[var(--c-tx2)]"></div>
|
| 73 |
+
<div id="audio-mode-label" class="text-[11px] text-[var(--c-tx3)]">Audio will be transcribed before text processing</div>
|
| 74 |
+
</div>
|
| 75 |
+
<button id="remove-audio-btn"
|
| 76 |
+
class="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--c-bd)] bg-[var(--c-ho)] text-[var(--c-tx3)] transition-colors hover:text-[var(--c-tx2)]"
|
| 77 |
+
aria-label="Remove audio"
|
| 78 |
+
type="button">
|
| 79 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<textarea
|
| 85 |
+
id="message-input"
|
| 86 |
+
class="bg-transparent px-4 pt-3 pb-1 text-[13.5px] text-[var(--c-tx)] placeholder-[var(--c-txph)] focus:outline-none leading-relaxed resize-none w-full"
|
| 87 |
+
placeholder="Type a message…"
|
| 88 |
+
rows="1"
|
| 89 |
+
style="max-height: 144px; overflow-y: auto;"
|
| 90 |
+
aria-label="Message input"
|
| 91 |
+
></textarea>
|
| 92 |
+
|
| 93 |
+
<div class="flex items-center justify-between px-2 pb-2 pt-1 gap-2">
|
| 94 |
+
<div class="flex items-center gap-0.5">
|
| 95 |
+
<button id="media-upload-btn"
|
| 96 |
+
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all ${mediaSupported ? 'text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]' : 'text-[var(--c-tx3)] opacity-40 cursor-not-allowed'}"
|
| 97 |
+
${mediaSupported ? '' : 'disabled'}
|
| 98 |
+
aria-label="Attach image or video"
|
| 99 |
+
title="${mediaTitle}">
|
| 100 |
+
${icon('image')}
|
| 101 |
+
</button>
|
| 102 |
+
<button id="audio-upload-btn"
|
| 103 |
+
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all ${audioSupported ? 'text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]' : 'text-[var(--c-tx3)] opacity-40 cursor-not-allowed'}"
|
| 104 |
+
${audioSupported ? '' : 'disabled'}
|
| 105 |
+
aria-label="Attach audio"
|
| 106 |
+
title="${audioSupported ? 'Attach audio for transcription or translation' : 'Audio not enabled for this model'}">
|
| 107 |
+
${icon('audio')}
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
<span id="context-info"
|
| 111 |
+
class="ml-auto text-right text-[11px] font-mono tabular-nums text-[var(--c-tx3)] transition-colors select-none"
|
| 112 |
+
title="Estimated context tokens / configured window">ctx 0</span>
|
| 113 |
+
<button id="send-btn"
|
| 114 |
+
class="flex items-center justify-center w-8 h-8 rounded-xl transition-all hover:bg-[var(--c-hi)]"
|
| 115 |
+
aria-label="Send message"
|
| 116 |
+
title="Send (Enter) · Shift+Enter for new line">
|
| 117 |
+
${icon('send')}
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
`;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
_bindEvents() {
|
| 127 |
+
const textarea = this.el.querySelector('#message-input');
|
| 128 |
+
const sendBtn = this.el.querySelector('#send-btn');
|
| 129 |
+
const mediaBtn = this.el.querySelector('#media-upload-btn');
|
| 130 |
+
const audioBtn = this.el.querySelector('#audio-upload-btn');
|
| 131 |
+
const fileInput = this.el.querySelector('#media-file-input');
|
| 132 |
+
const audioInput = this.el.querySelector('#audio-file-input');
|
| 133 |
+
const removeImageBtn = this.el.querySelector('#remove-image-btn');
|
| 134 |
+
const removeVideoBtn = this.el.querySelector('#remove-video-btn');
|
| 135 |
+
const removeAudioBtn = this.el.querySelector('#remove-audio-btn');
|
| 136 |
+
|
| 137 |
+
// Auto-resize textarea
|
| 138 |
+
textarea.addEventListener('input', () => this._autoResize(textarea));
|
| 139 |
+
|
| 140 |
+
// Enter sends; Shift+Enter inserts newline (default behaviour)
|
| 141 |
+
textarea.addEventListener('keydown', (e) => {
|
| 142 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 143 |
+
e.preventDefault();
|
| 144 |
+
this._submit();
|
| 145 |
+
}
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
sendBtn.addEventListener('click', () => this._submit());
|
| 149 |
+
|
| 150 |
+
// Media (image/video) upload
|
| 151 |
+
mediaBtn?.addEventListener('click', () => {
|
| 152 |
+
if (!mediaBtn.disabled) fileInput.click();
|
| 153 |
+
});
|
| 154 |
+
audioBtn?.addEventListener('click', () => {
|
| 155 |
+
if (!audioBtn.disabled) audioInput.click();
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
fileInput.addEventListener('change', (e) => {
|
| 159 |
+
const file = e.target.files?.[0];
|
| 160 |
+
if (file) this._handleMediaFile(file);
|
| 161 |
+
fileInput.value = '';
|
| 162 |
+
});
|
| 163 |
+
audioInput.addEventListener('change', (e) => {
|
| 164 |
+
const file = e.target.files?.[0];
|
| 165 |
+
if (file) this._handleAudioFile(file);
|
| 166 |
+
audioInput.value = '';
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
removeImageBtn.addEventListener('click', () => this._clearImage());
|
| 170 |
+
removeVideoBtn?.addEventListener('click', () => this._clearVideo());
|
| 171 |
+
removeAudioBtn?.addEventListener('click', () => this._clearAudio());
|
| 172 |
+
|
| 173 |
+
// Paste media support
|
| 174 |
+
textarea.addEventListener('paste', (e) => this._handlePaste(e));
|
| 175 |
+
|
| 176 |
+
// Model change
|
| 177 |
+
document.addEventListener('model:changed', (e) => {
|
| 178 |
+
this.setModel(e.detail.model);
|
| 179 |
+
});
|
| 180 |
+
document.addEventListener('caps:changed', () => {
|
| 181 |
+
this._updateAttachmentButtons();
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
_autoResize(textarea) {
|
| 186 |
+
textarea.style.height = 'auto';
|
| 187 |
+
const scrollH = textarea.scrollHeight;
|
| 188 |
+
const maxH = 144; // max-height ~6 rows
|
| 189 |
+
textarea.style.height = Math.min(scrollH, maxH) + 'px';
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
async _handleImageFile(file) {
|
| 193 |
+
if (!file.type.startsWith('image/')) return;
|
| 194 |
+
if (this._pendingAudio) this._clearAudio();
|
| 195 |
+
if (this._pendingVideo) this._clearVideo();
|
| 196 |
+
const dataUrl = await this._fileToDataUrl(file);
|
| 197 |
+
this._pendingImage = { dataUrl, file };
|
| 198 |
+
this._showImagePreview(dataUrl);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
async _handleVideoFile(file) {
|
| 202 |
+
if (!file.type.startsWith('video/')) return;
|
| 203 |
+
const sizeMB = file.size / (1024 * 1024);
|
| 204 |
+
if (sizeMB > VIDEO_SIZE_LIMIT_MB) {
|
| 205 |
+
alert(`Video file is too large (${sizeMB.toFixed(1)} MB). Please keep it under ${VIDEO_SIZE_LIMIT_MB} MB.`);
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
if (this._pendingAudio) this._clearAudio();
|
| 209 |
+
if (this._pendingImage) this._clearImage();
|
| 210 |
+
const dataUrl = await this._fileToDataUrl(file);
|
| 211 |
+
this._pendingVideo = { dataUrl, file };
|
| 212 |
+
this._showVideoPreview(dataUrl);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
_handleMediaFile(file) {
|
| 216 |
+
if (file.type.startsWith('image/')) {
|
| 217 |
+
this._handleImageFile(file);
|
| 218 |
+
} else if (file.type.startsWith('video/')) {
|
| 219 |
+
this._handleVideoFile(file);
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
_handleAudioFile(file) {
|
| 224 |
+
if (!String(file?.type || '').startsWith('audio/') && !/\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i.test(file?.name || '')) return;
|
| 225 |
+
if (this._pendingImage) this._clearImage();
|
| 226 |
+
this._pendingAudio = { file };
|
| 227 |
+
this._showAudioPreview();
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
_fileToDataUrl(file) {
|
| 231 |
+
return new Promise((resolve, reject) => {
|
| 232 |
+
const reader = new FileReader();
|
| 233 |
+
reader.onload = (e) => resolve(e.target.result);
|
| 234 |
+
reader.onerror = reject;
|
| 235 |
+
reader.readAsDataURL(file);
|
| 236 |
+
});
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
_showImagePreview(dataUrl) {
|
| 240 |
+
const previewArea = this.el.querySelector('#image-preview-area');
|
| 241 |
+
const thumb = this.el.querySelector('#image-preview-thumb');
|
| 242 |
+
thumb.src = dataUrl;
|
| 243 |
+
previewArea.classList.remove('hidden');
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
_clearImage() {
|
| 247 |
+
this._pendingImage = null;
|
| 248 |
+
const previewArea = this.el.querySelector('#image-preview-area');
|
| 249 |
+
const thumb = this.el.querySelector('#image-preview-thumb');
|
| 250 |
+
thumb.src = '';
|
| 251 |
+
previewArea.classList.add('hidden');
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
_showVideoPreview(dataUrl) {
|
| 255 |
+
const previewArea = this.el.querySelector('#video-preview-area');
|
| 256 |
+
const player = this.el.querySelector('#video-preview-player');
|
| 257 |
+
player.src = dataUrl;
|
| 258 |
+
previewArea.classList.remove('hidden');
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
_clearVideo() {
|
| 262 |
+
this._pendingVideo = null;
|
| 263 |
+
const previewArea = this.el.querySelector('#video-preview-area');
|
| 264 |
+
const player = this.el.querySelector('#video-preview-player');
|
| 265 |
+
if (player) { player.pause(); player.src = ''; }
|
| 266 |
+
previewArea?.classList.add('hidden');
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
_showAudioPreview() {
|
| 270 |
+
const previewArea = this.el.querySelector('#audio-preview-area');
|
| 271 |
+
const nameEl = this.el.querySelector('#audio-file-name');
|
| 272 |
+
if (!previewArea || !this._pendingAudio) return;
|
| 273 |
+
nameEl.textContent = this._pendingAudio.file?.name || 'audio';
|
| 274 |
+
previewArea.classList.remove('hidden');
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
_clearAudio() {
|
| 278 |
+
this._pendingAudio = null;
|
| 279 |
+
const previewArea = this.el.querySelector('#audio-preview-area');
|
| 280 |
+
const nameEl = this.el.querySelector('#audio-file-name');
|
| 281 |
+
if (nameEl) nameEl.textContent = '';
|
| 282 |
+
previewArea?.classList.add('hidden');
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
async _handlePaste(e) {
|
| 286 |
+
const items = e.clipboardData?.items;
|
| 287 |
+
if (!items) return;
|
| 288 |
+
for (const item of items) {
|
| 289 |
+
if (item.type.startsWith('image/')) {
|
| 290 |
+
const imageSupported = supportsImage(this._currentModel, store.getModelCapabilities());
|
| 291 |
+
if (!imageSupported) return;
|
| 292 |
+
e.preventDefault();
|
| 293 |
+
const file = item.getAsFile();
|
| 294 |
+
if (file) await this._handleImageFile(file);
|
| 295 |
+
break;
|
| 296 |
+
}
|
| 297 |
+
if (item.type.startsWith('video/')) {
|
| 298 |
+
const videoSupported = supportsVideo(this._currentModel, store.getModelCapabilities());
|
| 299 |
+
if (!videoSupported) return;
|
| 300 |
+
e.preventDefault();
|
| 301 |
+
const file = item.getAsFile();
|
| 302 |
+
if (file) await this._handleVideoFile(file);
|
| 303 |
+
break;
|
| 304 |
+
}
|
| 305 |
+
if (item.type.startsWith('audio/')) {
|
| 306 |
+
const audioSupported = supportsAudio(this._currentModel, store.getModelCapabilities());
|
| 307 |
+
if (!audioSupported) return;
|
| 308 |
+
e.preventDefault();
|
| 309 |
+
const file = item.getAsFile();
|
| 310 |
+
if (file) this._handleAudioFile(file);
|
| 311 |
+
break;
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
_submit() {
|
| 317 |
+
if (this._sending) return;
|
| 318 |
+
const textarea = this.el.querySelector('#message-input');
|
| 319 |
+
const text = textarea.value.trim();
|
| 320 |
+
if (!text && !this._pendingImage && !this._pendingVideo && !this._pendingAudio) return;
|
| 321 |
+
|
| 322 |
+
const image = this._pendingImage;
|
| 323 |
+
const video = this._pendingVideo;
|
| 324 |
+
const audio = this._pendingAudio;
|
| 325 |
+
this._clearImage();
|
| 326 |
+
this._clearVideo();
|
| 327 |
+
this._clearAudio();
|
| 328 |
+
textarea.value = '';
|
| 329 |
+
this._autoResize(textarea);
|
| 330 |
+
|
| 331 |
+
document.dispatchEvent(new CustomEvent('inputbar:send', {
|
| 332 |
+
detail: { text, image, video, audio },
|
| 333 |
+
}));
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
setSending(sending) {
|
| 337 |
+
this._sending = sending;
|
| 338 |
+
const sendBtn = this.el.querySelector('#send-btn');
|
| 339 |
+
const textarea = this.el.querySelector('#message-input');
|
| 340 |
+
if (sendBtn) {
|
| 341 |
+
sendBtn.disabled = sending;
|
| 342 |
+
sendBtn.className = sending
|
| 343 |
+
? 'flex items-center justify-center w-8 h-8 rounded-xl text-[var(--c-tx3)] cursor-not-allowed transition-all'
|
| 344 |
+
: 'flex items-center justify-center w-8 h-8 rounded-xl transition-all hover:bg-[var(--c-hi)]';
|
| 345 |
+
sendBtn.innerHTML = icon(sending ? 'sendMuted' : 'send');
|
| 346 |
+
}
|
| 347 |
+
if (textarea) textarea.disabled = sending;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
setContextInfo(currentTokens, maxTokens, warnTokens = maxTokens) {
|
| 351 |
+
const el = this.el.querySelector('#context-info');
|
| 352 |
+
if (!el) return;
|
| 353 |
+
el.textContent = `ctx ${formatCompactTokenCount(currentTokens)}/${formatCompactTokenCount(maxTokens)}`;
|
| 354 |
+
el.title = `Estimated context ${Math.round(currentTokens)} / ${Math.round(maxTokens)} tokens`;
|
| 355 |
+
const warning = currentTokens >= warnTokens;
|
| 356 |
+
el.className = warning
|
| 357 |
+
? 'ml-auto text-right text-[11px] font-mono tabular-nums text-amber-400 transition-colors select-none'
|
| 358 |
+
: 'ml-auto text-right text-[11px] font-mono tabular-nums text-[var(--c-tx3)] transition-colors select-none';
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
setKvCount(current, max) {
|
| 362 |
+
this.setContextInfo(current, max, Math.floor(max * 0.8));
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
setModel(modelId) {
|
| 366 |
+
this._currentModel = modelId;
|
| 367 |
+
this._updateAttachmentButtons();
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
_updateAttachmentButtons() {
|
| 371 |
+
const mediaBtn = this.el.querySelector('#media-upload-btn');
|
| 372 |
+
const audioBtn = this.el.querySelector('#audio-upload-btn');
|
| 373 |
+
const fileInput = this.el.querySelector('#media-file-input');
|
| 374 |
+
if (!mediaBtn || !audioBtn) return;
|
| 375 |
+
const imageSupported = supportsImage(this._currentModel, store.getModelCapabilities());
|
| 376 |
+
const videoSupported = supportsVideo(this._currentModel, store.getModelCapabilities());
|
| 377 |
+
const audioSupported = supportsAudio(this._currentModel, store.getModelCapabilities());
|
| 378 |
+
const mediaSupported = imageSupported || videoSupported;
|
| 379 |
+
|
| 380 |
+
if (mediaSupported) {
|
| 381 |
+
mediaBtn.disabled = false;
|
| 382 |
+
mediaBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]';
|
| 383 |
+
const labels = [imageSupported && 'image', videoSupported && 'video'].filter(Boolean).join(' / ');
|
| 384 |
+
mediaBtn.title = `Attach ${labels} (or paste)`;
|
| 385 |
+
if (fileInput) {
|
| 386 |
+
fileInput.accept = [imageSupported && 'image/*', videoSupported && 'video/*'].filter(Boolean).join(',');
|
| 387 |
+
}
|
| 388 |
+
} else {
|
| 389 |
+
mediaBtn.disabled = true;
|
| 390 |
+
mediaBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] opacity-40 cursor-not-allowed';
|
| 391 |
+
mediaBtn.title = 'Vision not enabled for this model';
|
| 392 |
+
if (this._pendingImage) this._clearImage();
|
| 393 |
+
if (this._pendingVideo) this._clearVideo();
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
if (audioSupported) {
|
| 397 |
+
audioBtn.disabled = false;
|
| 398 |
+
audioBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]';
|
| 399 |
+
audioBtn.title = 'Attach audio for transcription or translation';
|
| 400 |
+
} else {
|
| 401 |
+
audioBtn.disabled = true;
|
| 402 |
+
audioBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] opacity-40 cursor-not-allowed';
|
| 403 |
+
audioBtn.title = 'Audio not enabled for this model';
|
| 404 |
+
if (this._pendingAudio) this._clearAudio();
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
focus() {
|
| 409 |
+
this.el?.querySelector('#message-input')?.focus();
|
| 410 |
+
}
|
| 411 |
+
}
|
src/components/model-picker.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store } from '../store.js';
|
| 2 |
+
import { getCapabilities } from '../capabilities.js';
|
| 3 |
+
import { icon } from '../icons.js';
|
| 4 |
+
|
| 5 |
+
export class ModelPicker {
|
| 6 |
+
constructor() {
|
| 7 |
+
this.el = null;
|
| 8 |
+
this.dropdownEl = null;
|
| 9 |
+
this.open = false;
|
| 10 |
+
this._models = [];
|
| 11 |
+
this._currentModel = '';
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
render() {
|
| 15 |
+
const el = document.createElement('div');
|
| 16 |
+
el.className = 'relative';
|
| 17 |
+
el.innerHTML = this._buttonTemplate();
|
| 18 |
+
this.el = el;
|
| 19 |
+
this._bindEvents();
|
| 20 |
+
this._syncFromStore();
|
| 21 |
+
return this.el;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
_buttonTemplate() {
|
| 25 |
+
const label = this._currentModel || 'Select a model';
|
| 26 |
+
const isPlaceholder = !this._currentModel;
|
| 27 |
+
const caps = this._currentModel ? getCapabilities(this._currentModel, store.getModelCapabilities()) : null;
|
| 28 |
+
const badges = caps ? [
|
| 29 |
+
caps.image ? '<span class="text-xs bg-[var(--c-hi)] text-[var(--c-tx3)] rounded px-1.5 py-0.5">Vision</span>' : '',
|
| 30 |
+
caps.audio ? '<span class="text-xs bg-[var(--c-hi)] text-[var(--c-tx3)] rounded px-1.5 py-0.5">Audio</span>' : '',
|
| 31 |
+
].filter(Boolean).join('') : '';
|
| 32 |
+
|
| 33 |
+
return `
|
| 34 |
+
<button id="model-picker-btn" class="flex items-center gap-2 bg-[var(--c-top-el)] hover:bg-[var(--c-top-el-h)] border border-[var(--c-top-bd)] hover:border-[var(--c-bd-hi)] rounded-xl px-3 py-1.5 text-[13px] transition-all max-w-xs ${isPlaceholder ? 'text-[var(--c-tx3)]' : 'text-[var(--c-tx2)]'}" aria-haspopup="listbox" aria-expanded="false">
|
| 35 |
+
<span class="truncate model-name">${label}</span>
|
| 36 |
+
<span class="flex items-center gap-1 model-badges">${badges}</span>
|
| 37 |
+
<span class="flex-shrink-0 opacity-40">${icon('chevronDown')}</span>
|
| 38 |
+
</button>
|
| 39 |
+
`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
_buildDropdown() {
|
| 43 |
+
const div = document.createElement('div');
|
| 44 |
+
div.className = 'dropdown-enter absolute left-0 top-full mt-1.5 z-30 bg-[var(--c-card)] border border-[var(--c-bd)] rounded-xl shadow-2xl shadow-black/30 overflow-hidden min-w-48 max-w-xs';
|
| 45 |
+
div.setAttribute('role', 'listbox');
|
| 46 |
+
|
| 47 |
+
const allModels = this._getAllModels();
|
| 48 |
+
if (allModels.length === 0) {
|
| 49 |
+
div.innerHTML = `<div class="px-4 py-5 text-[12px] text-[var(--c-tx3)] text-center leading-relaxed">No models found.<br>Save Settings, then fetch models.</div>`;
|
| 50 |
+
return div;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const userCaps = store.getModelCapabilities();
|
| 54 |
+
const items = allModels.map(modelId => {
|
| 55 |
+
const caps = getCapabilities(modelId, userCaps);
|
| 56 |
+
const active = modelId === this._currentModel;
|
| 57 |
+
const badges = [
|
| 58 |
+
caps.image ? '<span class="text-[11px] bg-[var(--c-hi)] text-[var(--c-tx3)] rounded-md px-1.5 py-0.5">Vision</span>' : '',
|
| 59 |
+
caps.audio ? '<span class="text-[11px] bg-[var(--c-hi)] text-[var(--c-tx3)] rounded-md px-1.5 py-0.5">Audio</span>' : '',
|
| 60 |
+
].filter(Boolean).join('');
|
| 61 |
+
|
| 62 |
+
return `
|
| 63 |
+
<button class="w-full flex items-center justify-between gap-2 px-3 py-2.5 text-[13px] hover:bg-[var(--c-hi)] transition-all text-left ${active ? 'text-[var(--c-tx)] bg-[var(--c-hi)]' : 'text-[var(--c-tx2)]'}" role="option" aria-selected="${active}" data-model="${modelId}">
|
| 64 |
+
<span class="truncate">${modelId}</span>
|
| 65 |
+
<span class="flex items-center gap-1 flex-shrink-0">${badges}</span>
|
| 66 |
+
</button>
|
| 67 |
+
`;
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
div.innerHTML = `<div class="overflow-y-auto max-h-64 py-1">${items.join('')}</div>`;
|
| 71 |
+
|
| 72 |
+
div.querySelectorAll('[data-model]').forEach(btn => {
|
| 73 |
+
btn.addEventListener('click', () => {
|
| 74 |
+
this.setModel(btn.dataset.model);
|
| 75 |
+
this._closeDropdown();
|
| 76 |
+
});
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
return div;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
_getAllModels() {
|
| 83 |
+
return [...new Set(this._models.filter(Boolean))].sort();
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
_bindEvents() {
|
| 87 |
+
const btn = this.el.querySelector('#model-picker-btn');
|
| 88 |
+
btn.addEventListener('click', (e) => {
|
| 89 |
+
e.stopPropagation();
|
| 90 |
+
this.open ? this._closeDropdown() : this._openDropdown();
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
document.addEventListener('click', () => {
|
| 94 |
+
if (this.open) this._closeDropdown();
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
document.addEventListener('caps:changed', () => this._updateButton());
|
| 98 |
+
document.addEventListener('settings:changed', () => this._syncFromStore());
|
| 99 |
+
document.addEventListener('models:changed', () => this._syncFromStore());
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
_syncFromStore() {
|
| 103 |
+
this._models = store.getAvailableModels();
|
| 104 |
+
const selected = store.getCurrentModel();
|
| 105 |
+
this._currentModel = this._models.includes(selected) ? selected : '';
|
| 106 |
+
this._updateButton();
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
_openDropdown() {
|
| 110 |
+
this._closeDropdown();
|
| 111 |
+
this.dropdownEl = this._buildDropdown();
|
| 112 |
+
this.el.appendChild(this.dropdownEl);
|
| 113 |
+
this.open = true;
|
| 114 |
+
this.el.querySelector('#model-picker-btn').setAttribute('aria-expanded', 'true');
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
_closeDropdown() {
|
| 118 |
+
if (this.dropdownEl && this.dropdownEl.parentNode) {
|
| 119 |
+
this.dropdownEl.parentNode.removeChild(this.dropdownEl);
|
| 120 |
+
}
|
| 121 |
+
this.dropdownEl = null;
|
| 122 |
+
this.open = false;
|
| 123 |
+
this.el?.querySelector('#model-picker-btn')?.setAttribute('aria-expanded', 'false');
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
_updateButton() {
|
| 127 |
+
const isPlaceholder = !this._currentModel;
|
| 128 |
+
const caps = this._currentModel ? getCapabilities(this._currentModel, store.getModelCapabilities()) : null;
|
| 129 |
+
const badges = caps ? [
|
| 130 |
+
caps.image ? '<span class="text-xs bg-[var(--c-hi)] text-[var(--c-tx3)] rounded px-1.5 py-0.5">Vision</span>' : '',
|
| 131 |
+
caps.audio ? '<span class="text-xs bg-[var(--c-hi)] text-[var(--c-tx3)] rounded px-1.5 py-0.5">Audio</span>' : '',
|
| 132 |
+
].filter(Boolean).join('') : '';
|
| 133 |
+
|
| 134 |
+
const nameEl = this.el?.querySelector('.model-name');
|
| 135 |
+
const badgesEl = this.el?.querySelector('.model-badges');
|
| 136 |
+
const btn = this.el?.querySelector('#model-picker-btn');
|
| 137 |
+
if (nameEl) nameEl.textContent = this._currentModel || 'Select a model';
|
| 138 |
+
if (badgesEl) badgesEl.innerHTML = badges;
|
| 139 |
+
if (btn) {
|
| 140 |
+
btn.classList.remove('text-[var(--c-tx3)]', 'text-[var(--c-tx2)]');
|
| 141 |
+
btn.classList.add(isPlaceholder ? 'text-[var(--c-tx3)]' : 'text-[var(--c-tx2)]');
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
setModel(modelId) {
|
| 146 |
+
this._currentModel = modelId;
|
| 147 |
+
store.setCurrentModel(store.getSettings().baseUrl, modelId);
|
| 148 |
+
this._updateButton();
|
| 149 |
+
document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: modelId } }));
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
getModel() {
|
| 153 |
+
return this._currentModel;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
setModels(models) {
|
| 157 |
+
this._models = [...new Set((models || []).filter(Boolean))].sort();
|
| 158 |
+
const selected = store.getCurrentModel();
|
| 159 |
+
if (selected && !this._models.includes(selected)) {
|
| 160 |
+
this._currentModel = '';
|
| 161 |
+
store.setCurrentModel(store.getSettings().baseUrl, '');
|
| 162 |
+
document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: '' } }));
|
| 163 |
+
} else {
|
| 164 |
+
this._currentModel = this._models.includes(selected) ? selected : '';
|
| 165 |
+
}
|
| 166 |
+
if (this.open) {
|
| 167 |
+
this._closeDropdown();
|
| 168 |
+
this._openDropdown();
|
| 169 |
+
}
|
| 170 |
+
this._updateButton();
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
syncToConversation(conv) {
|
| 174 |
+
this._syncFromStore();
|
| 175 |
+
}
|
| 176 |
+
}
|
src/components/settings-modal.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store } from '../store.js';
|
| 2 |
+
import { DEFAULT_CAPABILITIES } from '../capabilities.js';
|
| 3 |
+
import { fetchModels } from '../api.js';
|
| 4 |
+
import {
|
| 5 |
+
DEFAULT_CONTEXT_LIMIT_TOKENS,
|
| 6 |
+
DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT,
|
| 7 |
+
} from '../context.js';
|
| 8 |
+
import { icon } from '../icons.js';
|
| 9 |
+
|
| 10 |
+
export class SettingsModal {
|
| 11 |
+
constructor() {
|
| 12 |
+
this.el = null;
|
| 13 |
+
this.visible = false;
|
| 14 |
+
this._fetchedModels = [];
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
render() {
|
| 18 |
+
const el = document.createElement('div');
|
| 19 |
+
el.innerHTML = this._template();
|
| 20 |
+
this.el = el.firstElementChild;
|
| 21 |
+
this._bindEvents();
|
| 22 |
+
return this.el;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
_template() {
|
| 26 |
+
return `
|
| 27 |
+
<div class="modal-backdrop fixed inset-0 z-50 flex items-start justify-center pt-16 px-4 pb-8" style="background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);">
|
| 28 |
+
<div class="modal-panel bg-[var(--c-card)] border border-[var(--c-bd)] rounded-lg w-full max-w-lg max-h-[80vh] flex flex-col shadow-2xl">
|
| 29 |
+
<div class="flex items-center justify-between px-5 py-4 border-b border-[var(--c-bd)] flex-shrink-0">
|
| 30 |
+
<h2 class="text-[var(--c-tx)] font-semibold text-base">Settings</h2>
|
| 31 |
+
<button id="settings-close" class="text-[var(--c-tx3)] hover:text-[var(--c-tx)] transition-colors p-1 rounded" aria-label="Close settings">
|
| 32 |
+
${icon('x')}
|
| 33 |
+
</button>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="overflow-y-auto flex-1 px-5 py-4 space-y-6">
|
| 36 |
+
<!-- API Config -->
|
| 37 |
+
<section>
|
| 38 |
+
<h3 class="text-[var(--c-tx3)] font-medium text-sm mb-3 uppercase tracking-wide">API Configuration</h3>
|
| 39 |
+
<div class="space-y-3">
|
| 40 |
+
<div>
|
| 41 |
+
<label class="block text-sm text-[var(--c-tx3)] mb-1" for="settings-base-url">API Base URL</label>
|
| 42 |
+
<input id="settings-base-url" type="url" class="w-full bg-[var(--c-sf)] border border-[var(--c-bd)] rounded px-3 py-2 text-sm text-[var(--c-tx)] placeholder-[var(--c-txph)] focus:outline-none focus:border-[var(--c-bd-hi)] transition-colors" placeholder="https://api.openai.com" />
|
| 43 |
+
</div>
|
| 44 |
+
<div>
|
| 45 |
+
<label class="block text-sm text-[var(--c-tx3)] mb-1" for="settings-api-key">API Key</label>
|
| 46 |
+
<input id="settings-api-key" type="password" class="w-full bg-[var(--c-sf)] border border-[var(--c-bd)] rounded px-3 py-2 text-sm text-[var(--c-tx)] placeholder-[var(--c-txph)] focus:outline-none focus:border-[var(--c-bd-hi)] transition-colors" placeholder="sk-..." />
|
| 47 |
+
</div>
|
| 48 |
+
<div>
|
| 49 |
+
<label class="block text-sm text-[var(--c-tx3)] mb-1" for="settings-context-limit">Max Context Tokens</label>
|
| 50 |
+
<input id="settings-context-limit" type="number" min="1024" step="1024" class="w-full bg-[var(--c-sf)] border border-[var(--c-bd)] rounded px-3 py-2 text-sm text-[var(--c-tx)] placeholder-[var(--c-txph)] focus:outline-none focus:border-[var(--c-bd-hi)] transition-colors" placeholder="${DEFAULT_CONTEXT_LIMIT_TOKENS}" />
|
| 51 |
+
<p class="mt-1 text-xs text-[var(--c-tx3)]">This value is used directly for the context badge and auto-reset logic. For on-device models, set the exact limit here.</p>
|
| 52 |
+
</div>
|
| 53 |
+
<div>
|
| 54 |
+
<label class="block text-sm text-[var(--c-tx3)] mb-1" for="settings-context-threshold">Auto-reset Threshold (%)</label>
|
| 55 |
+
<input id="settings-context-threshold" type="number" min="50" max="95" step="1" class="w-full bg-[var(--c-sf)] border border-[var(--c-bd)] rounded px-3 py-2 text-sm text-[var(--c-tx)] placeholder-[var(--c-txph)] focus:outline-none focus:border-[var(--c-bd-hi)] transition-colors" placeholder="${DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT}" />
|
| 56 |
+
<p class="mt-1 text-xs text-[var(--c-tx3)]">Resets the API context before a send when estimated usage reaches this percentage of the configured window.</p>
|
| 57 |
+
</div>
|
| 58 |
+
<button id="settings-save" class="bg-[var(--c-tx)] text-[var(--c-bg)] font-medium text-sm px-4 py-2 rounded hover:opacity-90 transition-opacity">Save Settings</button>
|
| 59 |
+
</div>
|
| 60 |
+
</section>
|
| 61 |
+
|
| 62 |
+
<!-- Model Capabilities -->
|
| 63 |
+
<section>
|
| 64 |
+
<div class="flex items-center justify-between mb-3">
|
| 65 |
+
<h3 class="text-[var(--c-tx3)] font-medium text-sm uppercase tracking-wide">Model Capabilities</h3>
|
| 66 |
+
<button id="fetch-models-btn" class="flex items-center gap-1.5 text-xs text-[var(--c-tx3)] hover:text-[var(--c-tx)] border border-[var(--c-bd)] rounded px-3 py-1.5 transition-colors">
|
| 67 |
+
${icon('refresh')} Fetch Models
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
<div id="model-caps-list" class="space-y-1">
|
| 71 |
+
<!-- Populated dynamically -->
|
| 72 |
+
</div>
|
| 73 |
+
</section>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
`;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
_bindEvents() {
|
| 81 |
+
this.el.querySelector('#settings-close').addEventListener('click', () => this.hide());
|
| 82 |
+
this.el.addEventListener('mousedown', (e) => {
|
| 83 |
+
if (e.target === this.el) {
|
| 84 |
+
this._backdropMouseDown = true;
|
| 85 |
+
} else {
|
| 86 |
+
this._backdropMouseDown = false;
|
| 87 |
+
}
|
| 88 |
+
});
|
| 89 |
+
this.el.addEventListener('click', (e) => {
|
| 90 |
+
if (e.target === this.el && this._backdropMouseDown) this.hide();
|
| 91 |
+
this._backdropMouseDown = false;
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
this.el.querySelector('#settings-save').addEventListener('click', () => this._saveSettings());
|
| 95 |
+
this.el.querySelector('#fetch-models-btn').addEventListener('click', () => this._fetchModels());
|
| 96 |
+
|
| 97 |
+
// Load current values
|
| 98 |
+
this._loadValues();
|
| 99 |
+
this._renderModelCaps();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
_loadValues() {
|
| 103 |
+
const settings = store.getSettings();
|
| 104 |
+
this.el.querySelector('#settings-base-url').value = settings.baseUrl || '';
|
| 105 |
+
this.el.querySelector('#settings-api-key').value = settings.apiKey || '';
|
| 106 |
+
this.el.querySelector('#settings-context-limit').value = settings.contextLimitTokens || '';
|
| 107 |
+
this.el.querySelector('#settings-context-threshold').value =
|
| 108 |
+
settings.contextResetThresholdPercent || DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT;
|
| 109 |
+
this._fetchedModels = store.getAvailableModels(settings.baseUrl);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
_saveSettings() {
|
| 113 |
+
const previousBaseUrl = store.getSettings().baseUrl;
|
| 114 |
+
const baseUrl = this.el.querySelector('#settings-base-url').value.trim() || 'https://api.openai.com';
|
| 115 |
+
const apiKey = this.el.querySelector('#settings-api-key').value.trim();
|
| 116 |
+
const rawContextLimit = this.el.querySelector('#settings-context-limit').value.trim();
|
| 117 |
+
const contextLimitTokens = rawContextLimit
|
| 118 |
+
? Math.max(1024, Math.round(Number(rawContextLimit) || DEFAULT_CONTEXT_LIMIT_TOKENS))
|
| 119 |
+
: DEFAULT_CONTEXT_LIMIT_TOKENS;
|
| 120 |
+
const rawThreshold = Number(this.el.querySelector('#settings-context-threshold').value);
|
| 121 |
+
const contextResetThresholdPercent = Number.isFinite(rawThreshold)
|
| 122 |
+
? Math.min(95, Math.max(50, Math.round(rawThreshold)))
|
| 123 |
+
: DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT;
|
| 124 |
+
|
| 125 |
+
store.saveSettings({
|
| 126 |
+
...store.getSettings(),
|
| 127 |
+
baseUrl,
|
| 128 |
+
apiKey,
|
| 129 |
+
contextLimitTokens,
|
| 130 |
+
contextResetThresholdPercent,
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
this._fetchedModels = store.getAvailableModels(baseUrl);
|
| 134 |
+
const availableModels = store.getAvailableModels(baseUrl);
|
| 135 |
+
const currentModel = store.getCurrentModel(baseUrl);
|
| 136 |
+
if (previousBaseUrl !== baseUrl && !availableModels.length) {
|
| 137 |
+
store.setCurrentModel(baseUrl, '');
|
| 138 |
+
document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: '' } }));
|
| 139 |
+
} else if (currentModel && !availableModels.includes(currentModel)) {
|
| 140 |
+
store.setCurrentModel(baseUrl, '');
|
| 141 |
+
document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: '' } }));
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const btn = this.el.querySelector('#settings-save');
|
| 145 |
+
btn.textContent = 'Saved!';
|
| 146 |
+
setTimeout(() => { btn.textContent = 'Save Settings'; }, 2000);
|
| 147 |
+
|
| 148 |
+
document.dispatchEvent(new CustomEvent('settings:changed'));
|
| 149 |
+
document.dispatchEvent(new CustomEvent('models:changed'));
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
async _fetchModels() {
|
| 153 |
+
const btn = this.el.querySelector('#fetch-models-btn');
|
| 154 |
+
const settings = store.getSettings();
|
| 155 |
+
btn.disabled = true;
|
| 156 |
+
btn.innerHTML = `<span class="opacity-60">Fetching...</span>`;
|
| 157 |
+
try {
|
| 158 |
+
const models = await fetchModels(settings.baseUrl, settings.apiKey);
|
| 159 |
+
this._fetchedModels = models;
|
| 160 |
+
store.saveAvailableModels(settings.baseUrl, models);
|
| 161 |
+
const currentModel = store.getCurrentModel(settings.baseUrl);
|
| 162 |
+
if (currentModel && !models.includes(currentModel)) {
|
| 163 |
+
store.setCurrentModel(settings.baseUrl, '');
|
| 164 |
+
document.dispatchEvent(new CustomEvent('model:changed', { detail: { model: '' } }));
|
| 165 |
+
}
|
| 166 |
+
this._renderModelCaps(models);
|
| 167 |
+
document.dispatchEvent(new CustomEvent('models:changed'));
|
| 168 |
+
} catch (err) {
|
| 169 |
+
this.el.querySelector('#model-caps-list').innerHTML =
|
| 170 |
+
`<p class="text-sm text-red-400">Failed to fetch: ${err.message}</p>`;
|
| 171 |
+
} finally {
|
| 172 |
+
btn.disabled = false;
|
| 173 |
+
btn.innerHTML = `${icon('refresh')} Fetch Models`;
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
_renderModelCaps(extraModels = []) {
|
| 178 |
+
const container = this.el.querySelector('#model-caps-list');
|
| 179 |
+
const userCaps = store.getModelCapabilities();
|
| 180 |
+
const allModelIds = new Set(extraModels);
|
| 181 |
+
|
| 182 |
+
const rows = [...allModelIds].sort().map(modelId => {
|
| 183 |
+
const base = DEFAULT_CAPABILITIES[modelId] || { text: true, image: false, audio: false };
|
| 184 |
+
const override = userCaps[modelId] || {};
|
| 185 |
+
const caps = { ...base, ...override };
|
| 186 |
+
return `
|
| 187 |
+
<div class="flex items-center justify-between py-2 border-b border-[var(--c-bd)] gap-2" data-model="${modelId}">
|
| 188 |
+
<span class="text-sm text-[var(--c-tx2)] truncate flex-1">${modelId}</span>
|
| 189 |
+
<div class="flex items-center gap-3 flex-shrink-0">
|
| 190 |
+
<label class="flex items-center gap-1 text-xs text-[var(--c-tx3)] cursor-pointer">
|
| 191 |
+
<input type="checkbox" class="cap-check accent-current" data-cap="image" ${caps.image ? 'checked' : ''} />
|
| 192 |
+
Vision
|
| 193 |
+
</label>
|
| 194 |
+
<label class="flex items-center gap-1 text-xs text-[var(--c-tx3)] cursor-pointer">
|
| 195 |
+
<input type="checkbox" class="cap-check accent-current" data-cap="audio" ${caps.audio ? 'checked' : ''} />
|
| 196 |
+
Audio
|
| 197 |
+
</label>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
`;
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
container.innerHTML = rows.join('') || '<p class="text-sm text-[var(--c-tx3)]">No models found. Fetch models or use defaults.</p>';
|
| 204 |
+
|
| 205 |
+
// Bind changes
|
| 206 |
+
container.querySelectorAll('.cap-check').forEach(cb => {
|
| 207 |
+
cb.addEventListener('change', () => this._saveCapChange(cb));
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
_saveCapChange(checkbox) {
|
| 212 |
+
const row = checkbox.closest('[data-model]');
|
| 213 |
+
const modelId = row.dataset.model;
|
| 214 |
+
const cap = checkbox.dataset.cap;
|
| 215 |
+
const userCaps = store.getModelCapabilities();
|
| 216 |
+
if (!userCaps[modelId]) {
|
| 217 |
+
const base = DEFAULT_CAPABILITIES[modelId] || { text: true, image: false, audio: false };
|
| 218 |
+
userCaps[modelId] = { ...base };
|
| 219 |
+
}
|
| 220 |
+
userCaps[modelId][cap] = checkbox.checked;
|
| 221 |
+
store.saveModelCapabilities(store.getSettings().baseUrl, userCaps);
|
| 222 |
+
document.dispatchEvent(new CustomEvent('caps:changed'));
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
show() {
|
| 226 |
+
if (!this.el) return;
|
| 227 |
+
this._loadValues();
|
| 228 |
+
this._renderModelCaps(this._fetchedModels);
|
| 229 |
+
document.body.appendChild(this.el);
|
| 230 |
+
this.visible = true;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
hide() {
|
| 234 |
+
if (this.el && this.el.parentNode) {
|
| 235 |
+
this.el.parentNode.removeChild(this.el);
|
| 236 |
+
}
|
| 237 |
+
this.visible = false;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
toggle() {
|
| 241 |
+
this.visible ? this.hide() : this.show();
|
| 242 |
+
}
|
| 243 |
+
}
|
src/components/sidebar.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store } from '../store.js';
|
| 2 |
+
import { icon } from '../icons.js';
|
| 3 |
+
|
| 4 |
+
function relativeTime(isoString) {
|
| 5 |
+
const now = Date.now();
|
| 6 |
+
const then = new Date(isoString).getTime();
|
| 7 |
+
const diff = Math.floor((now - then) / 1000);
|
| 8 |
+
if (diff < 60) return 'just now';
|
| 9 |
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
| 10 |
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
| 11 |
+
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
| 12 |
+
return new Date(isoString).toLocaleDateString();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export class Sidebar {
|
| 16 |
+
constructor() {
|
| 17 |
+
this.el = null;
|
| 18 |
+
this._mobileOpen = false;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
render() {
|
| 22 |
+
const wrapper = document.createElement('div');
|
| 23 |
+
wrapper.className = 'relative';
|
| 24 |
+
wrapper.innerHTML = this._template();
|
| 25 |
+
this.el = wrapper.firstElementChild;
|
| 26 |
+
this._bindEvents();
|
| 27 |
+
return this.el;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
_template() {
|
| 31 |
+
const convs = store.getConversations();
|
| 32 |
+
const currentId = store.getCurrentConversationId();
|
| 33 |
+
|
| 34 |
+
const items = convs.map(conv => {
|
| 35 |
+
const active = conv.id === currentId;
|
| 36 |
+
return `
|
| 37 |
+
<div class="group relative flex items-center gap-2 rounded-xl cursor-pointer border px-3 py-2.5 transition-all ${active ? 'border-[var(--c-side-act-bd)] bg-[var(--c-side-act)]' : 'border-transparent hover:border-[var(--c-side-bd)] hover:bg-[var(--c-side-el)]'}" data-conv-id="${conv.id}" role="option" aria-selected="${active}">
|
| 38 |
+
<div class="flex-1 min-w-0">
|
| 39 |
+
<div class="text-[13px] truncate ${active ? 'font-medium text-[var(--c-side-tx)]' : 'text-[var(--c-side-tx2)]'}">${escapeHtml(conv.title || 'New Chat')}</div>
|
| 40 |
+
<div class="mt-0.5 truncate text-[11px] text-[var(--c-side-tx3)]">${relativeTime(conv.updatedAt || conv.createdAt)}</div>
|
| 41 |
+
</div>
|
| 42 |
+
<button class="delete-conv flex-shrink-0 p-1 rounded-md text-[var(--c-side-tx3)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--c-side-el-h)] hover:text-[var(--c-side-tx)]" data-conv-id="${conv.id}" aria-label="Delete conversation">
|
| 43 |
+
${icon('trash')}
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
`;
|
| 47 |
+
}).join('');
|
| 48 |
+
|
| 49 |
+
return `
|
| 50 |
+
<aside id="sidebar" class="sidebar-transition flex h-full w-56 flex-col border-r border-[var(--c-side-bd)] bg-[var(--c-side)] flex-shrink-0">
|
| 51 |
+
<div class="px-3 pt-3 pb-2">
|
| 52 |
+
<button id="new-chat-btn" class="flex w-full items-center justify-center gap-1.5 rounded-xl border border-[var(--c-side-bd)] bg-[var(--c-side-el)] px-3 py-2.5 text-[12px] text-[var(--c-side-tx2)] transition-all hover:bg-[var(--c-side-el-h)] hover:text-[var(--c-side-tx)] hover:border-[var(--c-side-act-bd)]" aria-label="New chat">
|
| 53 |
+
${icon('plus')} <span>New Chat</span>
|
| 54 |
+
</button>
|
| 55 |
+
</div>
|
| 56 |
+
<div id="conv-list" class="flex-1 overflow-y-auto pb-2 px-2 space-y-px" role="listbox" aria-label="Conversations">
|
| 57 |
+
${items || '<div class="px-4 py-10 text-center text-[11px] leading-relaxed text-[var(--c-side-tx3)]">No conversations yet.<br>Start a new chat!</div>'}
|
| 58 |
+
</div>
|
| 59 |
+
<div class="border-t border-[var(--c-side-bd)] px-3 py-3">
|
| 60 |
+
<div class="flex items-center gap-2 rounded-xl px-1.5 py-1">
|
| 61 |
+
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg border border-[var(--c-side-bd)] bg-[var(--c-side-el)]">
|
| 62 |
+
<img src="/logo.svg" alt="" class="h-4 w-4 object-contain" aria-hidden="true" />
|
| 63 |
+
</div>
|
| 64 |
+
<div class="min-w-0">
|
| 65 |
+
<div class="truncate text-[10px] font-semibold uppercase tracking-[0.16em] text-[var(--c-side-tx2)]">AXERA</div>
|
| 66 |
+
<div class="truncate text-[10px] text-[var(--c-side-tx3)]">Lite WebUI</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</aside>
|
| 71 |
+
`;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
_bindEvents() {
|
| 75 |
+
this.el.querySelector('#new-chat-btn').addEventListener('click', () => {
|
| 76 |
+
document.dispatchEvent(new CustomEvent('sidebar:newchat'));
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
this.el.querySelector('#conv-list').addEventListener('click', (e) => {
|
| 80 |
+
const delBtn = e.target.closest('.delete-conv');
|
| 81 |
+
if (delBtn) {
|
| 82 |
+
e.stopPropagation();
|
| 83 |
+
const convId = delBtn.dataset.convId;
|
| 84 |
+
if (confirm('Delete this conversation?')) {
|
| 85 |
+
store.deleteConversation(convId);
|
| 86 |
+
document.dispatchEvent(new CustomEvent('sidebar:deleted', { detail: { convId } }));
|
| 87 |
+
this.update();
|
| 88 |
+
}
|
| 89 |
+
return;
|
| 90 |
+
}
|
| 91 |
+
const item = e.target.closest('[data-conv-id]');
|
| 92 |
+
if (item && !item.classList.contains('delete-conv')) {
|
| 93 |
+
const convId = item.dataset.convId;
|
| 94 |
+
document.dispatchEvent(new CustomEvent('sidebar:select', { detail: { convId } }));
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
update() {
|
| 100 |
+
const list = this.el.querySelector('#conv-list');
|
| 101 |
+
const convs = store.getConversations();
|
| 102 |
+
const currentId = store.getCurrentConversationId();
|
| 103 |
+
|
| 104 |
+
if (convs.length === 0) {
|
| 105 |
+
list.innerHTML = '<div class="px-4 py-10 text-center text-[11px] leading-relaxed text-[var(--c-side-tx3)]">No conversations yet.<br>Start a new chat!</div>';
|
| 106 |
+
return;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const items = convs.map(conv => {
|
| 110 |
+
const active = conv.id === currentId;
|
| 111 |
+
return `
|
| 112 |
+
<div class="group relative flex items-center gap-2 rounded-xl cursor-pointer border px-3 py-2.5 transition-all ${active ? 'border-[var(--c-side-act-bd)] bg-[var(--c-side-act)]' : 'border-transparent hover:border-[var(--c-side-bd)] hover:bg-[var(--c-side-el)]'}" data-conv-id="${conv.id}" role="option" aria-selected="${active}">
|
| 113 |
+
<div class="flex-1 min-w-0">
|
| 114 |
+
<div class="text-[13px] truncate ${active ? 'font-medium text-[var(--c-side-tx)]' : 'text-[var(--c-side-tx2)]'}">${escapeHtml(conv.title || 'New Chat')}</div>
|
| 115 |
+
<div class="mt-0.5 truncate text-[11px] text-[var(--c-side-tx3)]">${relativeTime(conv.updatedAt || conv.createdAt)}</div>
|
| 116 |
+
</div>
|
| 117 |
+
<button class="delete-conv flex-shrink-0 p-1 rounded-md text-[var(--c-side-tx3)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--c-side-el-h)] hover:text-[var(--c-side-tx)]" data-conv-id="${conv.id}" aria-label="Delete conversation">
|
| 118 |
+
${icon('trash')}
|
| 119 |
+
</button>
|
| 120 |
+
</div>
|
| 121 |
+
`;
|
| 122 |
+
}).join('');
|
| 123 |
+
|
| 124 |
+
list.innerHTML = items;
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function escapeHtml(str) {
|
| 129 |
+
return String(str)
|
| 130 |
+
.replace(/&/g, '&')
|
| 131 |
+
.replace(/</g, '<')
|
| 132 |
+
.replace(/>/g, '>')
|
| 133 |
+
.replace(/"/g, '"');
|
| 134 |
+
}
|
src/context.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { estimateMessageTokens } from './api.js';
|
| 2 |
+
|
| 3 |
+
export const DEFAULT_CONTEXT_LIMIT_TOKENS = 4096;
|
| 4 |
+
export const DEFAULT_CONTEXT_WARN_PERCENT = 80;
|
| 5 |
+
export const DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT = 85;
|
| 6 |
+
export const DEFAULT_CONTEXT_RESPONSE_RESERVE = 2048;
|
| 7 |
+
export const DEFAULT_CONTEXT_RESPONSE_RESERVE_RATIO = 0.2;
|
| 8 |
+
|
| 9 |
+
function toPositiveInteger(value) {
|
| 10 |
+
const parsed = Number(value);
|
| 11 |
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
| 12 |
+
return Math.round(parsed);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function clamp(value, min, max) {
|
| 16 |
+
return Math.min(max, Math.max(min, value));
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function resolveContextConfig(settings = {}, modelId = '') {
|
| 20 |
+
const configuredMaxTokens = toPositiveInteger(settings.contextLimitTokens);
|
| 21 |
+
const maxTokens = configuredMaxTokens || DEFAULT_CONTEXT_LIMIT_TOKENS;
|
| 22 |
+
|
| 23 |
+
const resetPercent = clamp(
|
| 24 |
+
toPositiveInteger(settings.contextResetThresholdPercent) || DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT,
|
| 25 |
+
50,
|
| 26 |
+
95
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
const percentThreshold = Math.max(1, Math.floor(maxTokens * (resetPercent / 100)));
|
| 30 |
+
const responseReserve = Math.max(128, Math.min(
|
| 31 |
+
DEFAULT_CONTEXT_RESPONSE_RESERVE,
|
| 32 |
+
Math.floor(maxTokens * DEFAULT_CONTEXT_RESPONSE_RESERVE_RATIO)
|
| 33 |
+
));
|
| 34 |
+
const reserveThreshold = maxTokens > responseReserve
|
| 35 |
+
? maxTokens - responseReserve
|
| 36 |
+
: maxTokens;
|
| 37 |
+
const resetTokens = Math.max(1, Math.min(percentThreshold, reserveThreshold));
|
| 38 |
+
const warnTokens = Math.min(
|
| 39 |
+
Math.max(1, Math.floor(maxTokens * (DEFAULT_CONTEXT_WARN_PERCENT / 100))),
|
| 40 |
+
resetTokens
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
return {
|
| 44 |
+
maxTokens,
|
| 45 |
+
warnTokens,
|
| 46 |
+
resetTokens,
|
| 47 |
+
resetPercent,
|
| 48 |
+
source: configuredMaxTokens ? 'manual' : 'default',
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export function estimateThreadTokens(messages = [], pendingMessage = null) {
|
| 53 |
+
const baseMessages = Array.isArray(messages) ? messages : [];
|
| 54 |
+
const thread = pendingMessage ? [...baseMessages, pendingMessage] : baseMessages;
|
| 55 |
+
return estimateMessageTokens(thread);
|
| 56 |
+
}
|
src/icons.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// SVG Icons (Lucide-style inline SVGs)
|
| 2 |
+
export const icons = {
|
| 3 |
+
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
| 4 |
+
|
| 5 |
+
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
| 6 |
+
|
| 7 |
+
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
|
| 8 |
+
|
| 9 |
+
send: `<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><defs><linearGradient id="send-gradient" x1="3" y1="18.5" x2="21" y2="4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#38bdf8"/><stop offset="0.33" stop-color="#34d399"/><stop offset="0.66" stop-color="#f59e0b"/><stop offset="1" stop-color="#f43f5e"/></linearGradient></defs><path d="M21.5 3.5 2.8 10.7c-.7.3-.7 1.3 0 1.6l5.9 2.2 2.2 5.9c.3.7 1.3.7 1.6 0L21.5 3.5Z" stroke="url(#send-gradient)"/><path d="M8.7 14.5 21.5 3.5" stroke="url(#send-gradient)"/><path d="M11 20.4 14.8 10" stroke="url(#send-gradient)"/></svg>`,
|
| 10 |
+
|
| 11 |
+
sendMuted: `<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.5 3.5 2.8 10.7c-.7.3-.7 1.3 0 1.6l5.9 2.2 2.2 5.9c.3.7 1.3.7 1.6 0L21.5 3.5Z"/><path d="M8.7 14.5 21.5 3.5"/><path d="M11 20.4 14.8 10"/></svg>`,
|
| 12 |
+
|
| 13 |
+
image: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
| 14 |
+
|
| 15 |
+
audio: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6v12"/><path d="M8 8v8"/><path d="M16 9v6"/><path d="M4 11v2"/><path d="M20 10v4"/></svg>`,
|
| 16 |
+
|
| 17 |
+
video: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>`,
|
| 18 |
+
|
| 19 |
+
x: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
|
| 20 |
+
|
| 21 |
+
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
|
| 22 |
+
|
| 23 |
+
menu: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`,
|
| 24 |
+
|
| 25 |
+
bot: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>`,
|
| 26 |
+
|
| 27 |
+
user: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
|
| 28 |
+
|
| 29 |
+
refresh: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>`,
|
| 30 |
+
|
| 31 |
+
sun: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
|
| 32 |
+
|
| 33 |
+
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export function icon(name, extraClasses = '') {
|
| 37 |
+
const svg = icons[name];
|
| 38 |
+
if (!svg) return '';
|
| 39 |
+
if (extraClasses) {
|
| 40 |
+
return svg.replace('<svg ', `<svg class="${extraClasses}" `);
|
| 41 |
+
}
|
| 42 |
+
return svg;
|
| 43 |
+
}
|
src/main.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './style.css';
|
| 2 |
+
import { App } from './components/app.js';
|
| 3 |
+
|
| 4 |
+
const app = new App(document.getElementById('app'));
|
| 5 |
+
app.init();
|
src/markdown.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { marked } from 'marked';
|
| 2 |
+
import hljs from 'highlight.js/lib/core';
|
| 3 |
+
|
| 4 |
+
// Register common languages only to keep bundle size small
|
| 5 |
+
import langBash from 'highlight.js/lib/languages/bash';
|
| 6 |
+
import langC from 'highlight.js/lib/languages/c';
|
| 7 |
+
import langCpp from 'highlight.js/lib/languages/cpp';
|
| 8 |
+
import langCss from 'highlight.js/lib/languages/css';
|
| 9 |
+
import langDiff from 'highlight.js/lib/languages/diff';
|
| 10 |
+
import langGo from 'highlight.js/lib/languages/go';
|
| 11 |
+
import langGraphql from 'highlight.js/lib/languages/graphql';
|
| 12 |
+
import langIni from 'highlight.js/lib/languages/ini';
|
| 13 |
+
import langJava from 'highlight.js/lib/languages/java';
|
| 14 |
+
import langJs from 'highlight.js/lib/languages/javascript';
|
| 15 |
+
import langJson from 'highlight.js/lib/languages/json';
|
| 16 |
+
import langKotlin from 'highlight.js/lib/languages/kotlin';
|
| 17 |
+
import langMarkdown from 'highlight.js/lib/languages/markdown';
|
| 18 |
+
import langPhp from 'highlight.js/lib/languages/php';
|
| 19 |
+
import langPython from 'highlight.js/lib/languages/python';
|
| 20 |
+
import langRuby from 'highlight.js/lib/languages/ruby';
|
| 21 |
+
import langRust from 'highlight.js/lib/languages/rust';
|
| 22 |
+
import langScss from 'highlight.js/lib/languages/scss';
|
| 23 |
+
import langShell from 'highlight.js/lib/languages/shell';
|
| 24 |
+
import langSql from 'highlight.js/lib/languages/sql';
|
| 25 |
+
import langSwift from 'highlight.js/lib/languages/swift';
|
| 26 |
+
import langTs from 'highlight.js/lib/languages/typescript';
|
| 27 |
+
import langXml from 'highlight.js/lib/languages/xml';
|
| 28 |
+
import langYaml from 'highlight.js/lib/languages/yaml';
|
| 29 |
+
|
| 30 |
+
hljs.registerLanguage('bash', langBash);
|
| 31 |
+
hljs.registerLanguage('c', langC);
|
| 32 |
+
hljs.registerLanguage('cpp', langCpp);
|
| 33 |
+
hljs.registerLanguage('css', langCss);
|
| 34 |
+
hljs.registerLanguage('diff', langDiff);
|
| 35 |
+
hljs.registerLanguage('go', langGo);
|
| 36 |
+
hljs.registerLanguage('graphql', langGraphql);
|
| 37 |
+
hljs.registerLanguage('ini', langIni);
|
| 38 |
+
hljs.registerLanguage('java', langJava);
|
| 39 |
+
hljs.registerLanguage('javascript', langJs);
|
| 40 |
+
hljs.registerLanguage('js', langJs);
|
| 41 |
+
hljs.registerLanguage('json', langJson);
|
| 42 |
+
hljs.registerLanguage('kotlin', langKotlin);
|
| 43 |
+
hljs.registerLanguage('markdown', langMarkdown);
|
| 44 |
+
hljs.registerLanguage('php', langPhp);
|
| 45 |
+
hljs.registerLanguage('python', langPython);
|
| 46 |
+
hljs.registerLanguage('py', langPython);
|
| 47 |
+
hljs.registerLanguage('ruby', langRuby);
|
| 48 |
+
hljs.registerLanguage('rust', langRust);
|
| 49 |
+
hljs.registerLanguage('scss', langScss);
|
| 50 |
+
hljs.registerLanguage('shell', langShell);
|
| 51 |
+
hljs.registerLanguage('sh', langShell);
|
| 52 |
+
hljs.registerLanguage('sql', langSql);
|
| 53 |
+
hljs.registerLanguage('swift', langSwift);
|
| 54 |
+
hljs.registerLanguage('typescript', langTs);
|
| 55 |
+
hljs.registerLanguage('ts', langTs);
|
| 56 |
+
hljs.registerLanguage('xml', langXml);
|
| 57 |
+
hljs.registerLanguage('html', langXml);
|
| 58 |
+
hljs.registerLanguage('yaml', langYaml);
|
| 59 |
+
hljs.registerLanguage('yml', langYaml);
|
| 60 |
+
|
| 61 |
+
const renderer = new marked.Renderer();
|
| 62 |
+
|
| 63 |
+
renderer.code = function (token) {
|
| 64 |
+
const code = token.text || '';
|
| 65 |
+
const lang = (token.lang || '').split(/[\s.]/)[0].toLowerCase();
|
| 66 |
+
let highlighted;
|
| 67 |
+
try {
|
| 68 |
+
if (lang && hljs.getLanguage(lang)) {
|
| 69 |
+
highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
| 70 |
+
} else {
|
| 71 |
+
highlighted = hljs.highlightAuto(code).value;
|
| 72 |
+
}
|
| 73 |
+
} catch {
|
| 74 |
+
highlighted = escapeHtml(code);
|
| 75 |
+
}
|
| 76 |
+
const langLabel = lang ? `<span class="code-lang-label">${lang}</span>` : '';
|
| 77 |
+
return `<div class="code-block-wrapper">${langLabel}<button class="copy-btn" aria-label="Copy code">Copy</button><pre><code class="hljs language-${lang || 'plaintext'}">${highlighted}</code></pre></div>`;
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
renderer.codespan = function (token) {
|
| 81 |
+
const text = token.text || '';
|
| 82 |
+
return `<code>${escapeHtml(text)}</code>`;
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
function escapeHtml(str) {
|
| 86 |
+
return str
|
| 87 |
+
.replace(/&/g, '&')
|
| 88 |
+
.replace(/</g, '<')
|
| 89 |
+
.replace(/>/g, '>')
|
| 90 |
+
.replace(/"/g, '"')
|
| 91 |
+
.replace(/'/g, ''');
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
marked.setOptions({
|
| 95 |
+
renderer,
|
| 96 |
+
gfm: true,
|
| 97 |
+
breaks: true,
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
export function renderMarkdown(text) {
|
| 101 |
+
if (!text) return '';
|
| 102 |
+
try {
|
| 103 |
+
return marked.parse(String(text));
|
| 104 |
+
} catch {
|
| 105 |
+
return escapeHtml(String(text));
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export function attachCopyButtons(container) {
|
| 110 |
+
container.querySelectorAll('.copy-btn').forEach(btn => {
|
| 111 |
+
btn.addEventListener('click', async () => {
|
| 112 |
+
const pre = btn.nextElementSibling;
|
| 113 |
+
const code = pre?.querySelector('code')?.textContent || '';
|
| 114 |
+
try {
|
| 115 |
+
await navigator.clipboard.writeText(code);
|
| 116 |
+
btn.textContent = 'Copied!';
|
| 117 |
+
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
|
| 118 |
+
} catch {
|
| 119 |
+
btn.textContent = 'Error';
|
| 120 |
+
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
|
| 121 |
+
}
|
| 122 |
+
});
|
| 123 |
+
});
|
| 124 |
+
}
|
src/store.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// LocalStorage keys
|
| 2 |
+
const KEYS = {
|
| 3 |
+
SETTINGS: 'lw_settings',
|
| 4 |
+
CONVERSATIONS: 'lw_conversations',
|
| 5 |
+
CURRENT_CONV: 'lw_current_conv',
|
| 6 |
+
MODEL_CAPS: 'lw_model_caps',
|
| 7 |
+
AVAILABLE_MODELS: 'lw_available_models',
|
| 8 |
+
MODEL_SELECTIONS: 'lw_model_selections',
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const DEFAULT_SETTINGS = {
|
| 12 |
+
apiKey: '',
|
| 13 |
+
baseUrl: 'http://127.0.0.1:8000',
|
| 14 |
+
theme: 'dark',
|
| 15 |
+
contextLimitTokens: 4096,
|
| 16 |
+
contextResetThresholdPercent: 85,
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export function normalizeBaseUrl(baseUrl) {
|
| 20 |
+
const raw = String(baseUrl || '').trim();
|
| 21 |
+
if (!raw) return DEFAULT_SETTINGS.baseUrl;
|
| 22 |
+
return raw.replace(/\/+$/, '');
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function isCapabilityRecord(value) {
|
| 26 |
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
| 27 |
+
return ['text', 'image', 'audio'].some((key) => key in value);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function isLegacyCapabilityMap(value) {
|
| 31 |
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
| 32 |
+
const entries = Object.values(value);
|
| 33 |
+
return entries.length > 0 && entries.every(isCapabilityRecord);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function normalizeSettings(settings = {}) {
|
| 37 |
+
const merged = { ...DEFAULT_SETTINGS, ...settings };
|
| 38 |
+
const contextLimitTokens = Number(merged.contextLimitTokens);
|
| 39 |
+
const contextResetThresholdPercent = Number(merged.contextResetThresholdPercent);
|
| 40 |
+
|
| 41 |
+
merged.baseUrl = normalizeBaseUrl(merged.baseUrl);
|
| 42 |
+
merged.contextLimitTokens = Number.isFinite(contextLimitTokens) && contextLimitTokens >= 1024
|
| 43 |
+
? Math.round(contextLimitTokens)
|
| 44 |
+
: DEFAULT_SETTINGS.contextLimitTokens;
|
| 45 |
+
|
| 46 |
+
merged.contextResetThresholdPercent = Number.isFinite(contextResetThresholdPercent)
|
| 47 |
+
? Math.min(95, Math.max(50, Math.round(contextResetThresholdPercent)))
|
| 48 |
+
: DEFAULT_SETTINGS.contextResetThresholdPercent;
|
| 49 |
+
|
| 50 |
+
return merged;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function load(key, fallback) {
|
| 54 |
+
try {
|
| 55 |
+
const raw = localStorage.getItem(key);
|
| 56 |
+
return raw ? JSON.parse(raw) : fallback;
|
| 57 |
+
} catch {
|
| 58 |
+
return fallback;
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function save(key, value) {
|
| 63 |
+
localStorage.setItem(key, JSON.stringify(value));
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function uuid() {
|
| 67 |
+
return crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export const store = {
|
| 71 |
+
getSettings() {
|
| 72 |
+
return normalizeSettings(load(KEYS.SETTINGS, {}));
|
| 73 |
+
},
|
| 74 |
+
|
| 75 |
+
saveSettings(settings) {
|
| 76 |
+
save(KEYS.SETTINGS, normalizeSettings(settings));
|
| 77 |
+
},
|
| 78 |
+
|
| 79 |
+
getConversations() {
|
| 80 |
+
return load(KEYS.CONVERSATIONS, []);
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
saveConversations(conversations) {
|
| 84 |
+
save(KEYS.CONVERSATIONS, conversations);
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
getCurrentConversationId() {
|
| 88 |
+
return localStorage.getItem(KEYS.CURRENT_CONV) || null;
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
setCurrentConversationId(id) {
|
| 92 |
+
if (id) {
|
| 93 |
+
localStorage.setItem(KEYS.CURRENT_CONV, id);
|
| 94 |
+
} else {
|
| 95 |
+
localStorage.removeItem(KEYS.CURRENT_CONV);
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
getCurrentConversation() {
|
| 100 |
+
const id = this.getCurrentConversationId();
|
| 101 |
+
if (!id) return null;
|
| 102 |
+
const convs = this.getConversations();
|
| 103 |
+
return convs.find(c => c.id === id) || null;
|
| 104 |
+
},
|
| 105 |
+
|
| 106 |
+
getAvailableModels(baseUrl = this.getSettings().baseUrl) {
|
| 107 |
+
const catalogs = load(KEYS.AVAILABLE_MODELS, {});
|
| 108 |
+
const list = catalogs[normalizeBaseUrl(baseUrl)];
|
| 109 |
+
return Array.isArray(list) ? [...new Set(list.filter(Boolean))].sort() : [];
|
| 110 |
+
},
|
| 111 |
+
|
| 112 |
+
saveAvailableModels(baseUrl, models) {
|
| 113 |
+
const catalogs = load(KEYS.AVAILABLE_MODELS, {});
|
| 114 |
+
catalogs[normalizeBaseUrl(baseUrl)] = Array.isArray(models)
|
| 115 |
+
? [...new Set(models.filter(Boolean))].sort()
|
| 116 |
+
: [];
|
| 117 |
+
save(KEYS.AVAILABLE_MODELS, catalogs);
|
| 118 |
+
},
|
| 119 |
+
|
| 120 |
+
getCurrentModel(baseUrl = this.getSettings().baseUrl) {
|
| 121 |
+
const selections = load(KEYS.MODEL_SELECTIONS, {});
|
| 122 |
+
const selected = selections[normalizeBaseUrl(baseUrl)];
|
| 123 |
+
return typeof selected === 'string' ? selected : '';
|
| 124 |
+
},
|
| 125 |
+
|
| 126 |
+
setCurrentModel(baseUrl, modelId) {
|
| 127 |
+
const selections = load(KEYS.MODEL_SELECTIONS, {});
|
| 128 |
+
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
| 129 |
+
if (modelId) {
|
| 130 |
+
selections[normalizedUrl] = modelId;
|
| 131 |
+
} else {
|
| 132 |
+
delete selections[normalizedUrl];
|
| 133 |
+
}
|
| 134 |
+
save(KEYS.MODEL_SELECTIONS, selections);
|
| 135 |
+
},
|
| 136 |
+
|
| 137 |
+
getModelCapabilities(baseUrl = this.getSettings().baseUrl) {
|
| 138 |
+
const raw = load(KEYS.MODEL_CAPS, {});
|
| 139 |
+
if (isLegacyCapabilityMap(raw)) return raw;
|
| 140 |
+
|
| 141 |
+
const caps = raw[normalizeBaseUrl(baseUrl)];
|
| 142 |
+
return caps && typeof caps === 'object' && !Array.isArray(caps) ? caps : {};
|
| 143 |
+
},
|
| 144 |
+
|
| 145 |
+
saveModelCapabilities(baseUrlOrCaps, maybeCaps) {
|
| 146 |
+
if (maybeCaps === undefined) {
|
| 147 |
+
save(KEYS.MODEL_CAPS, baseUrlOrCaps);
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const raw = load(KEYS.MODEL_CAPS, {});
|
| 152 |
+
const nested = isLegacyCapabilityMap(raw) ? {} : raw;
|
| 153 |
+
nested[normalizeBaseUrl(baseUrlOrCaps)] = maybeCaps;
|
| 154 |
+
save(KEYS.MODEL_CAPS, nested);
|
| 155 |
+
},
|
| 156 |
+
|
| 157 |
+
createConversation(model) {
|
| 158 |
+
const conv = {
|
| 159 |
+
id: uuid(),
|
| 160 |
+
title: 'New Chat',
|
| 161 |
+
model: model || '',
|
| 162 |
+
messages: [],
|
| 163 |
+
createdAt: new Date().toISOString(),
|
| 164 |
+
updatedAt: new Date().toISOString(),
|
| 165 |
+
};
|
| 166 |
+
const convs = this.getConversations();
|
| 167 |
+
convs.unshift(conv);
|
| 168 |
+
this.saveConversations(convs);
|
| 169 |
+
return conv;
|
| 170 |
+
},
|
| 171 |
+
|
| 172 |
+
addMessage(convId, message) {
|
| 173 |
+
const convs = this.getConversations();
|
| 174 |
+
const idx = convs.findIndex(c => c.id === convId);
|
| 175 |
+
if (idx === -1) return;
|
| 176 |
+
convs[idx].messages.push(message);
|
| 177 |
+
convs[idx].updatedAt = new Date().toISOString();
|
| 178 |
+
this.saveConversations(convs);
|
| 179 |
+
},
|
| 180 |
+
|
| 181 |
+
updateLastAssistantMessage(convId, content) {
|
| 182 |
+
const convs = this.getConversations();
|
| 183 |
+
const idx = convs.findIndex(c => c.id === convId);
|
| 184 |
+
if (idx === -1) return;
|
| 185 |
+
const msgs = convs[idx].messages;
|
| 186 |
+
// Find last assistant message
|
| 187 |
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
| 188 |
+
if (msgs[i].role === 'assistant') {
|
| 189 |
+
msgs[i].content = content;
|
| 190 |
+
msgs[i].timestamp = new Date().toISOString();
|
| 191 |
+
break;
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
convs[idx].updatedAt = new Date().toISOString();
|
| 195 |
+
this.saveConversations(convs);
|
| 196 |
+
},
|
| 197 |
+
|
| 198 |
+
clearMessages(convId) {
|
| 199 |
+
const convs = this.getConversations();
|
| 200 |
+
const idx = convs.findIndex(c => c.id === convId);
|
| 201 |
+
if (idx === -1) return;
|
| 202 |
+
convs[idx].messages = [];
|
| 203 |
+
convs[idx].updatedAt = new Date().toISOString();
|
| 204 |
+
this.saveConversations(convs);
|
| 205 |
+
},
|
| 206 |
+
|
| 207 |
+
deleteConversation(convId) {
|
| 208 |
+
let convs = this.getConversations();
|
| 209 |
+
convs = convs.filter(c => c.id !== convId);
|
| 210 |
+
this.saveConversations(convs);
|
| 211 |
+
if (this.getCurrentConversationId() === convId) {
|
| 212 |
+
this.setCurrentConversationId(convs[0]?.id || null);
|
| 213 |
+
}
|
| 214 |
+
},
|
| 215 |
+
|
| 216 |
+
updateConversationTitle(convId, title) {
|
| 217 |
+
const convs = this.getConversations();
|
| 218 |
+
const idx = convs.findIndex(c => c.id === convId);
|
| 219 |
+
if (idx === -1) return;
|
| 220 |
+
convs[idx].title = title;
|
| 221 |
+
this.saveConversations(convs);
|
| 222 |
+
},
|
| 223 |
+
|
| 224 |
+
updateConversationModel(convId, model) {
|
| 225 |
+
const convs = this.getConversations();
|
| 226 |
+
const idx = convs.findIndex(c => c.id === convId);
|
| 227 |
+
if (idx === -1) return;
|
| 228 |
+
convs[idx].model = model;
|
| 229 |
+
this.saveConversations(convs);
|
| 230 |
+
},
|
| 231 |
+
};
|
src/style.css
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* ── Color tokens ── */
|
| 6 |
+
:root {
|
| 7 |
+
/* Light theme (default) */
|
| 8 |
+
--c-bg: #f5f6f8;
|
| 9 |
+
--c-sf: #ecedf1;
|
| 10 |
+
--c-card: #ffffff;
|
| 11 |
+
--c-top: #f8fbff;
|
| 12 |
+
--c-top-bd: rgba(148,163,184,0.18);
|
| 13 |
+
--c-top-el: rgba(255,255,255,0.76);
|
| 14 |
+
--c-top-el-h: #ffffff;
|
| 15 |
+
--c-side: #f1f5fb;
|
| 16 |
+
--c-side-bd: rgba(148,163,184,0.22);
|
| 17 |
+
--c-side-el: rgba(255,255,255,0.72);
|
| 18 |
+
--c-side-el-h: rgba(255,255,255,0.92);
|
| 19 |
+
--c-side-act: #ffffff;
|
| 20 |
+
--c-side-act-bd: rgba(96,165,250,0.22);
|
| 21 |
+
--c-side-tx: #334155;
|
| 22 |
+
--c-side-tx2: rgba(51,65,85,0.78);
|
| 23 |
+
--c-side-tx3: rgba(51,65,85,0.52);
|
| 24 |
+
--c-bd: rgba(0,0,0,0.09);
|
| 25 |
+
--c-bd-hi: rgba(0,0,0,0.22);
|
| 26 |
+
--c-tx: #121212;
|
| 27 |
+
--c-tx2: #3d3d3d;
|
| 28 |
+
--c-tx3: rgba(0,0,0,0.38);
|
| 29 |
+
--c-txph: rgba(0,0,0,0.32);
|
| 30 |
+
--c-hi: rgba(0,0,0,0.08);
|
| 31 |
+
--c-ho: rgba(0,0,0,0.04);
|
| 32 |
+
--c-ub: #dbeafe;
|
| 33 |
+
--c-ub-bd: #bfdbfe;
|
| 34 |
+
--c-utx: #1d4ed8;
|
| 35 |
+
--c-code: rgba(0,0,0,0.06);
|
| 36 |
+
--c-scrl: rgba(0,0,0,0.15);
|
| 37 |
+
--c-scrl-hv: rgba(0,0,0,0.28);
|
| 38 |
+
--c-prose: #2d2d2d;
|
| 39 |
+
--c-prose-h: #0f0f0f;
|
| 40 |
+
--c-prose-a: #2563eb;
|
| 41 |
+
--c-prose-ah: #1d4ed8;
|
| 42 |
+
--c-prose-cd: #1a3a72;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.dark {
|
| 46 |
+
--c-bg: #0b0b0b;
|
| 47 |
+
--c-sf: #0a0a0a;
|
| 48 |
+
--c-card: #141414;
|
| 49 |
+
--c-top: #10161f;
|
| 50 |
+
--c-top-bd: rgba(148,163,184,0.14);
|
| 51 |
+
--c-top-el: rgba(255,255,255,0.06);
|
| 52 |
+
--c-top-el-h: rgba(255,255,255,0.10);
|
| 53 |
+
--c-side: #131922;
|
| 54 |
+
--c-side-bd: rgba(148,163,184,0.16);
|
| 55 |
+
--c-side-el: rgba(255,255,255,0.05);
|
| 56 |
+
--c-side-el-h: rgba(255,255,255,0.08);
|
| 57 |
+
--c-side-act: rgba(59,130,246,0.16);
|
| 58 |
+
--c-side-act-bd: rgba(96,165,250,0.24);
|
| 59 |
+
--c-side-tx: #e5ecf6;
|
| 60 |
+
--c-side-tx2: rgba(226,232,240,0.78);
|
| 61 |
+
--c-side-tx3: rgba(226,232,240,0.48);
|
| 62 |
+
--c-bd: rgba(255,255,255,0.07);
|
| 63 |
+
--c-bd-hi: rgba(255,255,255,0.18);
|
| 64 |
+
--c-tx: #e2e2e2;
|
| 65 |
+
--c-tx2: rgba(255,255,255,0.75);
|
| 66 |
+
--c-tx3: rgba(255,255,255,0.28);
|
| 67 |
+
--c-txph: rgba(255,255,255,0.22);
|
| 68 |
+
--c-hi: rgba(255,255,255,0.07);
|
| 69 |
+
--c-ho: rgba(255,255,255,0.04);
|
| 70 |
+
--c-ub: rgba(79,142,247,0.13);
|
| 71 |
+
--c-ub-bd: rgba(79,142,247,0.22);
|
| 72 |
+
--c-utx: rgba(255,255,255,0.90);
|
| 73 |
+
--c-code: #0e0e0e;
|
| 74 |
+
--c-scrl: rgba(255,255,255,0.12);
|
| 75 |
+
--c-scrl-hv: rgba(255,255,255,0.22);
|
| 76 |
+
--c-prose: #d8d8d8;
|
| 77 |
+
--c-prose-h: #ffffff;
|
| 78 |
+
--c-prose-a: #7dd3fc;
|
| 79 |
+
--c-prose-ah: #bae6fd;
|
| 80 |
+
--c-prose-cd: #e2e8f0;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
* {
|
| 84 |
+
box-sizing: border-box;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
html, body, #app {
|
| 88 |
+
height: 100%;
|
| 89 |
+
margin: 0;
|
| 90 |
+
padding: 0;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
body {
|
| 94 |
+
background: var(--c-bg);
|
| 95 |
+
color: var(--c-tx);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* Custom scrollbar */
|
| 99 |
+
::-webkit-scrollbar {
|
| 100 |
+
width: 5px;
|
| 101 |
+
height: 5px;
|
| 102 |
+
}
|
| 103 |
+
::-webkit-scrollbar-track {
|
| 104 |
+
background: transparent;
|
| 105 |
+
}
|
| 106 |
+
::-webkit-scrollbar-thumb {
|
| 107 |
+
background: var(--c-scrl);
|
| 108 |
+
border-radius: 10px;
|
| 109 |
+
}
|
| 110 |
+
::-webkit-scrollbar-thumb:hover {
|
| 111 |
+
background: var(--c-scrl-hv);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* User message bubble */
|
| 115 |
+
.user-bubble {
|
| 116 |
+
background: var(--c-ub);
|
| 117 |
+
border: 1px solid var(--c-ub-bd);
|
| 118 |
+
border-radius: 18px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* Prose — markdown content */
|
| 122 |
+
.prose-dark {
|
| 123 |
+
color: var(--c-prose);
|
| 124 |
+
line-height: 1.75;
|
| 125 |
+
word-break: break-word;
|
| 126 |
+
}
|
| 127 |
+
.prose-dark h1,
|
| 128 |
+
.prose-dark h2,
|
| 129 |
+
.prose-dark h3,
|
| 130 |
+
.prose-dark h4 {
|
| 131 |
+
color: var(--c-prose-h);
|
| 132 |
+
font-weight: 600;
|
| 133 |
+
margin: 1em 0 0.5em;
|
| 134 |
+
line-height: 1.3;
|
| 135 |
+
}
|
| 136 |
+
.prose-dark h1 { font-size: 1.5em; }
|
| 137 |
+
.prose-dark h2 { font-size: 1.25em; }
|
| 138 |
+
.prose-dark h3 { font-size: 1.1em; }
|
| 139 |
+
.prose-dark p {
|
| 140 |
+
margin: 0.6em 0;
|
| 141 |
+
}
|
| 142 |
+
.prose-dark p:first-child { margin-top: 0; }
|
| 143 |
+
.prose-dark p:last-child { margin-bottom: 0; }
|
| 144 |
+
.prose-dark a {
|
| 145 |
+
color: var(--c-prose-a);
|
| 146 |
+
text-decoration: underline;
|
| 147 |
+
}
|
| 148 |
+
.prose-dark a:hover {
|
| 149 |
+
color: var(--c-prose-ah);
|
| 150 |
+
}
|
| 151 |
+
.prose-dark ul,
|
| 152 |
+
.prose-dark ol {
|
| 153 |
+
padding-left: 1.5em;
|
| 154 |
+
margin: 0.5em 0;
|
| 155 |
+
}
|
| 156 |
+
.prose-dark li {
|
| 157 |
+
margin: 0.2em 0;
|
| 158 |
+
}
|
| 159 |
+
.prose-dark blockquote {
|
| 160 |
+
border-left: 3px solid var(--c-bd);
|
| 161 |
+
padding-left: 1em;
|
| 162 |
+
color: var(--c-tx3);
|
| 163 |
+
margin: 0.75em 0;
|
| 164 |
+
font-style: italic;
|
| 165 |
+
}
|
| 166 |
+
.prose-dark hr {
|
| 167 |
+
border: none;
|
| 168 |
+
border-top: 1px solid var(--c-bd);
|
| 169 |
+
margin: 1em 0;
|
| 170 |
+
}
|
| 171 |
+
.prose-dark table {
|
| 172 |
+
border-collapse: collapse;
|
| 173 |
+
width: 100%;
|
| 174 |
+
margin: 0.75em 0;
|
| 175 |
+
font-size: 0.9em;
|
| 176 |
+
}
|
| 177 |
+
.prose-dark th,
|
| 178 |
+
.prose-dark td {
|
| 179 |
+
border: 1px solid var(--c-bd);
|
| 180 |
+
padding: 0.4em 0.75em;
|
| 181 |
+
text-align: left;
|
| 182 |
+
}
|
| 183 |
+
.prose-dark th {
|
| 184 |
+
background: var(--c-hi);
|
| 185 |
+
font-weight: 600;
|
| 186 |
+
}
|
| 187 |
+
.prose-dark code:not(pre code) {
|
| 188 |
+
background: var(--c-code);
|
| 189 |
+
border: 1px solid var(--c-bd);
|
| 190 |
+
border-radius: 3px;
|
| 191 |
+
padding: 0.1em 0.35em;
|
| 192 |
+
font-size: 0.875em;
|
| 193 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 194 |
+
color: var(--c-prose-cd);
|
| 195 |
+
}
|
| 196 |
+
.prose-dark pre {
|
| 197 |
+
background: var(--c-code);
|
| 198 |
+
border: 1px solid var(--c-bd);
|
| 199 |
+
border-radius: 6px;
|
| 200 |
+
padding: 1em;
|
| 201 |
+
overflow-x: auto;
|
| 202 |
+
margin: 0.75em 0;
|
| 203 |
+
position: relative;
|
| 204 |
+
}
|
| 205 |
+
.prose-dark pre code {
|
| 206 |
+
background: none;
|
| 207 |
+
border: none;
|
| 208 |
+
padding: 0;
|
| 209 |
+
font-size: 0.85em;
|
| 210 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 211 |
+
color: var(--c-prose-cd);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/* Code block copy button */
|
| 215 |
+
.code-block-wrapper {
|
| 216 |
+
position: relative;
|
| 217 |
+
}
|
| 218 |
+
.copy-btn {
|
| 219 |
+
position: absolute;
|
| 220 |
+
top: 0.5em;
|
| 221 |
+
right: 0.5em;
|
| 222 |
+
background: var(--c-hi);
|
| 223 |
+
border: 1px solid var(--c-bd);
|
| 224 |
+
border-radius: 6px;
|
| 225 |
+
color: var(--c-tx3);
|
| 226 |
+
font-size: 0.7em;
|
| 227 |
+
padding: 2px 8px;
|
| 228 |
+
cursor: pointer;
|
| 229 |
+
opacity: 0;
|
| 230 |
+
transition: opacity 0.15s ease;
|
| 231 |
+
}
|
| 232 |
+
.code-block-wrapper:hover .copy-btn {
|
| 233 |
+
opacity: 1;
|
| 234 |
+
}
|
| 235 |
+
.copy-btn:hover {
|
| 236 |
+
background: var(--c-ho);
|
| 237 |
+
color: var(--c-tx);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/* Message fade-in animation */
|
| 241 |
+
@keyframes fadeInUp {
|
| 242 |
+
from {
|
| 243 |
+
opacity: 0;
|
| 244 |
+
transform: translateY(8px);
|
| 245 |
+
}
|
| 246 |
+
to {
|
| 247 |
+
opacity: 1;
|
| 248 |
+
transform: translateY(0);
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
.message-enter {
|
| 252 |
+
animation: fadeInUp 0.2s ease forwards;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/* Typing indicator */
|
| 256 |
+
@keyframes typingDot {
|
| 257 |
+
0%, 60%, 100% { opacity: 0.2; transform: translateY(0); }
|
| 258 |
+
30% { opacity: 1; transform: translateY(-4px); }
|
| 259 |
+
}
|
| 260 |
+
.typing-dot {
|
| 261 |
+
display: inline-block;
|
| 262 |
+
width: 6px;
|
| 263 |
+
height: 6px;
|
| 264 |
+
border-radius: 50%;
|
| 265 |
+
background: var(--c-tx3);
|
| 266 |
+
animation: typingDot 1.2s infinite;
|
| 267 |
+
}
|
| 268 |
+
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 269 |
+
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 270 |
+
|
| 271 |
+
/* Sidebar transition */
|
| 272 |
+
.sidebar-transition {
|
| 273 |
+
transition: transform 0.25s ease, opacity 0.25s ease;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
/* Input textarea auto-resize */
|
| 277 |
+
textarea {
|
| 278 |
+
resize: none;
|
| 279 |
+
overflow-y: auto;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/* Dropdown */
|
| 283 |
+
.dropdown-enter {
|
| 284 |
+
animation: dropdownFade 0.15s ease forwards;
|
| 285 |
+
}
|
| 286 |
+
@keyframes dropdownFade {
|
| 287 |
+
from { opacity: 0; transform: translateY(-4px); }
|
| 288 |
+
to { opacity: 1; transform: translateY(0); }
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/* Modal */
|
| 292 |
+
.modal-backdrop {
|
| 293 |
+
animation: modalFadeIn 0.15s ease forwards;
|
| 294 |
+
}
|
| 295 |
+
@keyframes modalFadeIn {
|
| 296 |
+
from { opacity: 0; }
|
| 297 |
+
to { opacity: 1; }
|
| 298 |
+
}
|
| 299 |
+
.modal-panel {
|
| 300 |
+
animation: modalSlideIn 0.2s ease forwards;
|
| 301 |
+
}
|
| 302 |
+
@keyframes modalSlideIn {
|
| 303 |
+
from { opacity: 0; transform: scale(0.96) translateY(-8px); }
|
| 304 |
+
to { opacity: 1; transform: scale(1) translateY(0); }
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/* hljs theme overrides */
|
| 308 |
+
.hljs {
|
| 309 |
+
background: transparent !important;
|
| 310 |
+
}
|
tailwind.config.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: [
|
| 4 |
+
'./index.html',
|
| 5 |
+
'./src/**/*.js',
|
| 6 |
+
],
|
| 7 |
+
darkMode: 'class',
|
| 8 |
+
theme: {
|
| 9 |
+
extend: {
|
| 10 |
+
colors: {
|
| 11 |
+
bg: 'var(--c-bg)',
|
| 12 |
+
surface:'var(--c-sf)',
|
| 13 |
+
card: 'var(--c-card)',
|
| 14 |
+
border: 'var(--c-bd)',
|
| 15 |
+
muted: 'var(--c-tx3)',
|
| 16 |
+
},
|
| 17 |
+
fontFamily: {
|
| 18 |
+
sans: ['Inter', 'system-ui', 'sans-serif'],
|
| 19 |
+
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
plugins: [],
|
| 24 |
+
};
|
tests/api.test.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi } from 'vitest';
|
| 2 |
+
import {
|
| 3 |
+
buildRequestConfig,
|
| 4 |
+
fetchModels,
|
| 5 |
+
streamCompletion,
|
| 6 |
+
transcribeAudio,
|
| 7 |
+
} from '../src/api.js';
|
| 8 |
+
|
| 9 |
+
// ─── buildRequestConfig ──────────────────────────────────────────────────────
|
| 10 |
+
|
| 11 |
+
describe('buildRequestConfig – dev mode (isDev=true)', () => {
|
| 12 |
+
it('uses /lw-proxy as urlBase', () => {
|
| 13 |
+
const { urlBase } = buildRequestConfig('http://10.168.21.119:8000', '', true);
|
| 14 |
+
expect(urlBase).toBe('/lw-proxy');
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
it('sets X-LW-Target header to the cleaned baseUrl', () => {
|
| 18 |
+
const { headers } = buildRequestConfig('http://10.168.21.119:8000/', '', true);
|
| 19 |
+
expect(headers['X-LW-Target']).toBe('http://10.168.21.119:8000');
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
it('strips trailing slash from X-LW-Target', () => {
|
| 23 |
+
const { headers } = buildRequestConfig('http://localhost:8000/', '', true);
|
| 24 |
+
expect(headers['X-LW-Target']).toBe('http://localhost:8000');
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
it('does NOT add Authorization header when apiKey is empty', () => {
|
| 28 |
+
const { headers } = buildRequestConfig('http://localhost:8000', '', true);
|
| 29 |
+
expect(headers['Authorization']).toBeUndefined();
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
it('adds Authorization header when apiKey is provided', () => {
|
| 33 |
+
const { headers } = buildRequestConfig('http://localhost:8000', 'sk-test', true);
|
| 34 |
+
expect(headers['Authorization']).toBe('Bearer sk-test');
|
| 35 |
+
});
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
describe('buildRequestConfig – prod mode (isDev=false)', () => {
|
| 39 |
+
it('uses cleaned baseUrl as urlBase', () => {
|
| 40 |
+
const { urlBase } = buildRequestConfig('http://10.168.21.119:8000/', '', false);
|
| 41 |
+
expect(urlBase).toBe('http://10.168.21.119:8000');
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
it('does NOT set X-LW-Target header', () => {
|
| 45 |
+
const { headers } = buildRequestConfig('http://localhost:8000', '', false);
|
| 46 |
+
expect(headers['X-LW-Target']).toBeUndefined();
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it('does NOT add Authorization header when apiKey is empty', () => {
|
| 50 |
+
const { headers } = buildRequestConfig('http://localhost:8000', '', false);
|
| 51 |
+
expect(headers['Authorization']).toBeUndefined();
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it('adds Authorization header when apiKey is provided', () => {
|
| 55 |
+
const { headers } = buildRequestConfig('http://localhost:8000', 'sk-key', false);
|
| 56 |
+
expect(headers['Authorization']).toBe('Bearer sk-key');
|
| 57 |
+
});
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
// ─── fetchModels ──────────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
describe('fetchModels (dev mode via buildRequestConfig isDev=true default in Vitest)', () => {
|
| 63 |
+
it('calls /lw-proxy/v1/models and sets X-LW-Target', async () => {
|
| 64 |
+
const captured = {};
|
| 65 |
+
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
| 66 |
+
captured.url = url;
|
| 67 |
+
captured.headers = opts?.headers ?? {};
|
| 68 |
+
return { ok: true, json: async () => ({ data: [{ id: 'model-a' }] }) };
|
| 69 |
+
}));
|
| 70 |
+
|
| 71 |
+
await fetchModels('http://10.168.21.119:8000', '');
|
| 72 |
+
expect(captured.url).toBe('/lw-proxy/v1/models');
|
| 73 |
+
expect(captured.headers['X-LW-Target']).toBe('http://10.168.21.119:8000');
|
| 74 |
+
vi.unstubAllGlobals();
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it('parses OpenAI-style { data: [{id}] } response', async () => {
|
| 78 |
+
vi.stubGlobal('fetch', vi.fn(async () => ({
|
| 79 |
+
ok: true,
|
| 80 |
+
json: async () => ({ data: [{ id: 'AXERA-TECH/Qwen3-VL-2B-Instruct' }] }),
|
| 81 |
+
})));
|
| 82 |
+
|
| 83 |
+
const models = await fetchModels('http://10.168.21.119:8000', '');
|
| 84 |
+
expect(models).toContain('AXERA-TECH/Qwen3-VL-2B-Instruct');
|
| 85 |
+
vi.unstubAllGlobals();
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it('parses flat string-array response', async () => {
|
| 89 |
+
vi.stubGlobal('fetch', vi.fn(async () => ({
|
| 90 |
+
ok: true,
|
| 91 |
+
json: async () => ['model-x', 'model-y'],
|
| 92 |
+
})));
|
| 93 |
+
|
| 94 |
+
const models = await fetchModels('http://localhost:8000', '');
|
| 95 |
+
expect(models).toContain('model-x');
|
| 96 |
+
vi.unstubAllGlobals();
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
it('does NOT add Authorization header when apiKey is empty', async () => {
|
| 100 |
+
const captured = {};
|
| 101 |
+
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
| 102 |
+
captured.headers = opts?.headers ?? {};
|
| 103 |
+
return { ok: true, json: async () => ({ data: [] }) };
|
| 104 |
+
}));
|
| 105 |
+
|
| 106 |
+
await fetchModels('http://localhost:8000', '');
|
| 107 |
+
expect(captured.headers['Authorization']).toBeUndefined();
|
| 108 |
+
vi.unstubAllGlobals();
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
it('adds Authorization header when apiKey is provided', async () => {
|
| 112 |
+
const captured = {};
|
| 113 |
+
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
| 114 |
+
captured.headers = opts?.headers ?? {};
|
| 115 |
+
return { ok: true, json: async () => ({ data: [] }) };
|
| 116 |
+
}));
|
| 117 |
+
|
| 118 |
+
await fetchModels('http://localhost:8000', 'sk-my-key');
|
| 119 |
+
expect(captured.headers['Authorization']).toBe('Bearer sk-my-key');
|
| 120 |
+
vi.unstubAllGlobals();
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it('throws on non-OK HTTP response', async () => {
|
| 124 |
+
vi.stubGlobal('fetch', vi.fn(async () => ({
|
| 125 |
+
ok: false, status: 500, text: async () => 'Internal error',
|
| 126 |
+
})));
|
| 127 |
+
|
| 128 |
+
await expect(fetchModels('http://localhost:8000', '')).rejects.toThrow('500');
|
| 129 |
+
vi.unstubAllGlobals();
|
| 130 |
+
});
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
// ─── streamCompletion ────────────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
describe('streamCompletion (dev mode)', () => {
|
| 136 |
+
it('calls /lw-proxy/v1/chat/completions and sets X-LW-Target', async () => {
|
| 137 |
+
const captured = {};
|
| 138 |
+
const sseChunks = [
|
| 139 |
+
'data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n',
|
| 140 |
+
'data: [DONE]\n\n',
|
| 141 |
+
];
|
| 142 |
+
let idx = 0;
|
| 143 |
+
const reader = {
|
| 144 |
+
read: vi.fn(async () =>
|
| 145 |
+
idx >= sseChunks.length
|
| 146 |
+
? { done: true }
|
| 147 |
+
: { done: false, value: new TextEncoder().encode(sseChunks[idx++]) }
|
| 148 |
+
),
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
| 152 |
+
captured.url = url;
|
| 153 |
+
captured.headers = opts?.headers ?? {};
|
| 154 |
+
return { ok: true, body: { getReader: () => reader } };
|
| 155 |
+
}));
|
| 156 |
+
|
| 157 |
+
const chunks = [];
|
| 158 |
+
for await (const chunk of streamCompletion('http://10.168.21.119:8000', '', 'model', [])) {
|
| 159 |
+
chunks.push(chunk);
|
| 160 |
+
}
|
| 161 |
+
expect(captured.url).toBe('/lw-proxy/v1/chat/completions');
|
| 162 |
+
expect(captured.headers['X-LW-Target']).toBe('http://10.168.21.119:8000');
|
| 163 |
+
expect(chunks).toContain('Hi');
|
| 164 |
+
vi.unstubAllGlobals();
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
describe('audio uploads', () => {
|
| 169 |
+
it('transcribeAudio posts multipart form data to /v1/audio/transcriptions', async () => {
|
| 170 |
+
const captured = {};
|
| 171 |
+
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
| 172 |
+
captured.url = url;
|
| 173 |
+
captured.method = opts?.method;
|
| 174 |
+
captured.headers = opts?.headers ?? {};
|
| 175 |
+
captured.body = opts?.body;
|
| 176 |
+
return { ok: true, json: async () => ({ text: 'hello transcript' }) };
|
| 177 |
+
}));
|
| 178 |
+
|
| 179 |
+
const file = new File(['audio'], 'meeting.wav', { type: 'audio/wav' });
|
| 180 |
+
const text = await transcribeAudio('http://localhost:8000', 'sk-key', 'audio-model', file);
|
| 181 |
+
|
| 182 |
+
expect(captured.url).toBe('/lw-proxy/v1/audio/transcriptions');
|
| 183 |
+
expect(captured.method).toBe('POST');
|
| 184 |
+
expect(captured.headers['Content-Type']).toBeUndefined();
|
| 185 |
+
expect(captured.headers['Authorization']).toBe('Bearer sk-key');
|
| 186 |
+
expect(captured.body).toBeInstanceOf(FormData);
|
| 187 |
+
expect(captured.body.get('model')).toBe('audio-model');
|
| 188 |
+
expect(captured.body.get('file').name).toBe(file.name);
|
| 189 |
+
expect(captured.body.get('file').type).toBe(file.type);
|
| 190 |
+
expect(text).toBe('hello transcript');
|
| 191 |
+
vi.unstubAllGlobals();
|
| 192 |
+
});
|
| 193 |
+
});
|
tests/app.test.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
| 2 |
+
import { App } from '../src/components/app.js';
|
| 3 |
+
import { store } from '../src/store.js';
|
| 4 |
+
|
| 5 |
+
function setViewport(width) {
|
| 6 |
+
Object.defineProperty(window, 'innerWidth', {
|
| 7 |
+
configurable: true,
|
| 8 |
+
writable: true,
|
| 9 |
+
value: width,
|
| 10 |
+
});
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function mountApp() {
|
| 14 |
+
const root = document.createElement('div');
|
| 15 |
+
document.body.appendChild(root);
|
| 16 |
+
const app = new App(root);
|
| 17 |
+
app._render();
|
| 18 |
+
app._syncSidebarLayout();
|
| 19 |
+
app._loadCurrentConversation();
|
| 20 |
+
app.settingsModal.render();
|
| 21 |
+
app._updateContextInfo();
|
| 22 |
+
return { app, root };
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
afterEach(() => {
|
| 26 |
+
document.body.innerHTML = '';
|
| 27 |
+
vi.restoreAllMocks();
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
describe('App sidebar layout', () => {
|
| 31 |
+
it('keeps the sidebar visible on desktop when selecting another conversation', () => {
|
| 32 |
+
setViewport(1280);
|
| 33 |
+
const first = store.createConversation('model-a');
|
| 34 |
+
const second = store.createConversation('model-a');
|
| 35 |
+
store.setCurrentConversationId(first.id);
|
| 36 |
+
|
| 37 |
+
const { app, root } = mountApp();
|
| 38 |
+
const sidebar = root.querySelector('#sidebar');
|
| 39 |
+
|
| 40 |
+
expect(sidebar.className).not.toContain('-translate-x-full');
|
| 41 |
+
|
| 42 |
+
app._selectConversation(second.id);
|
| 43 |
+
|
| 44 |
+
expect(sidebar.className).not.toContain('-translate-x-full');
|
| 45 |
+
expect(sidebar.className).not.toContain('fixed');
|
| 46 |
+
expect(root.querySelector('#new-chat-btn')).not.toBeNull();
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it('does not hide the sidebar on desktop after creating multiple new chats', () => {
|
| 50 |
+
setViewport(1280);
|
| 51 |
+
const { app, root } = mountApp();
|
| 52 |
+
|
| 53 |
+
app._newChat();
|
| 54 |
+
app._newChat();
|
| 55 |
+
|
| 56 |
+
const sidebar = root.querySelector('#sidebar');
|
| 57 |
+
expect(sidebar.className).not.toContain('-translate-x-full');
|
| 58 |
+
expect(root.querySelectorAll('#conv-list [role="option"]').length).toBe(2);
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it('uses off-canvas sidebar behaviour only on mobile widths', () => {
|
| 62 |
+
setViewport(640);
|
| 63 |
+
const first = store.createConversation('model-a');
|
| 64 |
+
const second = store.createConversation('model-a');
|
| 65 |
+
store.setCurrentConversationId(first.id);
|
| 66 |
+
|
| 67 |
+
const { app, root } = mountApp();
|
| 68 |
+
const sidebar = root.querySelector('#sidebar');
|
| 69 |
+
|
| 70 |
+
expect(sidebar.className).toContain('-translate-x-full');
|
| 71 |
+
expect(sidebar.className).toContain('fixed');
|
| 72 |
+
|
| 73 |
+
app._toggleMobileSidebar();
|
| 74 |
+
expect(sidebar.className).toContain('translate-x-0');
|
| 75 |
+
expect(sidebar.className).not.toContain('-translate-x-full');
|
| 76 |
+
|
| 77 |
+
app._selectConversation(second.id);
|
| 78 |
+
expect(sidebar.className).toContain('-translate-x-full');
|
| 79 |
+
});
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
describe('App model state', () => {
|
| 83 |
+
it('uses the globally selected model instead of per-conversation model', () => {
|
| 84 |
+
setViewport(1280);
|
| 85 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 86 |
+
store.saveAvailableModels('http://a.local', ['model-a']);
|
| 87 |
+
store.setCurrentModel('http://a.local', 'model-a');
|
| 88 |
+
|
| 89 |
+
const first = store.createConversation('legacy-model');
|
| 90 |
+
store.setCurrentConversationId(first.id);
|
| 91 |
+
|
| 92 |
+
const { app } = mountApp();
|
| 93 |
+
|
| 94 |
+
expect(app.modelPicker.getModel()).toBe('model-a');
|
| 95 |
+
expect(app.inputBar._currentModel).toBe('model-a');
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it('clears the current model after models:changed when current URL no longer provides it', () => {
|
| 99 |
+
setViewport(1280);
|
| 100 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 101 |
+
store.saveAvailableModels('http://a.local', ['model-a']);
|
| 102 |
+
store.setCurrentModel('http://a.local', 'model-a');
|
| 103 |
+
|
| 104 |
+
const { app } = mountApp();
|
| 105 |
+
store.saveAvailableModels('http://a.local', ['model-b']);
|
| 106 |
+
document.dispatchEvent(new CustomEvent('models:changed'));
|
| 107 |
+
|
| 108 |
+
expect(app.modelPicker.getModel()).toBe('');
|
| 109 |
+
expect(app.inputBar._currentModel).toBe('');
|
| 110 |
+
});
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
describe('App audio workflows', () => {
|
| 114 |
+
it('uploads audio and returns transcription text', async () => {
|
| 115 |
+
setViewport(1280);
|
| 116 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 117 |
+
store.saveAvailableModels('http://a.local', ['audio-model']);
|
| 118 |
+
store.setCurrentModel('http://a.local', 'audio-model');
|
| 119 |
+
store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } });
|
| 120 |
+
|
| 121 |
+
globalThis.fetch = vi.fn(async (url) => {
|
| 122 |
+
if (String(url).includes('/v1/audio/transcriptions')) {
|
| 123 |
+
return { ok: true, json: async () => ({ text: '会议录音转写文本' }) };
|
| 124 |
+
}
|
| 125 |
+
throw new Error(`Unexpected fetch: ${url}`);
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
const { app } = mountApp();
|
| 129 |
+
const file = new File(['audio'], 'meeting.wav', { type: 'audio/wav' });
|
| 130 |
+
await app._handleSend('', null, null, { file, mode: 'transcribe' });
|
| 131 |
+
|
| 132 |
+
const conv = store.getCurrentConversation();
|
| 133 |
+
expect(conv.messages.at(-1).content).toBe('会议录音转写文本');
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
it('uploads audio and can return translated text via instruction', async () => {
|
| 137 |
+
setViewport(1280);
|
| 138 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 139 |
+
store.saveAvailableModels('http://a.local', ['audio-model']);
|
| 140 |
+
store.setCurrentModel('http://a.local', 'audio-model');
|
| 141 |
+
store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } });
|
| 142 |
+
|
| 143 |
+
const sseChunks = [
|
| 144 |
+
'data: {"choices":[{"delta":{"content":"Translated meeting notes"}}]}\n\n',
|
| 145 |
+
'data: [DONE]\n\n',
|
| 146 |
+
];
|
| 147 |
+
let idx = 0;
|
| 148 |
+
const reader = {
|
| 149 |
+
read: vi.fn(async () =>
|
| 150 |
+
idx >= sseChunks.length
|
| 151 |
+
? { done: true }
|
| 152 |
+
: { done: false, value: new TextEncoder().encode(sseChunks[idx++]) }
|
| 153 |
+
),
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
globalThis.fetch = vi.fn(async (url) => {
|
| 157 |
+
if (String(url).includes('/v1/audio/transcriptions')) {
|
| 158 |
+
return { ok: true, json: async () => ({ text: 'Bonjour tout le monde' }) };
|
| 159 |
+
}
|
| 160 |
+
if (String(url).includes('/v1/chat/completions')) {
|
| 161 |
+
return { ok: true, body: { getReader: () => reader } };
|
| 162 |
+
}
|
| 163 |
+
throw new Error(`Unexpected fetch: ${url}`);
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
const { app } = mountApp();
|
| 167 |
+
const file = new File(['audio'], 'speech.m4a', { type: 'audio/mp4' });
|
| 168 |
+
await app._handleSend('Translate this audio to English', null, null, { file });
|
| 169 |
+
|
| 170 |
+
const conv = store.getCurrentConversation();
|
| 171 |
+
expect(conv.messages.at(-1).content).toBe('Translated meeting notes');
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it('uploads audio with an instruction and returns processed text', async () => {
|
| 175 |
+
setViewport(1280);
|
| 176 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 177 |
+
store.saveAvailableModels('http://a.local', ['audio-model']);
|
| 178 |
+
store.setCurrentModel('http://a.local', 'audio-model');
|
| 179 |
+
store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } });
|
| 180 |
+
|
| 181 |
+
const sseChunks = [
|
| 182 |
+
'data: {"choices":[{"delta":{"content":"待办事项:整理纪要"}}]}\n\n',
|
| 183 |
+
'data: [DONE]\n\n',
|
| 184 |
+
];
|
| 185 |
+
let idx = 0;
|
| 186 |
+
const reader = {
|
| 187 |
+
read: vi.fn(async () =>
|
| 188 |
+
idx >= sseChunks.length
|
| 189 |
+
? { done: true }
|
| 190 |
+
: { done: false, value: new TextEncoder().encode(sseChunks[idx++]) }
|
| 191 |
+
),
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
let capturedChatBody = '';
|
| 195 |
+
globalThis.fetch = vi.fn(async (url, opts) => {
|
| 196 |
+
if (String(url).includes('/v1/audio/transcriptions')) {
|
| 197 |
+
return { ok: true, json: async () => ({ text: '原始会议录音文本' }) };
|
| 198 |
+
}
|
| 199 |
+
if (String(url).includes('/v1/chat/completions')) {
|
| 200 |
+
capturedChatBody = opts?.body || '';
|
| 201 |
+
return { ok: true, body: { getReader: () => reader } };
|
| 202 |
+
}
|
| 203 |
+
throw new Error(`Unexpected fetch: ${url}`);
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
const { app } = mountApp();
|
| 207 |
+
const file = new File(['audio'], 'call.wav', { type: 'audio/wav' });
|
| 208 |
+
await app._handleSend('提取会议待办事项', null, null, { file });
|
| 209 |
+
|
| 210 |
+
const conv = store.getCurrentConversation();
|
| 211 |
+
expect(conv.messages.at(-1).content).toBe('待办事项:整理纪要');
|
| 212 |
+
expect(capturedChatBody).toContain('提取会议待办事项');
|
| 213 |
+
expect(capturedChatBody).toContain('原始会议录音文本');
|
| 214 |
+
});
|
| 215 |
+
});
|
tests/capabilities.test.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest';
|
| 2 |
+
import { DEFAULT_CAPABILITIES, getCapabilities, supportsImage, supportsAudio, supportsVideo } from '../src/capabilities.js';
|
| 3 |
+
|
| 4 |
+
describe('DEFAULT_CAPABILITIES', () => {
|
| 5 |
+
it('gpt-4o supports image', () => {
|
| 6 |
+
expect(DEFAULT_CAPABILITIES['gpt-4o'].image).toBe(true);
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
it('gpt-4o does not support audio', () => {
|
| 10 |
+
expect(DEFAULT_CAPABILITIES['gpt-4o'].audio).toBe(false);
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
it('deepseek-v3 does not support image', () => {
|
| 14 |
+
expect(DEFAULT_CAPABILITIES['deepseek-v3'].image).toBe(false);
|
| 15 |
+
});
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
describe('getCapabilities', () => {
|
| 19 |
+
it('returns default for known model with no overrides', () => {
|
| 20 |
+
const caps = getCapabilities('gpt-4o', {});
|
| 21 |
+
expect(caps.text).toBe(true);
|
| 22 |
+
expect(caps.image).toBe(true);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
it('unknown model defaults to text-only', () => {
|
| 26 |
+
const caps = getCapabilities('totally-unknown-model', {});
|
| 27 |
+
expect(caps.text).toBe(true);
|
| 28 |
+
expect(caps.image).toBe(false);
|
| 29 |
+
expect(caps.audio).toBe(false);
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
it('user override takes precedence over defaults', () => {
|
| 33 |
+
const userCaps = { 'gpt-4': { text: true, image: true, audio: false } };
|
| 34 |
+
const caps = getCapabilities('gpt-4', userCaps);
|
| 35 |
+
expect(caps.image).toBe(true); // overridden from false → true
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it('user override for unknown model is applied', () => {
|
| 39 |
+
const userCaps = { 'my-local-model': { text: true, image: true, audio: false } };
|
| 40 |
+
const caps = getCapabilities('my-local-model', userCaps);
|
| 41 |
+
expect(caps.image).toBe(true);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
it('partial override preserves unspecified defaults', () => {
|
| 45 |
+
// Default for gpt-4o: image=true. Override only audio.
|
| 46 |
+
const userCaps = { 'gpt-4o': { audio: true } };
|
| 47 |
+
const caps = getCapabilities('gpt-4o', userCaps);
|
| 48 |
+
expect(caps.image).toBe(true); // preserved from default
|
| 49 |
+
expect(caps.audio).toBe(true); // from override
|
| 50 |
+
});
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
describe('supportsImage', () => {
|
| 54 |
+
it('returns true for vision model', () => {
|
| 55 |
+
expect(supportsImage('gpt-4o', {})).toBe(true);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it('returns false for text-only model', () => {
|
| 59 |
+
expect(supportsImage('gpt-3.5-turbo', {})).toBe(false);
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
it('returns false for unknown model', () => {
|
| 63 |
+
expect(supportsImage('some-new-model', {})).toBe(false);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it('respects user override enabling image on text-only model', () => {
|
| 67 |
+
const userCaps = { 'my-model': { text: true, image: true, audio: false } };
|
| 68 |
+
expect(supportsImage('my-model', userCaps)).toBe(true);
|
| 69 |
+
});
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
describe('supportsAudio', () => {
|
| 73 |
+
it('returns false for gpt-4o (audio not in MVP)', () => {
|
| 74 |
+
expect(supportsAudio('gpt-4o', {})).toBe(false);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it('respects user override enabling audio', () => {
|
| 78 |
+
const userCaps = { 'gpt-4o': { text: true, image: true, audio: true } };
|
| 79 |
+
expect(supportsAudio('gpt-4o', userCaps)).toBe(true);
|
| 80 |
+
});
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
describe('supportsVideo', () => {
|
| 84 |
+
it('returns true when Vision is enabled (same as supportsImage)', () => {
|
| 85 |
+
expect(supportsVideo('gpt-4o', {})).toBe(true);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it('returns false when Vision is disabled', () => {
|
| 89 |
+
expect(supportsVideo('gpt-3.5-turbo', {})).toBe(false);
|
| 90 |
+
expect(supportsVideo('some-unknown-model', {})).toBe(false);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
it('respects user override enabling vision (image=true implies video=true)', () => {
|
| 94 |
+
const userCaps = { 'my-vl-model': { text: true, image: true, audio: false } };
|
| 95 |
+
expect(supportsVideo('my-vl-model', userCaps)).toBe(true);
|
| 96 |
+
});
|
| 97 |
+
});
|
tests/context.test.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest';
|
| 2 |
+
import { formatCompactTokenCount } from '../src/api.js';
|
| 3 |
+
import {
|
| 4 |
+
DEFAULT_CONTEXT_LIMIT_TOKENS,
|
| 5 |
+
DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT,
|
| 6 |
+
resolveContextConfig,
|
| 7 |
+
estimateThreadTokens,
|
| 8 |
+
} from '../src/context.js';
|
| 9 |
+
|
| 10 |
+
describe('context helpers – resolveContextConfig', () => {
|
| 11 |
+
it('falls back to the default context window when nothing is configured', () => {
|
| 12 |
+
const config = resolveContextConfig({}, '');
|
| 13 |
+
expect(config.maxTokens).toBe(DEFAULT_CONTEXT_LIMIT_TOKENS);
|
| 14 |
+
expect(config.resetPercent).toBe(DEFAULT_CONTEXT_RESET_THRESHOLD_PERCENT);
|
| 15 |
+
expect(config.source).toBe('default');
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
it('prefers a manually configured context window', () => {
|
| 19 |
+
const config = resolveContextConfig({ contextLimitTokens: 65536 }, 'qwen-32k');
|
| 20 |
+
expect(config.maxTokens).toBe(65536);
|
| 21 |
+
expect(config.source).toBe('manual');
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it('does not infer context length from model ids anymore', () => {
|
| 25 |
+
const config = resolveContextConfig({ contextLimitTokens: null }, 'qwen-128k');
|
| 26 |
+
expect(config.maxTokens).toBe(DEFAULT_CONTEXT_LIMIT_TOKENS);
|
| 27 |
+
expect(config.source).toBe('default');
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
it('respects thresholds below the default warning band', () => {
|
| 31 |
+
const config = resolveContextConfig({
|
| 32 |
+
contextLimitTokens: 4096,
|
| 33 |
+
contextResetThresholdPercent: 70,
|
| 34 |
+
}, '');
|
| 35 |
+
|
| 36 |
+
expect(config.resetTokens).toBe(Math.floor(4096 * 0.7));
|
| 37 |
+
expect(config.warnTokens).toBe(config.resetTokens);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it('keeps resetTokens below the hard window when threshold percent is too aggressive', () => {
|
| 41 |
+
const config = resolveContextConfig({
|
| 42 |
+
contextLimitTokens: 32768,
|
| 43 |
+
contextResetThresholdPercent: 95,
|
| 44 |
+
}, '');
|
| 45 |
+
|
| 46 |
+
expect(config.warnTokens).toBe(Math.floor(32768 * 0.8));
|
| 47 |
+
expect(config.resetTokens).toBe(30720);
|
| 48 |
+
});
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
describe('context helpers – estimateThreadTokens', () => {
|
| 52 |
+
it('includes pending messages in the projected estimate', () => {
|
| 53 |
+
const messages = [{ role: 'user', content: 'hello world' }];
|
| 54 |
+
const pending = { role: 'assistant', content: 'reply' };
|
| 55 |
+
|
| 56 |
+
expect(estimateThreadTokens(messages, pending)).toBeGreaterThan(estimateThreadTokens(messages));
|
| 57 |
+
});
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
describe('api helpers – formatCompactTokenCount', () => {
|
| 61 |
+
it('keeps 4k-class windows readable without rounding them up', () => {
|
| 62 |
+
expect(formatCompactTokenCount(4096)).toBe('4.1k');
|
| 63 |
+
});
|
| 64 |
+
});
|
tests/input-bar.test.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests for InputBar component behaviour:
|
| 3 |
+
* - Send button visual state (rainbow idle / muted sending) and icon markup
|
| 4 |
+
* - Enter key sends, Shift+Enter does not
|
| 5 |
+
* - /reset and /clean are passed through as text (command handling lives in App)
|
| 6 |
+
*/
|
| 7 |
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
| 8 |
+
import { InputBar } from '../src/components/input-bar.js';
|
| 9 |
+
import { store } from '../src/store.js';
|
| 10 |
+
|
| 11 |
+
function makeBar() {
|
| 12 |
+
const bar = new InputBar();
|
| 13 |
+
document.body.appendChild(bar.render());
|
| 14 |
+
return bar;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
afterEach(() => {
|
| 18 |
+
document.body.innerHTML = '';
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
function enableAudio(bar) {
|
| 22 |
+
store.saveModelCapabilities({
|
| 23 |
+
'audio-model': { text: true, image: false, audio: true },
|
| 24 |
+
});
|
| 25 |
+
bar.setModel('audio-model');
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// ─── Send button appearance ───────────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
describe('InputBar – send button appearance', () => {
|
| 31 |
+
it('send button uses the theme hover shell by default', () => {
|
| 32 |
+
const bar = makeBar();
|
| 33 |
+
const btn = bar.el.querySelector('#send-btn');
|
| 34 |
+
expect(btn.className).toContain('hover:bg-[var(--c-hi)]');
|
| 35 |
+
expect(btn.className).not.toContain('bg-blue-500');
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it('send button uses a transparent paper airplane svg with fold lines and gradient strokes', () => {
|
| 39 |
+
const bar = makeBar();
|
| 40 |
+
const btn = bar.el.querySelector('#send-btn');
|
| 41 |
+
expect(btn.querySelector('svg')?.getAttribute('fill')).toBe('none');
|
| 42 |
+
expect(btn.querySelectorAll('path')).toHaveLength(3);
|
| 43 |
+
expect(btn.querySelector('linearGradient')).not.toBeNull();
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
it('setSending(true) disables the button', () => {
|
| 47 |
+
const bar = makeBar();
|
| 48 |
+
bar.setSending(true);
|
| 49 |
+
expect(bar.el.querySelector('#send-btn').disabled).toBe(true);
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it('setSending(true) switches to the muted theme state', () => {
|
| 53 |
+
const bar = makeBar();
|
| 54 |
+
bar.setSending(true);
|
| 55 |
+
const btn = bar.el.querySelector('#send-btn');
|
| 56 |
+
expect(btn.className).toContain('text-[var(--c-tx3)]');
|
| 57 |
+
expect(btn.className).toContain('cursor-not-allowed');
|
| 58 |
+
expect(btn.querySelector('linearGradient')).toBeNull();
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it('setSending(false) re-enables button and restores the idle theme state', () => {
|
| 62 |
+
const bar = makeBar();
|
| 63 |
+
bar.setSending(true);
|
| 64 |
+
bar.setSending(false);
|
| 65 |
+
const btn = bar.el.querySelector('#send-btn');
|
| 66 |
+
expect(btn.disabled).toBe(false);
|
| 67 |
+
expect(btn.className).toContain('hover:bg-[var(--c-hi)]');
|
| 68 |
+
expect(btn.querySelector('linearGradient')).not.toBeNull();
|
| 69 |
+
});
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
// ─── Keyboard shortcuts ───────────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
describe('InputBar – keyboard shortcuts', () => {
|
| 75 |
+
it('Enter dispatches inputbar:send with the textarea text', () => {
|
| 76 |
+
const bar = makeBar();
|
| 77 |
+
const events = [];
|
| 78 |
+
const handler = (e) => events.push(e.detail);
|
| 79 |
+
document.addEventListener('inputbar:send', handler);
|
| 80 |
+
|
| 81 |
+
const textarea = bar.el.querySelector('#message-input');
|
| 82 |
+
textarea.value = 'hello world';
|
| 83 |
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
| 84 |
+
|
| 85 |
+
expect(events).toHaveLength(1);
|
| 86 |
+
expect(events[0].text).toBe('hello world');
|
| 87 |
+
document.removeEventListener('inputbar:send', handler);
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it('Enter clears the textarea after sending', () => {
|
| 91 |
+
const bar = makeBar();
|
| 92 |
+
const textarea = bar.el.querySelector('#message-input');
|
| 93 |
+
textarea.value = 'test message';
|
| 94 |
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
| 95 |
+
expect(textarea.value).toBe('');
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it('Shift+Enter does NOT dispatch inputbar:send', () => {
|
| 99 |
+
const bar = makeBar();
|
| 100 |
+
const events = [];
|
| 101 |
+
const handler = (e) => events.push(e.detail);
|
| 102 |
+
document.addEventListener('inputbar:send', handler);
|
| 103 |
+
|
| 104 |
+
const textarea = bar.el.querySelector('#message-input');
|
| 105 |
+
textarea.value = 'hello world';
|
| 106 |
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
|
| 107 |
+
|
| 108 |
+
expect(events).toHaveLength(0);
|
| 109 |
+
document.removeEventListener('inputbar:send', handler);
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
it('Enter does nothing when textarea is blank', () => {
|
| 113 |
+
const bar = makeBar();
|
| 114 |
+
const events = [];
|
| 115 |
+
const handler = (e) => events.push(e.detail);
|
| 116 |
+
document.addEventListener('inputbar:send', handler);
|
| 117 |
+
|
| 118 |
+
const textarea = bar.el.querySelector('#message-input');
|
| 119 |
+
textarea.value = ' ';
|
| 120 |
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
| 121 |
+
|
| 122 |
+
expect(events).toHaveLength(0);
|
| 123 |
+
document.removeEventListener('inputbar:send', handler);
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
it('Enter does NOT send while _sending is true', () => {
|
| 127 |
+
const bar = makeBar();
|
| 128 |
+
bar.setSending(true);
|
| 129 |
+
const events = [];
|
| 130 |
+
const handler = (e) => events.push(e.detail);
|
| 131 |
+
document.addEventListener('inputbar:send', handler);
|
| 132 |
+
|
| 133 |
+
const textarea = bar.el.querySelector('#message-input');
|
| 134 |
+
textarea.value = 'hello';
|
| 135 |
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
| 136 |
+
|
| 137 |
+
expect(events).toHaveLength(0);
|
| 138 |
+
document.removeEventListener('inputbar:send', handler);
|
| 139 |
+
});
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
// ─── Command passthrough ──────────────────────────────────────────────────────
|
| 143 |
+
|
| 144 |
+
describe('InputBar – command passthrough', () => {
|
| 145 |
+
it('dispatches inputbar:send with text="/reset" (command parsed by App, not InputBar)', () => {
|
| 146 |
+
const bar = makeBar();
|
| 147 |
+
const events = [];
|
| 148 |
+
const handler = (e) => events.push(e.detail);
|
| 149 |
+
document.addEventListener('inputbar:send', handler);
|
| 150 |
+
|
| 151 |
+
const textarea = bar.el.querySelector('#message-input');
|
| 152 |
+
textarea.value = '/reset';
|
| 153 |
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
| 154 |
+
|
| 155 |
+
expect(events).toHaveLength(1);
|
| 156 |
+
expect(events[0].text).toBe('/reset');
|
| 157 |
+
document.removeEventListener('inputbar:send', handler);
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
it('dispatches inputbar:send with text="/clean" (command parsed by App, not InputBar)', () => {
|
| 161 |
+
const bar = makeBar();
|
| 162 |
+
const events = [];
|
| 163 |
+
const handler = (e) => events.push(e.detail);
|
| 164 |
+
document.addEventListener('inputbar:send', handler);
|
| 165 |
+
|
| 166 |
+
const textarea = bar.el.querySelector('#message-input');
|
| 167 |
+
textarea.value = '/clean';
|
| 168 |
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
| 169 |
+
|
| 170 |
+
expect(events).toHaveLength(1);
|
| 171 |
+
expect(events[0].text).toBe('/clean');
|
| 172 |
+
document.removeEventListener('inputbar:send', handler);
|
| 173 |
+
});
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
describe('InputBar – audio attachments', () => {
|
| 177 |
+
it('dispatches audio upload with default transcribe mode', () => {
|
| 178 |
+
const bar = makeBar();
|
| 179 |
+
enableAudio(bar);
|
| 180 |
+
bar._handleAudioFile(new File(['audio'], 'meeting.wav', { type: 'audio/wav' }));
|
| 181 |
+
|
| 182 |
+
const events = [];
|
| 183 |
+
const handler = (e) => events.push(e.detail);
|
| 184 |
+
document.addEventListener('inputbar:send', handler);
|
| 185 |
+
|
| 186 |
+
bar.el.querySelector('#send-btn').click();
|
| 187 |
+
|
| 188 |
+
expect(events).toHaveLength(1);
|
| 189 |
+
expect(events[0].audio.file.name).toBe('meeting.wav');
|
| 190 |
+
document.removeEventListener('inputbar:send', handler);
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
it('supports pasting audio from the clipboard', async () => {
|
| 194 |
+
const bar = makeBar();
|
| 195 |
+
enableAudio(bar);
|
| 196 |
+
const file = new File(['audio'], 'speech.m4a', { type: 'audio/mp4' });
|
| 197 |
+
const preventDefault = vi.fn();
|
| 198 |
+
await bar._handlePaste({
|
| 199 |
+
preventDefault,
|
| 200 |
+
clipboardData: {
|
| 201 |
+
items: [
|
| 202 |
+
{
|
| 203 |
+
type: 'audio/mp4',
|
| 204 |
+
getAsFile: () => file,
|
| 205 |
+
},
|
| 206 |
+
],
|
| 207 |
+
},
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
expect(preventDefault).toHaveBeenCalled();
|
| 211 |
+
expect(bar._pendingAudio.file.name).toBe('speech.m4a');
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
it('sends audio together with a text instruction', () => {
|
| 215 |
+
const bar = makeBar();
|
| 216 |
+
enableAudio(bar);
|
| 217 |
+
bar._handleAudioFile(new File(['audio'], 'call.ogg', { type: 'audio/ogg' }));
|
| 218 |
+
bar.el.querySelector('#message-input').value = '总结这段录音内容';
|
| 219 |
+
|
| 220 |
+
const events = [];
|
| 221 |
+
const handler = (e) => events.push(e.detail);
|
| 222 |
+
document.addEventListener('inputbar:send', handler);
|
| 223 |
+
|
| 224 |
+
bar.el.querySelector('#send-btn').click();
|
| 225 |
+
|
| 226 |
+
expect(events).toHaveLength(1);
|
| 227 |
+
expect(events[0].text).toBe('总结这段录音内容');
|
| 228 |
+
expect(events[0].audio.file.name).toBe('call.ogg');
|
| 229 |
+
document.removeEventListener('inputbar:send', handler);
|
| 230 |
+
});
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
// ─── Context badge ────────────────────────────────────────────────────────────
|
| 234 |
+
|
| 235 |
+
describe('InputBar – context display', () => {
|
| 236 |
+
it('initial badge shows ctx 0', () => {
|
| 237 |
+
const bar = makeBar();
|
| 238 |
+
expect(bar.el.querySelector('#context-info').textContent).toBe('ctx 0');
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
it('setContextInfo updates the text with compact token counts', () => {
|
| 242 |
+
const bar = makeBar();
|
| 243 |
+
bar.setContextInfo(1536, 4096, 3276);
|
| 244 |
+
expect(bar.el.querySelector('#context-info').textContent).toBe('ctx 1.5k/4.1k');
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
it('setContextInfo(0, max) shows ctx 0/max', () => {
|
| 248 |
+
const bar = makeBar();
|
| 249 |
+
bar.setContextInfo(0, 4096, 3276);
|
| 250 |
+
expect(bar.el.querySelector('#context-info').textContent).toBe('ctx 0/4.1k');
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
it('setContextInfo below warning threshold uses muted color', () => {
|
| 254 |
+
const bar = makeBar();
|
| 255 |
+
bar.setContextInfo(2000, 4096, 3276);
|
| 256 |
+
expect(bar.el.querySelector('#context-info').className).not.toContain('text-amber-400');
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
it('setContextInfo at warning threshold applies amber warning class', () => {
|
| 260 |
+
const bar = makeBar();
|
| 261 |
+
bar.setContextInfo(3276, 4096, 3276);
|
| 262 |
+
expect(bar.el.querySelector('#context-info').className).toContain('text-amber-400');
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
it('setContextInfo above warning threshold applies amber warning class', () => {
|
| 266 |
+
const bar = makeBar();
|
| 267 |
+
bar.setContextInfo(3800, 4096, 3276);
|
| 268 |
+
expect(bar.el.querySelector('#context-info').className).toContain('text-amber-400');
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
it('setContextInfo back below threshold removes amber class', () => {
|
| 272 |
+
const bar = makeBar();
|
| 273 |
+
bar.setContextInfo(3800, 4096, 3276);
|
| 274 |
+
bar.setContextInfo(500, 4096, 3276);
|
| 275 |
+
expect(bar.el.querySelector('#context-info').className).not.toContain('text-amber-400');
|
| 276 |
+
});
|
| 277 |
+
});
|
tests/model-picker.test.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach } from 'vitest';
|
| 2 |
+
import { ModelPicker } from '../src/components/model-picker.js';
|
| 3 |
+
import { store } from '../src/store.js';
|
| 4 |
+
|
| 5 |
+
function mountPicker() {
|
| 6 |
+
const picker = new ModelPicker();
|
| 7 |
+
document.body.appendChild(picker.render());
|
| 8 |
+
return picker;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
beforeEach(() => {
|
| 12 |
+
document.body.innerHTML = '';
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
describe('ModelPicker', () => {
|
| 16 |
+
it('shows only models fetched for the current baseUrl', () => {
|
| 17 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 18 |
+
store.saveAvailableModels('http://a.local', ['model-a', 'model-b']);
|
| 19 |
+
store.saveAvailableModels('http://b.local', ['model-c']);
|
| 20 |
+
|
| 21 |
+
const picker = mountPicker();
|
| 22 |
+
|
| 23 |
+
expect(picker._getAllModels()).toEqual(['model-a', 'model-b']);
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
it('loads the selected model only from the current baseUrl', () => {
|
| 27 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 28 |
+
store.saveAvailableModels('http://a.local', ['model-a']);
|
| 29 |
+
store.setCurrentModel('http://a.local', 'model-a');
|
| 30 |
+
store.saveAvailableModels('http://b.local', ['model-b']);
|
| 31 |
+
store.setCurrentModel('http://b.local', 'model-b');
|
| 32 |
+
|
| 33 |
+
const picker = mountPicker();
|
| 34 |
+
|
| 35 |
+
expect(picker.getModel()).toBe('model-a');
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it('clears an invalid selected model when the fetched list changes', () => {
|
| 39 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 40 |
+
store.saveAvailableModels('http://a.local', ['model-a']);
|
| 41 |
+
store.setCurrentModel('http://a.local', 'model-a');
|
| 42 |
+
|
| 43 |
+
const picker = mountPicker();
|
| 44 |
+
picker.setModels(['model-b']);
|
| 45 |
+
|
| 46 |
+
expect(picker.getModel()).toBe('');
|
| 47 |
+
expect(store.getCurrentModel('http://a.local')).toBe('');
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it('default current model is empty when nothing is selected', () => {
|
| 51 |
+
const picker = mountPicker();
|
| 52 |
+
expect(picker.getModel()).toBe('');
|
| 53 |
+
});
|
| 54 |
+
});
|
tests/settings-modal.test.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
| 2 |
+
import { SettingsModal } from '../src/components/settings-modal.js';
|
| 3 |
+
import { store } from '../src/store.js';
|
| 4 |
+
|
| 5 |
+
function mountModal() {
|
| 6 |
+
const modal = new SettingsModal();
|
| 7 |
+
modal.render();
|
| 8 |
+
modal.show();
|
| 9 |
+
return modal;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
beforeEach(() => {
|
| 13 |
+
document.body.innerHTML = '';
|
| 14 |
+
vi.restoreAllMocks();
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
describe('SettingsModal', () => {
|
| 18 |
+
it('shows only fetched models for the current baseUrl', () => {
|
| 19 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 20 |
+
store.saveAvailableModels('http://a.local', ['model-a']);
|
| 21 |
+
store.saveAvailableModels('http://b.local', ['model-b']);
|
| 22 |
+
|
| 23 |
+
const modal = mountModal();
|
| 24 |
+
const text = modal.el.querySelector('#model-caps-list').textContent;
|
| 25 |
+
|
| 26 |
+
expect(text).toContain('model-a');
|
| 27 |
+
expect(text).not.toContain('model-b');
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
it('saves fetched models under the current baseUrl', async () => {
|
| 31 |
+
store.saveSettings({ baseUrl: 'http://a.local', apiKey: '' });
|
| 32 |
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
| 33 |
+
ok: true,
|
| 34 |
+
json: async () => ({ data: [{ id: 'model-a' }] }),
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const modal = mountModal();
|
| 38 |
+
await modal._fetchModels();
|
| 39 |
+
|
| 40 |
+
expect(store.getAvailableModels('http://a.local')).toEqual(['model-a']);
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
it('does not close when clicking inside the modal panel while editing inputs', () => {
|
| 44 |
+
const modal = mountModal();
|
| 45 |
+
const panel = modal.el.querySelector('.modal-panel');
|
| 46 |
+
const input = modal.el.querySelector('#settings-base-url');
|
| 47 |
+
|
| 48 |
+
panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
| 49 |
+
input.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
| 50 |
+
|
| 51 |
+
expect(document.body.contains(modal.el)).toBe(true);
|
| 52 |
+
expect(modal.visible).toBe(true);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
it('closes only when the backdrop itself is clicked', () => {
|
| 56 |
+
const modal = mountModal();
|
| 57 |
+
|
| 58 |
+
modal.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
| 59 |
+
modal.el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
| 60 |
+
|
| 61 |
+
expect(document.body.contains(modal.el)).toBe(false);
|
| 62 |
+
expect(modal.visible).toBe(false);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it('clears selected model when switching to a URL without that model', () => {
|
| 66 |
+
store.saveAvailableModels('http://a.local', ['model-a']);
|
| 67 |
+
store.setCurrentModel('http://a.local', 'model-a');
|
| 68 |
+
store.saveSettings({ baseUrl: 'http://a.local' });
|
| 69 |
+
|
| 70 |
+
const modal = mountModal();
|
| 71 |
+
modal.el.querySelector('#settings-base-url').value = 'http://b.local';
|
| 72 |
+
modal.el.querySelector('#settings-save').click();
|
| 73 |
+
|
| 74 |
+
expect(store.getSettings().baseUrl).toBe('http://b.local');
|
| 75 |
+
expect(store.getCurrentModel('http://b.local')).toBe('');
|
| 76 |
+
});
|
| 77 |
+
});
|
tests/setup.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Mock localStorage for all tests
|
| 2 |
+
const localStorageData = {};
|
| 3 |
+
const localStorageMock = {
|
| 4 |
+
getItem: (key) => localStorageData[key] ?? null,
|
| 5 |
+
setItem: (key, value) => { localStorageData[key] = String(value); },
|
| 6 |
+
removeItem: (key) => { delete localStorageData[key]; },
|
| 7 |
+
clear: () => { Object.keys(localStorageData).forEach(k => delete localStorageData[k]); },
|
| 8 |
+
};
|
| 9 |
+
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
| 10 |
+
|
| 11 |
+
// Clear localStorage before each test
|
| 12 |
+
beforeEach(() => localStorageMock.clear());
|
tests/store.test.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach } from 'vitest';
|
| 2 |
+
import { store } from '../src/store.js';
|
| 3 |
+
|
| 4 |
+
describe('store – default settings', () => {
|
| 5 |
+
it('default apiKey is empty string', () => {
|
| 6 |
+
expect(store.getSettings().apiKey).toBe('');
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
it('default baseUrl points to localhost:8000', () => {
|
| 10 |
+
expect(store.getSettings().baseUrl).toBe('http://127.0.0.1:8000');
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
it('default theme is dark', () => {
|
| 14 |
+
expect(store.getSettings().theme).toBe('dark');
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
it('default contextLimitTokens is 4096', () => {
|
| 18 |
+
expect(store.getSettings().contextLimitTokens).toBe(4096);
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
it('default contextResetThresholdPercent is 85', () => {
|
| 22 |
+
expect(store.getSettings().contextResetThresholdPercent).toBe(85);
|
| 23 |
+
});
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
describe('store – saveSettings', () => {
|
| 27 |
+
it('persists and retrieves settings', () => {
|
| 28 |
+
store.saveSettings({ apiKey: 'sk-abc', baseUrl: 'http://example.com', theme: 'dark' });
|
| 29 |
+
const s = store.getSettings();
|
| 30 |
+
expect(s.apiKey).toBe('sk-abc');
|
| 31 |
+
expect(s.baseUrl).toBe('http://example.com');
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
it('missing keys fall back to defaults', () => {
|
| 35 |
+
store.saveSettings({ apiKey: 'sk-test' }); // no baseUrl
|
| 36 |
+
expect(store.getSettings().baseUrl).toBe('http://127.0.0.1:8000');
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
it('invalid context settings are normalized back to safe defaults', () => {
|
| 40 |
+
store.saveSettings({ contextLimitTokens: null, contextResetThresholdPercent: 999 });
|
| 41 |
+
const settings = store.getSettings();
|
| 42 |
+
expect(settings.contextLimitTokens).toBe(4096);
|
| 43 |
+
expect(settings.contextResetThresholdPercent).toBe(95);
|
| 44 |
+
});
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
describe('store – conversations', () => {
|
| 48 |
+
it('starts with empty conversation list', () => {
|
| 49 |
+
expect(store.getConversations()).toEqual([]);
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it('createConversation uses provided model', () => {
|
| 53 |
+
const conv = store.createConversation('my-model');
|
| 54 |
+
expect(conv.model).toBe('my-model');
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it('createConversation defaults to empty string model (not hardcoded gpt-4o)', () => {
|
| 58 |
+
const conv = store.createConversation();
|
| 59 |
+
expect(conv.model).toBe('');
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
it('createConversation prepends to list', () => {
|
| 63 |
+
store.createConversation('a');
|
| 64 |
+
store.createConversation('b');
|
| 65 |
+
const convs = store.getConversations();
|
| 66 |
+
expect(convs[0].model).toBe('b'); // most recent first
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it('deleteConversation removes correct conversation', () => {
|
| 70 |
+
const c1 = store.createConversation('m1');
|
| 71 |
+
const c2 = store.createConversation('m2');
|
| 72 |
+
store.deleteConversation(c1.id);
|
| 73 |
+
const ids = store.getConversations().map(c => c.id);
|
| 74 |
+
expect(ids).not.toContain(c1.id);
|
| 75 |
+
expect(ids).toContain(c2.id);
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
it('addMessage appends to conversation', () => {
|
| 79 |
+
const conv = store.createConversation('m');
|
| 80 |
+
store.addMessage(conv.id, { role: 'user', content: 'hello', timestamp: new Date().toISOString() });
|
| 81 |
+
const updated = store.getCurrentConversation() ?? store.getConversations().find(c => c.id === conv.id);
|
| 82 |
+
expect(updated.messages).toHaveLength(1);
|
| 83 |
+
expect(updated.messages[0].content).toBe('hello');
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
it('updateConversationTitle updates correctly', () => {
|
| 87 |
+
const conv = store.createConversation('m');
|
| 88 |
+
store.updateConversationTitle(conv.id, 'My Title');
|
| 89 |
+
const updated = store.getConversations().find(c => c.id === conv.id);
|
| 90 |
+
expect(updated.title).toBe('My Title');
|
| 91 |
+
});
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
describe('store – clearMessages', () => {
|
| 95 |
+
it('clears all messages from a conversation', () => {
|
| 96 |
+
const conv = store.createConversation('m');
|
| 97 |
+
store.addMessage(conv.id, { role: 'user', content: 'hello', timestamp: 't1' });
|
| 98 |
+
store.addMessage(conv.id, { role: 'assistant', content: 'hi', timestamp: 't2' });
|
| 99 |
+
store.clearMessages(conv.id);
|
| 100 |
+
const updated = store.getConversations().find(c => c.id === conv.id);
|
| 101 |
+
expect(updated.messages).toHaveLength(0);
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it('preserves conversation metadata (title, model) after clearMessages', () => {
|
| 105 |
+
const conv = store.createConversation('my-model');
|
| 106 |
+
store.updateConversationTitle(conv.id, 'My Chat');
|
| 107 |
+
store.addMessage(conv.id, { role: 'user', content: 'msg', timestamp: 't1' });
|
| 108 |
+
store.clearMessages(conv.id);
|
| 109 |
+
const updated = store.getConversations().find(c => c.id === conv.id);
|
| 110 |
+
expect(updated.title).toBe('My Chat');
|
| 111 |
+
expect(updated.model).toBe('my-model');
|
| 112 |
+
expect(updated.messages).toHaveLength(0);
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
it('does nothing for non-existent conversation id', () => {
|
| 116 |
+
expect(() => store.clearMessages('no-such-id')).not.toThrow();
|
| 117 |
+
});
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
describe('store – model capabilities', () => {
|
| 121 |
+
it('starts empty', () => {
|
| 122 |
+
expect(store.getModelCapabilities()).toEqual({});
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it('saves and retrieves capabilities', () => {
|
| 126 |
+
store.saveModelCapabilities({ 'my-model': { text: true, image: true, audio: false } });
|
| 127 |
+
expect(store.getModelCapabilities()['my-model'].image).toBe(true);
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
it('scopes available models by baseUrl', () => {
|
| 131 |
+
store.saveAvailableModels('http://a.local', ['model-a', 'model-b']);
|
| 132 |
+
store.saveAvailableModels('http://b.local', ['model-c']);
|
| 133 |
+
expect(store.getAvailableModels('http://a.local')).toEqual(['model-a', 'model-b']);
|
| 134 |
+
expect(store.getAvailableModels('http://b.local')).toEqual(['model-c']);
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
it('scopes current model selection by baseUrl', () => {
|
| 138 |
+
store.setCurrentModel('http://a.local', 'model-a');
|
| 139 |
+
store.setCurrentModel('http://b.local', 'model-b');
|
| 140 |
+
expect(store.getCurrentModel('http://a.local')).toBe('model-a');
|
| 141 |
+
expect(store.getCurrentModel('http://b.local')).toBe('model-b');
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
it('scopes capability overrides by baseUrl', () => {
|
| 145 |
+
store.saveModelCapabilities('http://a.local', { 'model-a': { text: true, image: true, audio: false } });
|
| 146 |
+
store.saveModelCapabilities('http://b.local', { 'model-a': { text: true, image: false, audio: false } });
|
| 147 |
+
expect(store.getModelCapabilities('http://a.local')['model-a'].image).toBe(true);
|
| 148 |
+
expect(store.getModelCapabilities('http://b.local')['model-a'].image).toBe(false);
|
| 149 |
+
});
|
| 150 |
+
});
|
vite.config.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import http from 'node:http';
|
| 3 |
+
import https from 'node:https';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Dev-only CORS proxy middleware.
|
| 7 |
+
* Browser fetches /lw-proxy/<path> with X-LW-Target header → Vite server forwards to target (no CORS).
|
| 8 |
+
*/
|
| 9 |
+
const lwProxyPlugin = {
|
| 10 |
+
name: 'lw-proxy',
|
| 11 |
+
configureServer(server) {
|
| 12 |
+
server.middlewares.use('/lw-proxy', (req, res) => {
|
| 13 |
+
const target = req.headers['x-lw-target'];
|
| 14 |
+
if (!target) {
|
| 15 |
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
| 16 |
+
res.end(JSON.stringify({ error: 'Missing X-LW-Target header' }));
|
| 17 |
+
return;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
let targetUrl;
|
| 21 |
+
try {
|
| 22 |
+
// req.url is the path AFTER /lw-proxy (Connect strips the mount point)
|
| 23 |
+
targetUrl = new URL(req.url ?? '/', target);
|
| 24 |
+
} catch {
|
| 25 |
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
| 26 |
+
res.end(JSON.stringify({ error: 'Invalid target URL' }));
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const httpModule = targetUrl.protocol === 'https:' ? https : http;
|
| 31 |
+
|
| 32 |
+
// Forward headers, stripping browser/CORS-related ones
|
| 33 |
+
const fwdHeaders = {};
|
| 34 |
+
for (const [k, v] of Object.entries(req.headers)) {
|
| 35 |
+
const kl = k.toLowerCase();
|
| 36 |
+
if (kl === 'x-lw-target' || kl === 'host' || kl === 'origin' || kl === 'referer') continue;
|
| 37 |
+
fwdHeaders[k] = v;
|
| 38 |
+
}
|
| 39 |
+
fwdHeaders['host'] = targetUrl.host;
|
| 40 |
+
|
| 41 |
+
const options = {
|
| 42 |
+
hostname: targetUrl.hostname,
|
| 43 |
+
port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
|
| 44 |
+
path: targetUrl.pathname + (targetUrl.search || ''),
|
| 45 |
+
method: req.method,
|
| 46 |
+
headers: fwdHeaders,
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const proxyReq = httpModule.request(options, (proxyRes) => {
|
| 50 |
+
res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
|
| 51 |
+
proxyRes.pipe(res); // stream response directly (supports SSE)
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
proxyReq.on('error', (err) => {
|
| 55 |
+
if (!res.headersSent) {
|
| 56 |
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
| 57 |
+
}
|
| 58 |
+
res.end(JSON.stringify({ error: err.message }));
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
req.pipe(proxyReq); // forward request body (needed for POST /v1/chat/completions)
|
| 62 |
+
});
|
| 63 |
+
},
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
export default defineConfig({
|
| 67 |
+
plugins: [lwProxyPlugin],
|
| 68 |
+
build: {
|
| 69 |
+
target: 'esnext',
|
| 70 |
+
minify: 'esbuild',
|
| 71 |
+
chunkSizeWarningLimit: 600,
|
| 72 |
+
rollupOptions: {
|
| 73 |
+
output: {
|
| 74 |
+
manualChunks(id) {
|
| 75 |
+
if (id.includes('highlight.js')) return 'hljs';
|
| 76 |
+
if (id.includes('marked')) return 'marked';
|
| 77 |
+
},
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
},
|
| 81 |
+
test: {
|
| 82 |
+
environment: 'jsdom',
|
| 83 |
+
globals: true,
|
| 84 |
+
setupFiles: ['./tests/setup.js'],
|
| 85 |
+
},
|
| 86 |
+
});
|