blyon1995 commited on
Commit
ca51841
·
0 Parent(s):

init this repo

Browse files
.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
+ ![Settings screen](assets/settings.png)
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

  • SHA256: 59b211a6f5f5e9f0fa09a803a56fe7f6ae0b65dfca5913ebd628dcf3e711fcce
  • Pointer size: 131 Bytes
  • Size of remote file: 120 kB
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, '&amp;')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;');
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, '&amp;')
131
+ .replace(/</g, '&lt;')
132
+ .replace(/>/g, '&gt;')
133
+ .replace(/"/g, '&quot;');
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, '&amp;')
88
+ .replace(/</g, '&lt;')
89
+ .replace(/>/g, '&gt;')
90
+ .replace(/"/g, '&quot;')
91
+ .replace(/'/g, '&#39;');
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
+ });