@prismshadow/mmsp 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +121 -0
  2. package/dist/ant_messages/client.d.ts +59 -0
  3. package/dist/ant_messages/client.d.ts.map +1 -0
  4. package/dist/ant_messages/client.js +419 -0
  5. package/dist/ant_messages/index.d.ts +2 -0
  6. package/dist/ant_messages/index.d.ts.map +1 -0
  7. package/dist/ant_messages/index.js +18 -0
  8. package/dist/anthropic_official/client.d.ts +63 -0
  9. package/dist/anthropic_official/client.d.ts.map +1 -0
  10. package/dist/anthropic_official/client.js +540 -0
  11. package/dist/anthropic_official/index.d.ts +2 -0
  12. package/dist/anthropic_official/index.d.ts.map +1 -0
  13. package/dist/anthropic_official/index.js +18 -0
  14. package/dist/autoClient.d.ts +96 -0
  15. package/dist/autoClient.d.ts.map +1 -0
  16. package/dist/autoClient.js +258 -0
  17. package/dist/baseClient.d.ts +111 -0
  18. package/dist/baseClient.d.ts.map +1 -0
  19. package/dist/baseClient.js +284 -0
  20. package/dist/deepseek_official/client.d.ts +60 -0
  21. package/dist/deepseek_official/client.d.ts.map +1 -0
  22. package/dist/deepseek_official/client.js +407 -0
  23. package/dist/deepseek_official/index.d.ts +2 -0
  24. package/dist/deepseek_official/index.d.ts.map +1 -0
  25. package/dist/deepseek_official/index.js +18 -0
  26. package/dist/errors.d.ts +84 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +140 -0
  29. package/dist/gemini_generate_content/client.d.ts +86 -0
  30. package/dist/gemini_generate_content/client.d.ts.map +1 -0
  31. package/dist/gemini_generate_content/client.js +809 -0
  32. package/dist/gemini_generate_content/index.d.ts +2 -0
  33. package/dist/gemini_generate_content/index.d.ts.map +1 -0
  34. package/dist/gemini_generate_content/index.js +18 -0
  35. package/dist/gemini_official/client.d.ts +87 -0
  36. package/dist/gemini_official/client.d.ts.map +1 -0
  37. package/dist/gemini_official/client.js +780 -0
  38. package/dist/gemini_official/index.d.ts +2 -0
  39. package/dist/gemini_official/index.d.ts.map +1 -0
  40. package/dist/gemini_official/index.js +18 -0
  41. package/dist/index.d.ts +6 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +44 -0
  44. package/dist/integration/index.d.ts +1 -0
  45. package/dist/integration/index.d.ts.map +1 -0
  46. package/dist/integration/index.js +14 -0
  47. package/dist/integration/playground.d.ts +17 -0
  48. package/dist/integration/playground.d.ts.map +1 -0
  49. package/dist/integration/playground.js +3246 -0
  50. package/dist/integration/tracer.d.ts +188 -0
  51. package/dist/integration/tracer.d.ts.map +1 -0
  52. package/dist/integration/tracer.js +1928 -0
  53. package/dist/legacy.d.ts +13 -0
  54. package/dist/legacy.d.ts.map +1 -0
  55. package/dist/legacy.js +59 -0
  56. package/dist/minimax_official/client.d.ts +43 -0
  57. package/dist/minimax_official/client.d.ts.map +1 -0
  58. package/dist/minimax_official/client.js +346 -0
  59. package/dist/minimax_official/index.d.ts +2 -0
  60. package/dist/minimax_official/index.d.ts.map +1 -0
  61. package/dist/minimax_official/index.js +18 -0
  62. package/dist/moonshot_official/client.d.ts +73 -0
  63. package/dist/moonshot_official/client.d.ts.map +1 -0
  64. package/dist/moonshot_official/client.js +477 -0
  65. package/dist/moonshot_official/index.d.ts +2 -0
  66. package/dist/moonshot_official/index.d.ts.map +1 -0
  67. package/dist/moonshot_official/index.js +18 -0
  68. package/dist/openai_chat/client.d.ts +67 -0
  69. package/dist/openai_chat/client.d.ts.map +1 -0
  70. package/dist/openai_chat/client.js +419 -0
  71. package/dist/openai_chat/index.d.ts +2 -0
  72. package/dist/openai_chat/index.d.ts.map +1 -0
  73. package/dist/openai_chat/index.js +18 -0
  74. package/dist/openai_chat_vllm_adapter/client.d.ts +15 -0
  75. package/dist/openai_chat_vllm_adapter/client.d.ts.map +1 -0
  76. package/dist/openai_chat_vllm_adapter/client.js +118 -0
  77. package/dist/openai_chat_vllm_adapter/index.d.ts +2 -0
  78. package/dist/openai_chat_vllm_adapter/index.d.ts.map +1 -0
  79. package/dist/openai_chat_vllm_adapter/index.js +5 -0
  80. package/dist/openai_embedding/client.d.ts +46 -0
  81. package/dist/openai_embedding/client.d.ts.map +1 -0
  82. package/dist/openai_embedding/client.js +124 -0
  83. package/dist/openai_embedding/index.d.ts +2 -0
  84. package/dist/openai_embedding/index.d.ts.map +1 -0
  85. package/dist/openai_embedding/index.js +18 -0
  86. package/dist/openai_official/client.d.ts +61 -0
  87. package/dist/openai_official/client.d.ts.map +1 -0
  88. package/dist/openai_official/client.js +464 -0
  89. package/dist/openai_official/index.d.ts +2 -0
  90. package/dist/openai_official/index.d.ts.map +1 -0
  91. package/dist/openai_official/index.js +18 -0
  92. package/dist/openai_responses/client.d.ts +61 -0
  93. package/dist/openai_responses/client.d.ts.map +1 -0
  94. package/dist/openai_responses/client.js +449 -0
  95. package/dist/openai_responses/index.d.ts +2 -0
  96. package/dist/openai_responses/index.d.ts.map +1 -0
  97. package/dist/openai_responses/index.js +18 -0
  98. package/dist/registry.d.ts +49 -0
  99. package/dist/registry.d.ts.map +1 -0
  100. package/dist/registry.js +798 -0
  101. package/dist/streamItems.d.ts +36 -0
  102. package/dist/streamItems.d.ts.map +1 -0
  103. package/dist/streamItems.js +183 -0
  104. package/dist/types.d.ts +190 -0
  105. package/dist/types.d.ts.map +1 -0
  106. package/dist/types.js +37 -0
  107. package/dist/utils.d.ts +99 -0
  108. package/dist/utils.d.ts.map +1 -0
  109. package/dist/utils.js +312 -0
  110. package/dist/zai_official/client.d.ts +78 -0
  111. package/dist/zai_official/client.d.ts.map +1 -0
  112. package/dist/zai_official/client.js +420 -0
  113. package/dist/zai_official/index.d.ts +2 -0
  114. package/dist/zai_official/index.d.ts.map +1 -0
  115. package/dist/zai_official/index.js +18 -0
  116. package/package.json +68 -0
@@ -0,0 +1,3246 @@
1
+ "use strict";
2
+ // Copyright 2025 Prism Shadow. and/or its affiliates
3
+ //
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // you may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ //
8
+ // http://www.apache.org/licenses/LICENSE-2.0
9
+ //
10
+ // Unless required by applicable law or agreed to in writing, software
11
+ // distributed under the License is distributed on an "AS IS" BASIS,
12
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ // See the License for the specific language governing permissions and
14
+ // limitations under the License.
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.createChatApp = createChatApp;
20
+ exports.startPlaygroundServer = startPlaygroundServer;
21
+ /**
22
+ * Playground for interacting with LLMs.
23
+ *
24
+ * This module provides a web interface for chatting with language models,
25
+ * with support for config editing, streaming responses, and message cards
26
+ * showing token usage and stop reasons.
27
+ */
28
+ const express_1 = __importDefault(require("express"));
29
+ const autoClient_1 = require("../autoClient");
30
+ const tracer_1 = require("./tracer");
31
+ const sessionClients = new Map();
32
+ const sessionClientOptions = new Map();
33
+ const sessionAbortControllers = new Map();
34
+ function normalizeOptionalString(value) {
35
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
36
+ }
37
+ function getClientOptions(config) {
38
+ return {
39
+ model: normalizeOptionalString(config.model) || "gpt-5.6-luna",
40
+ apiKey: normalizeOptionalString(config.api_key),
41
+ baseUrl: normalizeOptionalString(config.base_url),
42
+ clientType: normalizeOptionalString(config.client_type),
43
+ defaultHeaders: config.default_headers,
44
+ };
45
+ }
46
+ function getRequestConfig(config) {
47
+ const requestConfig = { ...config };
48
+ delete requestConfig.model;
49
+ delete requestConfig.api_key;
50
+ delete requestConfig.base_url;
51
+ delete requestConfig.client_type;
52
+ delete requestConfig.default_headers;
53
+ return requestConfig;
54
+ }
55
+ function clientOptionsChanged(previous, next) {
56
+ return (!previous ||
57
+ previous.model !== next.model ||
58
+ previous.apiKey !== next.apiKey ||
59
+ previous.baseUrl !== next.baseUrl ||
60
+ previous.clientType !== next.clientType ||
61
+ JSON.stringify(previous.defaultHeaders) !==
62
+ JSON.stringify(next.defaultHeaders));
63
+ }
64
+ /**
65
+ * Serialize objects for JSON, converting Buffer to base64.
66
+ *
67
+ * @param obj - Object to serialize
68
+ * @returns JSON-serializable object
69
+ */
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ function serializeForJson(obj) {
72
+ if (Buffer.isBuffer(obj)) {
73
+ return obj.toString("base64");
74
+ }
75
+ else if (obj && typeof obj === "object") {
76
+ if (Array.isArray(obj)) {
77
+ return obj.map((item) => serializeForJson(item));
78
+ }
79
+ else {
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ const result = {};
82
+ for (const [key, value] of Object.entries(obj)) {
83
+ result[key] = serializeForJson(value);
84
+ }
85
+ return result;
86
+ }
87
+ }
88
+ return obj;
89
+ }
90
+ /**
91
+ * Create an Express web application for chatting with LLMs.
92
+ *
93
+ * @returns Express application instance
94
+ */
95
+ // The endpoint each client type reaches when it is given no base URL: the environment's, else the
96
+ // vendor's own. The playground fills the Base URL field with it and leaves it out of requests.
97
+ const DEFAULT_BASE_URLS = {
98
+ "openai-official": ["OPENAI_BASE_URL", "https://api.openai.com/v1"],
99
+ "anthropic-official": ["ANTHROPIC_BASE_URL", "https://api.anthropic.com"],
100
+ "gemini-official": [
101
+ "GEMINI_BASE_URL",
102
+ "https://generativelanguage.googleapis.com",
103
+ ],
104
+ "zai-official": ["ZAI_BASE_URL", "https://api.z.ai/api/paas/v4/"],
105
+ "moonshot-official": ["MOONSHOT_BASE_URL", "https://api.moonshot.cn/v1"],
106
+ "deepseek-official": ["DEEPSEEK_BASE_URL", "https://api.deepseek.com"],
107
+ "minimax-official": ["MINIMAX_BASE_URL", "https://api.minimax.io/v1"],
108
+ "openai-responses": ["OPENAI_BASE_URL", "https://api.openai.com/v1"],
109
+ "openai-chat": ["OPENAI_BASE_URL", "https://api.openai.com/v1"],
110
+ "openai-chat-vllm-adapter": ["OPENAI_BASE_URL", "https://api.openai.com/v1"],
111
+ "openai-embedding": ["OPENAI_BASE_URL", "https://api.openai.com/v1"],
112
+ "ant-messages": ["ANTHROPIC_BASE_URL", "https://api.anthropic.com"],
113
+ "gemini-generate-content": [
114
+ "GEMINI_BASE_URL",
115
+ "https://generativelanguage.googleapis.com",
116
+ ],
117
+ };
118
+ /**
119
+ * The client types, their model families and default endpoints, as the page script reads them.
120
+ */
121
+ function playgroundDefaults() {
122
+ return JSON.stringify({
123
+ official: autoClient_1.OFFICIAL_CLIENT_TYPES,
124
+ compatible: autoClient_1.COMPATIBLE_CLIENT_TYPES,
125
+ families: autoClient_1.MODEL_FAMILIES,
126
+ baseUrls: Object.fromEntries(Object.entries(DEFAULT_BASE_URLS).map(([name, [env, url]]) => [
127
+ name,
128
+ process.env[env] || url,
129
+ ])),
130
+ });
131
+ }
132
+ function createChatApp() {
133
+ const app = (0, express_1.default)();
134
+ app.use(express_1.default.json({ limit: "50mb" }));
135
+ app.use((err, _req, res, next) => {
136
+ if (err.status === 413 || err.type === "entity.too.large") {
137
+ return res.status(413).json({
138
+ error: "Request body is too large. Please upload fewer or smaller images.",
139
+ });
140
+ }
141
+ next(err);
142
+ });
143
+ app.use("/tracer", new tracer_1.Tracer().createWebApp({ basePath: "/tracer" }));
144
+ const CHAT_TEMPLATE = `
145
+ <!DOCTYPE html>
146
+ <html lang="en">
147
+ <head>
148
+ <title>MMSP Playground</title>
149
+ <meta charset="utf-8">
150
+ <meta name="viewport" content="width=device-width, initial-scale=1">
151
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Cstyle%3E.d{fill:%23111116}@media (prefers-color-scheme: dark){.d{fill:%232a2a33}}%3C/style%3E%3Crect width=%2232%22 height=%2232%22 rx=%227%22 class=%22d%22/%3E%3Cpath d=%22M0 16V7a7 7 0 0 1 7-7h9v16Z%22 fill=%22%237c4dff%22/%3E%3Cpath d=%22M16 16h16v9a7 7 0 0 1-7 7h-9Z%22 fill=%22%237c4dff%22/%3E%3Cg fill=%22%23fff%22 font-family=%22ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif%22 font-size=%2210.5%22 font-weight=%22700%22 text-anchor=%22middle%22 dominant-baseline=%22central%22%3E%3Ctext x=%228.5%22 y=%228.5%22%3EM%3C/text%3E%3Ctext x=%2223.5%22 y=%228.5%22%3EM%3C/text%3E%3Ctext x=%228.5%22 y=%2223.5%22%3ES%3C/text%3E%3Ctext x=%2223.5%22 y=%2223.5%22%3EP%3C/text%3E%3C/g%3E%3C/svg%3E">
152
+ <link rel="preconnect" href="https://fonts.googleapis.com">
153
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
154
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
155
+ <script>
156
+ // the stored theme applies before the first paint; without one the page follows the system
157
+ try {
158
+ const theme = localStorage.getItem('mmsp.playground.theme');
159
+ if (theme === 'light' || theme === 'dark') {
160
+ document.documentElement.dataset.theme = theme;
161
+ }
162
+ } catch (error) {
163
+ // storage refused: the system theme it is
164
+ }
165
+ </script>
166
+ <style>
167
+ :root {
168
+ --bg: #f5f5f6;
169
+ --panel: #fafafa;
170
+ --surface: #ffffff;
171
+ --raised: #f0f0f2;
172
+ --hover: rgba(20, 22, 28, 0.05);
173
+ --ring: rgba(20, 22, 28, 0.09);
174
+ --ring-strong: rgba(20, 22, 28, 0.17);
175
+ --text: #16181d;
176
+ --muted: #5c616c;
177
+ --subtle: #8a8f99;
178
+ --accent: #2f6fed;
179
+ --accent-soft: rgba(47, 111, 237, 0.14);
180
+ --on-accent: #ffffff;
181
+ --green: #16945b;
182
+ --green-soft: rgba(22, 148, 91, 0.12);
183
+ --amber: #b16a0a;
184
+ --amber-soft: rgba(177, 106, 10, 0.12);
185
+ --red: #d23b3b;
186
+ --red-soft: rgba(210, 59, 59, 0.1);
187
+ --shadow-card: 0 0 0 1px var(--ring), 0 1px 2px rgba(20, 22, 28, 0.04);
188
+ --shadow-menu: 0 0 0 1px var(--ring), 0 12px 32px -10px rgba(20, 22, 28, 0.22);
189
+ --shadow-composer: 0 0 0 1px var(--ring), 0 10px 30px -14px rgba(20, 22, 28, 0.25);
190
+ --ease: cubic-bezier(0.23, 1, 0.32, 1);
191
+ --font: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
192
+ --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
193
+ color-scheme: light;
194
+ }
195
+
196
+ @media (prefers-color-scheme: dark) {
197
+ :root:not([data-theme="light"]) {
198
+ --bg: #1b1c1f;
199
+ --panel: #18191c;
200
+ --surface: #222327;
201
+ --raised: #28292e;
202
+ --hover: rgba(255, 255, 255, 0.05);
203
+ --ring: rgba(255, 255, 255, 0.08);
204
+ --ring-strong: rgba(255, 255, 255, 0.15);
205
+ --text: #eceef1;
206
+ --muted: #a3a8b1;
207
+ --subtle: #6f747e;
208
+ --accent: #4d8ef7;
209
+ --accent-soft: rgba(77, 142, 247, 0.2);
210
+ --green: #43c283;
211
+ --green-soft: rgba(67, 194, 131, 0.14);
212
+ --amber: #e3a646;
213
+ --amber-soft: rgba(227, 166, 70, 0.14);
214
+ --red: #f06a6a;
215
+ --red-soft: rgba(240, 106, 106, 0.14);
216
+ --shadow-card: 0 0 0 1px var(--ring), 0 1px 2px rgba(0, 0, 0, 0.3);
217
+ --shadow-menu: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 16px 36px -10px rgba(0, 0, 0, 0.65);
218
+ --shadow-composer: 0 0 0 1px rgba(255, 255, 255, 0.09), 0 14px 36px -14px rgba(0, 0, 0, 0.7);
219
+ color-scheme: dark;
220
+ }
221
+ }
222
+
223
+ :root[data-theme="dark"] {
224
+ --bg: #1b1c1f;
225
+ --panel: #18191c;
226
+ --surface: #222327;
227
+ --raised: #28292e;
228
+ --hover: rgba(255, 255, 255, 0.05);
229
+ --ring: rgba(255, 255, 255, 0.08);
230
+ --ring-strong: rgba(255, 255, 255, 0.15);
231
+ --text: #eceef1;
232
+ --muted: #a3a8b1;
233
+ --subtle: #6f747e;
234
+ --accent: #4d8ef7;
235
+ --accent-soft: rgba(77, 142, 247, 0.2);
236
+ --green: #43c283;
237
+ --green-soft: rgba(67, 194, 131, 0.14);
238
+ --amber: #e3a646;
239
+ --amber-soft: rgba(227, 166, 70, 0.14);
240
+ --red: #f06a6a;
241
+ --red-soft: rgba(240, 106, 106, 0.14);
242
+ --shadow-card: 0 0 0 1px var(--ring), 0 1px 2px rgba(0, 0, 0, 0.3);
243
+ --shadow-menu: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 16px 36px -10px rgba(0, 0, 0, 0.65);
244
+ --shadow-composer: 0 0 0 1px rgba(255, 255, 255, 0.09), 0 14px 36px -14px rgba(0, 0, 0, 0.7);
245
+ color-scheme: dark;
246
+ }
247
+
248
+ *, *::before, *::after {
249
+ box-sizing: border-box;
250
+ }
251
+
252
+ html, body {
253
+ height: 100%;
254
+ margin: 0;
255
+ }
256
+
257
+ body {
258
+ background: var(--bg);
259
+ color: var(--text);
260
+ font: 14px/1.55 var(--font);
261
+ -webkit-font-smoothing: antialiased;
262
+ text-rendering: optimizeLegibility;
263
+ }
264
+
265
+ button, input, textarea {
266
+ font: inherit;
267
+ color: inherit;
268
+ }
269
+
270
+ button {
271
+ cursor: pointer;
272
+ background: none;
273
+ border: 0;
274
+ padding: 0;
275
+ }
276
+
277
+ button:disabled {
278
+ cursor: not-allowed;
279
+ }
280
+
281
+ a {
282
+ color: inherit;
283
+ text-decoration: none;
284
+ }
285
+
286
+ svg {
287
+ flex: none;
288
+ }
289
+
290
+ .hidden {
291
+ display: none !important;
292
+ }
293
+
294
+ .mono {
295
+ font-family: var(--mono);
296
+ }
297
+
298
+ :focus-visible {
299
+ outline: 2px solid var(--accent);
300
+ outline-offset: 2px;
301
+ }
302
+
303
+ ::selection {
304
+ background: var(--accent-soft);
305
+ }
306
+
307
+ /* layout */
308
+
309
+ .app {
310
+ display: flex;
311
+ height: 100vh;
312
+ height: 100dvh;
313
+ overflow: hidden;
314
+ }
315
+
316
+ .sidebar {
317
+ width: 320px;
318
+ flex: none;
319
+ display: flex;
320
+ flex-direction: column;
321
+ background: var(--panel);
322
+ box-shadow: 1px 0 0 var(--ring);
323
+ z-index: 20;
324
+ transition: margin-left 0.3s var(--ease);
325
+ }
326
+
327
+ .app.sidebar-collapsed .sidebar {
328
+ margin-left: -321px;
329
+ }
330
+
331
+ .sidebar-head {
332
+ display: flex;
333
+ align-items: center;
334
+ justify-content: space-between;
335
+ gap: 12px;
336
+ height: 56px;
337
+ padding: 0 16px 0 20px;
338
+ flex: none;
339
+ }
340
+
341
+ .brand {
342
+ display: flex;
343
+ align-items: center;
344
+ gap: 8px;
345
+ min-width: 0;
346
+ }
347
+
348
+
349
+ .brand-mark {
350
+ width: 22px;
351
+ height: 22px;
352
+ flex: none;
353
+ align-self: center;
354
+ }
355
+
356
+ .mark-bg {
357
+ fill: #111116;
358
+ }
359
+
360
+ @media (prefers-color-scheme: dark) {
361
+ :root:not([data-theme="light"]) .mark-bg {
362
+ fill: #2a2a33;
363
+ }
364
+ }
365
+
366
+ :root[data-theme="dark"] .mark-bg {
367
+ fill: #2a2a33;
368
+ }
369
+
370
+ .brand-name {
371
+ margin: 0;
372
+ font-size: 15px;
373
+ font-weight: 600;
374
+ letter-spacing: -0.01em;
375
+ }
376
+
377
+ .brand-sub {
378
+ color: var(--subtle);
379
+ font-size: 13px;
380
+ }
381
+
382
+ .sidebar-scroll {
383
+ flex: 1;
384
+ overflow-y: auto;
385
+ padding: 4px 20px 28px;
386
+ scrollbar-gutter: stable;
387
+ }
388
+
389
+ .group + .group {
390
+ margin-top: 28px;
391
+ }
392
+
393
+ .group-title {
394
+ display: flex;
395
+ align-items: center;
396
+ gap: 10px;
397
+ margin-bottom: 14px;
398
+ color: var(--subtle);
399
+ font-size: 12px;
400
+ font-weight: 500;
401
+ }
402
+
403
+ .group-title::after {
404
+ content: "";
405
+ flex: 1;
406
+ height: 1px;
407
+ background: var(--ring);
408
+ }
409
+
410
+ .field + .field {
411
+ margin-top: 14px;
412
+ }
413
+
414
+ .field-head {
415
+ display: flex;
416
+ align-items: center;
417
+ justify-content: space-between;
418
+ gap: 8px;
419
+ margin-bottom: 6px;
420
+ min-height: 18px;
421
+ }
422
+
423
+ .field-label {
424
+ color: var(--muted);
425
+ font-size: 12.5px;
426
+ font-weight: 500;
427
+ }
428
+
429
+ .field-note {
430
+ margin: 6px 0 0;
431
+ color: var(--subtle);
432
+ font-size: 12px;
433
+ line-height: 1.45;
434
+ }
435
+
436
+ .field-error {
437
+ margin: 6px 0 0;
438
+ color: var(--red);
439
+ font-size: 12px;
440
+ line-height: 1.45;
441
+ overflow-wrap: anywhere;
442
+ }
443
+
444
+ .link-btn {
445
+ color: var(--accent);
446
+ font-size: 12px;
447
+ font-weight: 500;
448
+ border-radius: 4px;
449
+ transition: opacity 0.15s;
450
+ }
451
+
452
+ .link-btn:hover {
453
+ opacity: 0.8;
454
+ }
455
+
456
+ .link-btn:disabled {
457
+ opacity: 0.5;
458
+ }
459
+
460
+ .status-text {
461
+ color: var(--subtle);
462
+ font-size: 12px;
463
+ }
464
+
465
+ .control {
466
+ display: block;
467
+ width: 100%;
468
+ min-height: 34px;
469
+ padding: 7px 10px;
470
+ background: var(--surface);
471
+ border: 0;
472
+ border-radius: 8px;
473
+ box-shadow: 0 0 0 1px var(--ring);
474
+ font-size: 13px;
475
+ line-height: 20px;
476
+ transition: box-shadow 0.15s var(--ease), background-color 0.15s;
477
+ }
478
+
479
+ .control::placeholder {
480
+ color: var(--subtle);
481
+ }
482
+
483
+ .control:hover {
484
+ box-shadow: 0 0 0 1px var(--ring-strong);
485
+ }
486
+
487
+ .control:focus, .control:focus-visible {
488
+ outline: none;
489
+ box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-soft);
490
+ }
491
+
492
+ .control.invalid {
493
+ box-shadow: 0 0 0 1px var(--red), 0 0 0 4px var(--red-soft);
494
+ }
495
+
496
+ textarea.control {
497
+ resize: vertical;
498
+ min-height: 64px;
499
+ }
500
+
501
+ .control.code {
502
+ font-family: var(--mono);
503
+ font-size: 12px;
504
+ line-height: 18px;
505
+ }
506
+
507
+ .input-wrap {
508
+ position: relative;
509
+ }
510
+
511
+ .input-wrap .control {
512
+ padding-right: 40px;
513
+ }
514
+
515
+ .input-action {
516
+ position: absolute;
517
+ top: 3px;
518
+ right: 3px;
519
+ width: 28px;
520
+ height: 28px;
521
+ display: grid;
522
+ place-items: center;
523
+ border-radius: 6px;
524
+ color: var(--subtle);
525
+ transition: color 0.15s, background-color 0.15s;
526
+ }
527
+
528
+ .input-action:hover {
529
+ color: var(--text);
530
+ background: var(--hover);
531
+ }
532
+
533
+ .input-tag {
534
+ position: absolute;
535
+ top: 50%;
536
+ right: 8px;
537
+ transform: translateY(-50%);
538
+ padding: 1px 6px;
539
+ border-radius: 999px;
540
+ background: var(--raised);
541
+ color: var(--subtle);
542
+ font-size: 11px;
543
+ pointer-events: none;
544
+ }
545
+
546
+ /* comboboxes */
547
+
548
+ [data-combobox] {
549
+ position: relative;
550
+ }
551
+
552
+ .combo-button {
553
+ display: flex;
554
+ align-items: center;
555
+ justify-content: space-between;
556
+ gap: 8px;
557
+ text-align: left;
558
+ }
559
+
560
+ .combo-button[aria-expanded="true"] {
561
+ box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-soft);
562
+ }
563
+
564
+ .combo-button [data-combobox-label] {
565
+ overflow: hidden;
566
+ text-overflow: ellipsis;
567
+ white-space: nowrap;
568
+ }
569
+
570
+ .combo-button svg {
571
+ color: var(--subtle);
572
+ transition: transform 0.2s var(--ease);
573
+ }
574
+
575
+ .combo-button[aria-expanded="true"] svg {
576
+ transform: rotate(180deg);
577
+ }
578
+
579
+ [data-combobox-menu] {
580
+ position: absolute;
581
+ z-index: 30;
582
+ top: calc(100% + 6px);
583
+ left: 0;
584
+ right: 0;
585
+ max-height: 320px;
586
+ overflow-y: auto;
587
+ padding: 4px;
588
+ background: var(--surface);
589
+ border-radius: 10px;
590
+ box-shadow: var(--shadow-menu);
591
+ transform-origin: top center;
592
+ animation: menu-in 0.18s var(--ease);
593
+ }
594
+
595
+ @keyframes menu-in {
596
+ from {
597
+ opacity: 0;
598
+ transform: translateY(-4px) scale(0.98);
599
+ }
600
+ }
601
+
602
+ .menu-search {
603
+ position: sticky;
604
+ top: -4px;
605
+ margin: -4px -4px 4px;
606
+ padding: 8px 8px 6px;
607
+ background: var(--surface);
608
+ box-shadow: 0 1px 0 var(--ring);
609
+ z-index: 1;
610
+ }
611
+
612
+ .menu-search input {
613
+ width: 100%;
614
+ height: 30px;
615
+ padding: 0 8px;
616
+ border: 0;
617
+ border-radius: 6px;
618
+ background: var(--raised);
619
+ font-size: 13px;
620
+ }
621
+
622
+ .menu-search input:focus {
623
+ outline: none;
624
+ box-shadow: 0 0 0 1px var(--accent);
625
+ }
626
+
627
+ .menu-heading {
628
+ padding: 8px 8px 4px;
629
+ color: var(--subtle);
630
+ font-size: 11.5px;
631
+ font-weight: 500;
632
+ }
633
+
634
+ .menu-empty {
635
+ padding: 10px 8px;
636
+ color: var(--subtle);
637
+ font-size: 12.5px;
638
+ }
639
+
640
+ .combo-option {
641
+ position: relative;
642
+ display: block;
643
+ width: 100%;
644
+ padding: 6px 28px 6px 8px;
645
+ border-radius: 6px;
646
+ text-align: left;
647
+ font-size: 13px;
648
+ line-height: 18px;
649
+ transition: background-color 0.1s;
650
+ }
651
+
652
+ .combo-option span {
653
+ display: block;
654
+ overflow: hidden;
655
+ text-overflow: ellipsis;
656
+ white-space: nowrap;
657
+ }
658
+
659
+ .combo-option::after {
660
+ content: attr(data-description);
661
+ display: block;
662
+ overflow: hidden;
663
+ text-overflow: ellipsis;
664
+ white-space: nowrap;
665
+ color: var(--subtle);
666
+ font-family: var(--mono);
667
+ font-size: 11px;
668
+ line-height: 16px;
669
+ }
670
+
671
+ .combo-option[data-description=""]::after {
672
+ display: none;
673
+ }
674
+
675
+ .combo-option:hover, .combo-option:focus-visible {
676
+ outline: none;
677
+ background: var(--hover);
678
+ }
679
+
680
+ .combo-option[aria-selected="true"] {
681
+ background: var(--accent-soft);
682
+ }
683
+
684
+ .combo-option[aria-selected="true"]::before {
685
+ content: "";
686
+ position: absolute;
687
+ right: 9px;
688
+ top: 50%;
689
+ width: 10px;
690
+ height: 6px;
691
+ margin-top: -5px;
692
+ border-left: 1.75px solid var(--accent);
693
+ border-bottom: 1.75px solid var(--accent);
694
+ transform: rotate(-45deg);
695
+ }
696
+
697
+ /* segmented controls */
698
+
699
+ .segmented {
700
+ position: relative;
701
+ display: flex;
702
+ padding: 3px;
703
+ border-radius: 9px;
704
+ background: var(--raised);
705
+ box-shadow: inset 0 0 0 1px var(--ring);
706
+ }
707
+
708
+ .segmented button {
709
+ position: relative;
710
+ z-index: 1;
711
+ flex: 1;
712
+ min-width: 0;
713
+ height: 26px;
714
+ padding: 0 6px;
715
+ border-radius: 6px;
716
+ color: var(--muted);
717
+ font-size: 12.5px;
718
+ font-weight: 500;
719
+ white-space: nowrap;
720
+ transition: color 0.15s;
721
+ }
722
+
723
+ .segmented button:hover {
724
+ color: var(--text);
725
+ }
726
+
727
+ .segmented button[aria-checked="true"] {
728
+ color: var(--text);
729
+ }
730
+
731
+ .seg-thumb {
732
+ position: absolute;
733
+ top: 3px;
734
+ bottom: 3px;
735
+ left: 0;
736
+ width: 0;
737
+ border-radius: 6px;
738
+ background: var(--surface);
739
+ box-shadow: 0 0 0 1px var(--ring), 0 1px 2px rgba(0, 0, 0, 0.12);
740
+ transition: transform 0.25s var(--ease), width 0.25s var(--ease);
741
+ }
742
+
743
+ .theme-toggle {
744
+ width: 64px;
745
+ flex: none;
746
+ }
747
+
748
+ .theme-toggle button {
749
+ display: grid;
750
+ place-items: center;
751
+ padding: 0;
752
+ }
753
+
754
+ /* main column */
755
+
756
+ .main {
757
+ position: relative;
758
+ flex: 1;
759
+ min-width: 0;
760
+ display: flex;
761
+ flex-direction: column;
762
+ }
763
+
764
+ .topbar {
765
+ display: flex;
766
+ align-items: center;
767
+ gap: 10px;
768
+ height: 56px;
769
+ padding: 0 14px;
770
+ flex: none;
771
+ box-shadow: 0 1px 0 var(--ring);
772
+ }
773
+
774
+ .topbar-model {
775
+ display: flex;
776
+ align-items: baseline;
777
+ gap: 8px;
778
+ min-width: 0;
779
+ }
780
+
781
+ .topbar-model #headerModel {
782
+ overflow: hidden;
783
+ text-overflow: ellipsis;
784
+ white-space: nowrap;
785
+ font-weight: 500;
786
+ }
787
+
788
+ .topbar-model #headerClientType {
789
+ color: var(--subtle);
790
+ font-size: 12px;
791
+ white-space: nowrap;
792
+ }
793
+
794
+ .topbar-actions {
795
+ display: flex;
796
+ align-items: center;
797
+ gap: 4px;
798
+ margin-left: auto;
799
+ }
800
+
801
+ .icon-btn {
802
+ display: inline-grid;
803
+ place-items: center;
804
+ width: 32px;
805
+ height: 32px;
806
+ border-radius: 8px;
807
+ color: var(--muted);
808
+ transition: color 0.15s, background-color 0.15s;
809
+ }
810
+
811
+ .icon-btn:hover {
812
+ color: var(--text);
813
+ background: var(--hover);
814
+ }
815
+
816
+ .ghost-btn {
817
+ display: inline-flex;
818
+ align-items: center;
819
+ gap: 6px;
820
+ height: 32px;
821
+ padding: 0 10px;
822
+ border-radius: 8px;
823
+ color: var(--muted);
824
+ font-size: 13px;
825
+ font-weight: 500;
826
+ white-space: nowrap;
827
+ transition: color 0.15s, background-color 0.15s;
828
+ }
829
+
830
+ .ghost-btn:hover {
831
+ color: var(--text);
832
+ background: var(--hover);
833
+ }
834
+
835
+ .messages {
836
+ flex: 1;
837
+ overflow-y: auto;
838
+ scrollbar-gutter: stable both-edges;
839
+ }
840
+
841
+ .thread {
842
+ max-width: 760px;
843
+ margin: 0 auto;
844
+ padding: 32px 20px 24px;
845
+ }
846
+
847
+ .empty-state {
848
+ display: flex;
849
+ flex-direction: column;
850
+ align-items: center;
851
+ justify-content: center;
852
+ gap: 6px;
853
+ min-height: 100%;
854
+ padding: 40px 20px;
855
+ text-align: center;
856
+ }
857
+
858
+ .empty-state h2 {
859
+ margin: 0;
860
+ font-size: 20px;
861
+ font-weight: 600;
862
+ letter-spacing: -0.015em;
863
+ }
864
+
865
+ .empty-state p {
866
+ margin: 0;
867
+ color: var(--muted);
868
+ }
869
+
870
+ /* messages */
871
+
872
+ .msg {
873
+ margin-bottom: 28px;
874
+ animation: msg-in 0.35s var(--ease);
875
+ }
876
+
877
+ @keyframes msg-in {
878
+ from {
879
+ opacity: 0;
880
+ transform: translateY(6px);
881
+ }
882
+ }
883
+
884
+ .msg-head {
885
+ display: flex;
886
+ align-items: center;
887
+ gap: 8px;
888
+ margin-bottom: 8px;
889
+ color: var(--subtle);
890
+ font-size: 12px;
891
+ }
892
+
893
+ .msg-model {
894
+ color: var(--muted);
895
+ font-weight: 500;
896
+ }
897
+
898
+ .msg-user {
899
+ display: flex;
900
+ flex-direction: column;
901
+ align-items: flex-end;
902
+ }
903
+
904
+ .msg-user .msg-head {
905
+ margin-bottom: 6px;
906
+ }
907
+
908
+ .bubble {
909
+ max-width: min(85%, 600px);
910
+ padding: 10px 14px;
911
+ border-radius: 18px 18px 6px 18px;
912
+ background: var(--raised);
913
+ box-shadow: inset 0 0 0 1px var(--ring);
914
+ }
915
+
916
+ .bubble-images {
917
+ display: flex;
918
+ flex-wrap: wrap;
919
+ justify-content: flex-end;
920
+ gap: 6px;
921
+ margin-bottom: 6px;
922
+ }
923
+
924
+ .bubble-images img {
925
+ max-width: 220px;
926
+ max-height: 220px;
927
+ border-radius: 12px;
928
+ object-fit: cover;
929
+ }
930
+
931
+ .bubble-images:last-child {
932
+ margin-bottom: 0;
933
+ }
934
+
935
+ .message-content {
936
+ white-space: pre-wrap;
937
+ overflow-wrap: anywhere;
938
+ }
939
+
940
+ .message-content:empty {
941
+ display: none;
942
+ }
943
+
944
+ .msg-assistant .message-content {
945
+ white-space: normal;
946
+ }
947
+
948
+ .msg-assistant .message-content:empty {
949
+ display: block;
950
+ }
951
+
952
+ .text-content {
953
+ white-space: pre-wrap;
954
+ overflow-wrap: anywhere;
955
+ line-height: 1.65;
956
+ }
957
+
958
+ .text-content + *, * + .text-content {
959
+ margin-top: 10px;
960
+ }
961
+
962
+ .pending {
963
+ display: inline-flex;
964
+ align-items: center;
965
+ gap: 8px;
966
+ color: var(--muted);
967
+ font-size: 13px;
968
+ }
969
+
970
+ .pending-grid {
971
+ display: grid;
972
+ grid-template-columns: repeat(3, 3px);
973
+ gap: 2px;
974
+ }
975
+
976
+ .pending-grid i {
977
+ width: 3px;
978
+ height: 3px;
979
+ border-radius: 1px;
980
+ background: var(--subtle);
981
+ animation: pixel 1.2s infinite ease-in-out;
982
+ }
983
+
984
+ .pending-grid i:nth-child(2), .pending-grid i:nth-child(4) { animation-delay: 0.15s; }
985
+ .pending-grid i:nth-child(3), .pending-grid i:nth-child(5), .pending-grid i:nth-child(7) { animation-delay: 0.3s; }
986
+ .pending-grid i:nth-child(6), .pending-grid i:nth-child(8) { animation-delay: 0.45s; }
987
+ .pending-grid i:nth-child(9) { animation-delay: 0.6s; }
988
+
989
+ @keyframes pixel {
990
+ 0%, 100% { opacity: 0.25; }
991
+ 50% { opacity: 1; background: var(--text); }
992
+ }
993
+
994
+ .shimmer {
995
+ background: linear-gradient(90deg, var(--subtle) 0%, var(--subtle) 35%, var(--text) 50%, var(--subtle) 65%, var(--subtle) 100%);
996
+ background-size: 250% 100%;
997
+ -webkit-background-clip: text;
998
+ background-clip: text;
999
+ color: transparent;
1000
+ animation: shimmer 1.8s linear infinite;
1001
+ }
1002
+
1003
+ @keyframes shimmer {
1004
+ from { background-position: 100% 0; }
1005
+ to { background-position: -150% 0; }
1006
+ }
1007
+
1008
+ .elapsed {
1009
+ color: var(--subtle);
1010
+ font-family: var(--mono);
1011
+ font-size: 12px;
1012
+ font-variant-numeric: tabular-nums;
1013
+ }
1014
+
1015
+ .thinking {
1016
+ margin: 2px 0 12px;
1017
+ }
1018
+
1019
+ .thinking-head {
1020
+ display: inline-flex;
1021
+ align-items: center;
1022
+ gap: 7px;
1023
+ padding: 2px 0;
1024
+ color: var(--muted);
1025
+ font-size: 13px;
1026
+ transition: color 0.15s;
1027
+ }
1028
+
1029
+ .thinking-head:hover {
1030
+ color: var(--text);
1031
+ }
1032
+
1033
+ .thinking-head .chevron {
1034
+ transition: transform 0.25s var(--ease);
1035
+ }
1036
+
1037
+ .thinking.open .thinking-head .chevron {
1038
+ transform: rotate(180deg);
1039
+ }
1040
+
1041
+ .thinking-body {
1042
+ display: grid;
1043
+ grid-template-rows: 0fr;
1044
+ opacity: 0;
1045
+ transition: grid-template-rows 0.35s var(--ease), opacity 0.25s var(--ease);
1046
+ }
1047
+
1048
+ .thinking.open .thinking-body {
1049
+ grid-template-rows: 1fr;
1050
+ opacity: 1;
1051
+ }
1052
+
1053
+ .thinking-inner {
1054
+ overflow: hidden;
1055
+ }
1056
+
1057
+ .thinking-text strong {
1058
+ color: var(--text);
1059
+ font-weight: 500;
1060
+ }
1061
+
1062
+ .thinking-text {
1063
+ margin: 8px 0 2px 6px;
1064
+ padding: 2px 0 2px 16px;
1065
+ box-shadow: inset 1px 0 0 var(--ring-strong);
1066
+ color: var(--muted);
1067
+ font-size: 13px;
1068
+ line-height: 1.6;
1069
+ white-space: pre-wrap;
1070
+ overflow-wrap: anywhere;
1071
+ }
1072
+
1073
+ .tool {
1074
+ margin: 4px 0 12px;
1075
+ border-radius: 12px;
1076
+ background: var(--surface);
1077
+ box-shadow: var(--shadow-card);
1078
+ overflow: hidden;
1079
+ }
1080
+
1081
+ .tool-head {
1082
+ display: flex;
1083
+ align-items: center;
1084
+ gap: 8px;
1085
+ padding: 8px 12px;
1086
+ font-size: 13px;
1087
+ }
1088
+
1089
+ .tool-icon {
1090
+ display: grid;
1091
+ place-items: center;
1092
+ width: 22px;
1093
+ height: 22px;
1094
+ border-radius: 6px;
1095
+ background: var(--amber-soft);
1096
+ color: var(--amber);
1097
+ }
1098
+
1099
+ .tool-name {
1100
+ font-family: var(--mono);
1101
+ font-size: 12.5px;
1102
+ font-weight: 500;
1103
+ }
1104
+
1105
+ .tool-state {
1106
+ margin-left: auto;
1107
+ color: var(--subtle);
1108
+ font-size: 12px;
1109
+ }
1110
+
1111
+ .tool-args {
1112
+ margin: 0;
1113
+ padding: 10px 12px;
1114
+ max-height: 280px;
1115
+ overflow: auto;
1116
+ background: var(--raised);
1117
+ box-shadow: inset 0 1px 0 var(--ring);
1118
+ font-family: var(--mono);
1119
+ font-size: 12px;
1120
+ line-height: 1.6;
1121
+ white-space: pre-wrap;
1122
+ overflow-wrap: anywhere;
1123
+ }
1124
+
1125
+ .tool-args:empty {
1126
+ display: none;
1127
+ }
1128
+
1129
+ .media {
1130
+ margin: 4px 0 12px;
1131
+ }
1132
+
1133
+ .media img {
1134
+ display: block;
1135
+ max-width: min(100%, 420px);
1136
+ border-radius: 12px;
1137
+ box-shadow: var(--shadow-card);
1138
+ }
1139
+
1140
+ .audio-box {
1141
+ display: inline-flex;
1142
+ align-items: center;
1143
+ gap: 10px;
1144
+ margin: 4px 0 12px;
1145
+ padding: 8px 12px;
1146
+ border-radius: 12px;
1147
+ background: var(--surface);
1148
+ box-shadow: var(--shadow-card);
1149
+ color: var(--muted);
1150
+ font-size: 13px;
1151
+ }
1152
+
1153
+ .audio-box {
1154
+ max-width: 100%;
1155
+ }
1156
+
1157
+ .audio-box audio {
1158
+ flex: 1 1 auto;
1159
+ min-width: 0;
1160
+ height: 36px;
1161
+ max-width: 320px;
1162
+ }
1163
+
1164
+ .inline-data {
1165
+ display: inline-block;
1166
+ margin: 4px 0 12px;
1167
+ padding: 6px 10px;
1168
+ border-radius: 8px;
1169
+ background: var(--raised);
1170
+ color: var(--muted);
1171
+ font-family: var(--mono);
1172
+ font-size: 12px;
1173
+ }
1174
+
1175
+ .embedding-content {
1176
+ margin: 4px 0 12px;
1177
+ border-radius: 12px;
1178
+ background: var(--surface);
1179
+ box-shadow: var(--shadow-card);
1180
+ overflow: hidden;
1181
+ }
1182
+
1183
+ .embedding-head {
1184
+ display: flex;
1185
+ justify-content: space-between;
1186
+ gap: 8px;
1187
+ padding: 8px 12px;
1188
+ color: var(--muted);
1189
+ font-size: 12.5px;
1190
+ }
1191
+
1192
+ .embedding-content code {
1193
+ display: block;
1194
+ padding: 10px 12px;
1195
+ background: var(--raised);
1196
+ box-shadow: inset 0 1px 0 var(--ring);
1197
+ font-family: var(--mono);
1198
+ font-size: 12px;
1199
+ overflow-wrap: anywhere;
1200
+ }
1201
+
1202
+ .error-banner {
1203
+ margin: 4px 0 12px;
1204
+ padding: 8px 12px;
1205
+ border-radius: 10px;
1206
+ background: var(--red-soft);
1207
+ color: var(--red);
1208
+ font-size: 13px;
1209
+ white-space: pre-wrap;
1210
+ overflow-wrap: anywhere;
1211
+ }
1212
+
1213
+ .interrupted {
1214
+ display: inline-flex;
1215
+ align-items: center;
1216
+ gap: 6px;
1217
+ margin-top: 8px;
1218
+ color: var(--amber);
1219
+ font-size: 12.5px;
1220
+ font-weight: 500;
1221
+ }
1222
+
1223
+ .msg-foot {
1224
+ display: flex;
1225
+ flex-wrap: wrap;
1226
+ align-items: center;
1227
+ gap: 6px 14px;
1228
+ margin-top: 12px;
1229
+ color: var(--subtle);
1230
+ font-size: 12px;
1231
+ }
1232
+
1233
+ .reason {
1234
+ display: inline-flex;
1235
+ align-items: center;
1236
+ gap: 6px;
1237
+ height: 22px;
1238
+ padding: 0 8px;
1239
+ border-radius: 999px;
1240
+ background: var(--raised);
1241
+ color: var(--muted);
1242
+ font-family: var(--mono);
1243
+ font-size: 11.5px;
1244
+ }
1245
+
1246
+ .reason::before {
1247
+ content: "";
1248
+ width: 6px;
1249
+ height: 6px;
1250
+ border-radius: 50%;
1251
+ background: currentColor;
1252
+ }
1253
+
1254
+ .reason-stop { background: var(--green-soft); color: var(--green); }
1255
+ .reason-tool_call { background: var(--amber-soft); color: var(--amber); }
1256
+ .reason-length, .reason-unknown { background: var(--red-soft); color: var(--red); }
1257
+
1258
+ .usage {
1259
+ display: inline-flex;
1260
+ flex-wrap: wrap;
1261
+ gap: 4px 12px;
1262
+ font-family: var(--mono);
1263
+ font-size: 11.5px;
1264
+ font-variant-numeric: tabular-nums;
1265
+ }
1266
+
1267
+ .usage b {
1268
+ color: var(--muted);
1269
+ font-weight: 500;
1270
+ }
1271
+
1272
+ .copy-btn {
1273
+ width: 26px;
1274
+ height: 26px;
1275
+ margin-left: auto;
1276
+ border-radius: 6px;
1277
+ }
1278
+
1279
+ /* composer */
1280
+
1281
+ .composer-wrap {
1282
+ flex: none;
1283
+ padding: 0 20px 20px;
1284
+ }
1285
+
1286
+ .composer {
1287
+ max-width: 760px;
1288
+ margin: 0 auto;
1289
+ padding: 10px 10px 8px 14px;
1290
+ border-radius: 20px;
1291
+ background: var(--surface);
1292
+ box-shadow: var(--shadow-composer);
1293
+ transition: box-shadow 0.2s var(--ease);
1294
+ }
1295
+
1296
+ .composer:focus-within {
1297
+ box-shadow: 0 0 0 1px var(--ring-strong), 0 0 0 4px var(--accent-soft), 0 14px 36px -14px rgba(0, 0, 0, 0.3);
1298
+ }
1299
+
1300
+ .composer.dragging {
1301
+ box-shadow: 0 0 0 1.5px var(--accent), 0 0 0 5px var(--accent-soft);
1302
+ }
1303
+
1304
+ .previews {
1305
+ display: flex;
1306
+ flex-wrap: wrap;
1307
+ gap: 8px;
1308
+ padding: 2px 0 10px;
1309
+ }
1310
+
1311
+ .preview {
1312
+ position: relative;
1313
+ animation: msg-in 0.25s var(--ease);
1314
+ }
1315
+
1316
+ .preview img {
1317
+ display: block;
1318
+ width: 56px;
1319
+ height: 56px;
1320
+ object-fit: cover;
1321
+ border-radius: 10px;
1322
+ box-shadow: 0 0 0 1px var(--ring);
1323
+ }
1324
+
1325
+ .preview button {
1326
+ position: absolute;
1327
+ top: -6px;
1328
+ right: -6px;
1329
+ display: grid;
1330
+ place-items: center;
1331
+ width: 20px;
1332
+ height: 20px;
1333
+ border-radius: 50%;
1334
+ background: var(--text);
1335
+ color: var(--bg);
1336
+ box-shadow: 0 0 0 2px var(--surface);
1337
+ opacity: 0;
1338
+ transition: opacity 0.15s;
1339
+ }
1340
+
1341
+ .preview:hover button, .preview button:focus-visible {
1342
+ opacity: 1;
1343
+ }
1344
+
1345
+ #messageInput {
1346
+ display: block;
1347
+ width: 100%;
1348
+ min-height: 24px;
1349
+ max-height: 200px;
1350
+ padding: 4px 0;
1351
+ border: 0;
1352
+ outline: none;
1353
+ resize: none;
1354
+ background: transparent;
1355
+ font-size: 14.5px;
1356
+ line-height: 22px;
1357
+ }
1358
+
1359
+ #messageInput::placeholder {
1360
+ color: var(--subtle);
1361
+ }
1362
+
1363
+ .composer-bar {
1364
+ display: flex;
1365
+ align-items: center;
1366
+ gap: 6px;
1367
+ margin-top: 6px;
1368
+ }
1369
+
1370
+ .composer-hint {
1371
+ color: var(--subtle);
1372
+ font-size: 12px;
1373
+ }
1374
+
1375
+ .send-btn {
1376
+ display: grid;
1377
+ place-items: center;
1378
+ width: 32px;
1379
+ height: 32px;
1380
+ margin-left: auto;
1381
+ border-radius: 50%;
1382
+ background: var(--text);
1383
+ color: var(--bg);
1384
+ transition: transform 0.2s var(--ease), opacity 0.15s, background-color 0.15s;
1385
+ }
1386
+
1387
+ .send-btn:hover:not(:disabled) {
1388
+ transform: scale(1.06);
1389
+ }
1390
+
1391
+ .send-btn:active:not(:disabled) {
1392
+ transform: scale(0.94);
1393
+ }
1394
+
1395
+ .send-btn:disabled {
1396
+ opacity: 0.25;
1397
+ }
1398
+
1399
+ .scrim {
1400
+ display: none;
1401
+ }
1402
+
1403
+ @media (max-width: 900px) {
1404
+ .sidebar {
1405
+ position: fixed;
1406
+ top: 0;
1407
+ bottom: 0;
1408
+ left: 0;
1409
+ width: min(340px, 88vw);
1410
+ margin-left: 0 !important;
1411
+ transform: translateX(-102%);
1412
+ transition: transform 0.32s var(--ease);
1413
+ box-shadow: var(--shadow-menu);
1414
+ z-index: 40;
1415
+ }
1416
+
1417
+ .app.sidebar-open .sidebar {
1418
+ transform: none;
1419
+ }
1420
+
1421
+ .scrim {
1422
+ display: block;
1423
+ position: fixed;
1424
+ inset: 0;
1425
+ z-index: 35;
1426
+ background: rgba(0, 0, 0, 0.35);
1427
+ opacity: 0;
1428
+ pointer-events: none;
1429
+ transition: opacity 0.3s var(--ease);
1430
+ }
1431
+
1432
+ .app.sidebar-open .scrim {
1433
+ opacity: 1;
1434
+ pointer-events: auto;
1435
+ }
1436
+
1437
+ .ghost-btn .label-wide, .composer-hint {
1438
+ display: none;
1439
+ }
1440
+
1441
+ .thread {
1442
+ padding: 24px 16px 16px;
1443
+ }
1444
+
1445
+ .composer-wrap {
1446
+ padding: 0 12px 12px;
1447
+ }
1448
+ }
1449
+
1450
+ @media (prefers-reduced-motion: reduce) {
1451
+ *, *::before, *::after {
1452
+ animation-duration: 0.01ms !important;
1453
+ animation-iteration-count: 1 !important;
1454
+ transition-duration: 0.01ms !important;
1455
+ }
1456
+ }
1457
+ </style>
1458
+ </head>
1459
+ <body>
1460
+ <div class="app" id="app">
1461
+ <aside class="sidebar" id="configPanel" aria-label="Settings">
1462
+ <div class="sidebar-head">
1463
+ <div class="brand">
1464
+ <svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" class="mark-bg"></rect><path d="M0 16V7a7 7 0 0 1 7-7h9v16Z" fill="#7c4dff"></path><path d="M16 16h16v9a7 7 0 0 1-7 7h-9Z" fill="#7c4dff"></path><g fill="#fff" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="10.5" font-weight="700" text-anchor="middle" dominant-baseline="central"><text x="8.5" y="8.5">M</text><text x="23.5" y="8.5">M</text><text x="8.5" y="23.5">S</text><text x="23.5" y="23.5">P</text></g></svg>
1465
+ <h1 class="brand-name">MMSP</h1>
1466
+ <span class="brand-sub">Playground</span>
1467
+ </div>
1468
+ <div class="segmented theme-toggle" id="themeToggle" role="radiogroup" aria-label="Theme">
1469
+ <span class="seg-thumb" aria-hidden="true"></span>
1470
+ <button type="button" role="radio" aria-checked="false" aria-label="Light theme" title="Light" data-theme-choice="light" onclick="setTheme('light')">
1471
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path></svg>
1472
+ </button>
1473
+ <button type="button" role="radio" aria-checked="false" aria-label="Dark theme" title="Dark" data-theme-choice="dark" onclick="setTheme('dark')">
1474
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"></path></svg>
1475
+ </button>
1476
+ </div>
1477
+ </div>
1478
+
1479
+ <div class="sidebar-scroll">
1480
+ <section class="group">
1481
+ <div class="group-title"><span>Connection</span></div>
1482
+
1483
+ <div class="field">
1484
+ <div class="field-head">
1485
+ <label class="field-label" for="modelComboboxButton">Model</label>
1486
+ <span>
1487
+ <span id="listModelsStatus" class="status-text"></span>
1488
+ <button type="button" id="listModelsButton" class="link-btn" onclick="listModels()">List models</button>
1489
+ </span>
1490
+ </div>
1491
+ <div id="modelCombobox" data-combobox>
1492
+ <input id="modelSelect" type="hidden" value="gpt-6.1-sol" data-combobox-value>
1493
+ <button id="modelComboboxButton" type="button" role="combobox" aria-controls="modelComboboxMenu" aria-expanded="false" class="control combo-button" onclick="toggleCombobox('modelCombobox')" onkeydown="handleComboboxKeydown(event, 'modelCombobox')" data-combobox-button>
1494
+ <span data-combobox-label>GPT 6.1 Sol</span>
1495
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
1496
+ </button>
1497
+ <div id="modelComboboxMenu" class="hidden" role="listbox" aria-labelledby="modelComboboxButton" data-combobox-menu onkeydown="handleMenuKeydown(event, 'modelCombobox')">
1498
+ <div class="menu-search">
1499
+ <input id="modelFilterInput" type="text" autocomplete="off" spellcheck="false" placeholder="Filter models" aria-label="Filter models" oninput="filterModelOptions()">
1500
+ </div>
1501
+ <button type="button" role="option" aria-selected="true" class="combo-option" data-combobox-option data-value="gpt-6.1-sol" data-label="GPT 6.1 Sol" data-description="gpt-6.1-sol" onclick="selectComboboxOption('modelCombobox', this)"><span>GPT 6.1 Sol</span></button>
1502
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="text-embedding-3-large" data-label="Text Embedding 3 Large" data-description="text-embedding-3-large" onclick="selectComboboxOption('modelCombobox', this)"><span>Text Embedding 3 Large</span></button>
1503
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="gemini-3.8-flash" data-label="Gemini 3.8 Flash" data-description="gemini-3.8-flash" onclick="selectComboboxOption('modelCombobox', this)"><span>Gemini 3.8 Flash</span></button>
1504
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="gemini-3.1-flash-image" data-label="Gemini 3.1 Flash Image" data-description="gemini-3.1-flash-image" onclick="selectComboboxOption('modelCombobox', this)"><span>Gemini 3.1 Flash Image</span></button>
1505
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="gemini-3.8-flash-tts" data-label="Gemini 3.8 Flash TTS" data-description="gemini-3.8-flash-tts" onclick="selectComboboxOption('modelCombobox', this)"><span>Gemini 3.8 Flash TTS</span></button>
1506
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="gemini-embedding-2" data-label="Gemini Embedding 2" data-description="gemini-embedding-2" onclick="selectComboboxOption('modelCombobox', this)"><span>Gemini Embedding 2</span></button>
1507
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="claude-sonnet-5-5" data-label="Claude Sonnet 5.5" data-description="claude-sonnet-5-5" onclick="selectComboboxOption('modelCombobox', this)"><span>Claude Sonnet 5.5</span></button>
1508
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="claude-opus-5-5" data-label="Claude Opus 5.5" data-description="claude-opus-5-5" onclick="selectComboboxOption('modelCombobox', this)"><span>Claude Opus 5.5</span></button>
1509
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="claude-fable-5-1" data-label="Claude Fable 5.1" data-description="claude-fable-5-1" onclick="selectComboboxOption('modelCombobox', this)"><span>Claude Fable 5.1</span></button>
1510
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="glm-5.3-flash" data-label="GLM 5.3 Flash" data-description="glm-5.3-flash" onclick="selectComboboxOption('modelCombobox', this)"><span>GLM 5.3 Flash</span></button>
1511
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="kimi-k3" data-label="Kimi K3" data-description="kimi-k3" onclick="selectComboboxOption('modelCombobox', this)"><span>Kimi K3</span></button>
1512
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="MiniMax-M3" data-label="MiniMax M3" data-description="MiniMax-M3" onclick="selectComboboxOption('modelCombobox', this)"><span>MiniMax M3</span></button>
1513
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="deepseek-flash" data-label="DeepSeek Flash" data-description="deepseek-flash" onclick="selectComboboxOption('modelCombobox', this)"><span>DeepSeek Flash</span></button>
1514
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="__custom__" data-label="Custom model" data-description="Type any model id" onclick="selectComboboxOption('modelCombobox', this)"><span>Custom model</span></button>
1515
+ <div id="modelFilterEmpty" class="menu-empty hidden">No model matches</div>
1516
+ </div>
1517
+ </div>
1518
+ <p id="listModelsError" class="field-error hidden"></p>
1519
+ <div id="customModelWrapper" class="hidden" style="margin-top: 8px;">
1520
+ <input id="customModelInput" type="text" autocomplete="off" spellcheck="false" placeholder="Model id" class="control code" oninput="updateHeader()">
1521
+ </div>
1522
+ </div>
1523
+
1524
+ <div class="field">
1525
+ <div class="field-head">
1526
+ <label class="field-label" for="clientTypeComboboxButton">Client type</label>
1527
+ </div>
1528
+ <div id="clientTypeCombobox" data-combobox>
1529
+ <input id="clientTypeSelect" type="hidden" value="" data-combobox-value>
1530
+ <button id="clientTypeComboboxButton" type="button" role="combobox" aria-controls="clientTypeComboboxMenu" aria-expanded="false" class="control combo-button" onclick="toggleCombobox('clientTypeCombobox')" onkeydown="handleComboboxKeydown(event, 'clientTypeCombobox')" data-combobox-button>
1531
+ <span class="mono" data-combobox-label>Auto</span>
1532
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
1533
+ </button>
1534
+ <div id="clientTypeComboboxMenu" class="hidden" role="listbox" aria-labelledby="clientTypeComboboxButton" data-combobox-menu onkeydown="handleMenuKeydown(event, 'clientTypeCombobox')"></div>
1535
+ </div>
1536
+ </div>
1537
+
1538
+ <div class="field">
1539
+ <div class="field-head">
1540
+ <label class="field-label" for="apiKeyInput">API key</label>
1541
+ </div>
1542
+ <div class="input-wrap">
1543
+ <input type="password" id="apiKeyInput" autocomplete="off" spellcheck="false" placeholder="From the environment when empty" class="control">
1544
+ <button type="button" id="apiKeyVisibilityToggle" aria-label="Show API key" title="Show API key" class="input-action" onclick="toggleApiKeyVisibility()">
1545
+ <svg id="apiKeyVisibilityShowIcon" class="hidden" 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" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
1546
+ <svg id="apiKeyVisibilityHideIcon" 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" aria-hidden="true"><path d="M10.7 5.1A10.9 10.9 0 0 1 12 5c6.5 0 10 7 10 7a18.5 18.5 0 0 1-3.3 4.3"></path><path d="M6.6 6.6C3.8 8.4 2 12 2 12s3.5 7 10 7a10.9 10.9 0 0 0 5.4-1.4"></path><path d="M9.9 9.9A3 3 0 0 0 14.1 14.1"></path><path d="M3 3l18 18"></path></svg>
1547
+ </button>
1548
+ </div>
1549
+ </div>
1550
+
1551
+ <div class="field">
1552
+ <div class="field-head">
1553
+ <label class="field-label" for="baseUrlInput">Base URL</label>
1554
+ </div>
1555
+ <div class="input-wrap">
1556
+ <input type="url" id="baseUrlInput" spellcheck="false" oninput="handleBaseUrlInput()" placeholder="The provider's own endpoint" class="control" style="padding-right: 68px;">
1557
+ <span id="baseUrlDefaultTag" class="input-tag hidden">default</span>
1558
+ </div>
1559
+ </div>
1560
+
1561
+ <div class="field">
1562
+ <div class="field-head">
1563
+ <label class="field-label" for="extraHeadersInput">Extra headers</label>
1564
+ </div>
1565
+ <textarea id="extraHeadersInput" rows="2" spellcheck="false" placeholder='{"X-Title": "MMSP"}' class="control code" oninput="getExtraHeaders()"></textarea>
1566
+ <p class="field-note">A JSON object, for endpoints that ask for headers of their own.</p>
1567
+ </div>
1568
+ </section>
1569
+
1570
+ <section class="group">
1571
+ <div class="group-title"><span>Generation</span></div>
1572
+
1573
+ <div class="field">
1574
+ <div class="field-head">
1575
+ <label class="field-label" for="thinkingLevelComboboxButton">Thinking level</label>
1576
+ </div>
1577
+ <div id="thinkingLevelCombobox" data-combobox>
1578
+ <input id="thinkingLevelSelect" type="hidden" value="" data-combobox-value>
1579
+ <button id="thinkingLevelComboboxButton" type="button" role="combobox" aria-controls="thinkingLevelComboboxMenu" aria-expanded="false" class="control combo-button" onclick="toggleCombobox('thinkingLevelCombobox')" onkeydown="handleComboboxKeydown(event, 'thinkingLevelCombobox')" data-combobox-button>
1580
+ <span data-combobox-label>Default</span>
1581
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
1582
+ </button>
1583
+ <div id="thinkingLevelComboboxMenu" class="hidden" role="listbox" aria-labelledby="thinkingLevelComboboxButton" data-combobox-menu onkeydown="handleMenuKeydown(event, 'thinkingLevelCombobox')">
1584
+ <button type="button" role="option" aria-selected="true" class="combo-option" data-combobox-option data-value="" data-label="Default" data-description="The provider's default" onclick="selectComboboxOption('thinkingLevelCombobox', this)"><span>Default</span></button>
1585
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="none" data-label="None" data-description="No thinking" onclick="selectComboboxOption('thinkingLevelCombobox', this)"><span>None</span></button>
1586
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="low" data-label="Low" data-description="low" onclick="selectComboboxOption('thinkingLevelCombobox', this)"><span>Low</span></button>
1587
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="medium" data-label="Medium" data-description="medium" onclick="selectComboboxOption('thinkingLevelCombobox', this)"><span>Medium</span></button>
1588
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="high" data-label="High" data-description="high" onclick="selectComboboxOption('thinkingLevelCombobox', this)"><span>High</span></button>
1589
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="xhigh" data-label="XHigh" data-description="xhigh" onclick="selectComboboxOption('thinkingLevelCombobox', this)"><span>XHigh</span></button>
1590
+ <button type="button" role="option" aria-selected="false" class="combo-option" data-combobox-option data-value="max" data-label="Max" data-description="max" onclick="selectComboboxOption('thinkingLevelCombobox', this)"><span>Max</span></button>
1591
+ </div>
1592
+ </div>
1593
+ </div>
1594
+
1595
+ <div class="field">
1596
+ <div class="field-head">
1597
+ <span class="field-label" id="thinkingSummaryLabel">Thinking summary</span>
1598
+ </div>
1599
+ <div id="thinkingSummaryCombobox" class="segmented" role="radiogroup" aria-labelledby="thinkingSummaryLabel" data-segmented>
1600
+ <input id="thinkingSummaryCheckbox" type="hidden" value="" data-combobox-value>
1601
+ <span class="seg-thumb" aria-hidden="true"></span>
1602
+ <button type="button" role="radio" aria-checked="true" data-combobox-option data-value="" data-label="Default" onclick="selectComboboxOption('thinkingSummaryCombobox', this)">Default</button>
1603
+ <button type="button" role="radio" aria-checked="false" data-combobox-option data-value="true" data-label="On" onclick="selectComboboxOption('thinkingSummaryCombobox', this)">On</button>
1604
+ <button type="button" role="radio" aria-checked="false" data-combobox-option data-value="false" data-label="Off" onclick="selectComboboxOption('thinkingSummaryCombobox', this)">Off</button>
1605
+ </div>
1606
+ </div>
1607
+
1608
+ <div class="field">
1609
+ <div class="field-head">
1610
+ <span class="field-label" id="toolChoiceLabel">Tool choice</span>
1611
+ </div>
1612
+ <div id="toolChoiceCombobox" class="segmented" role="radiogroup" aria-labelledby="toolChoiceLabel" data-segmented>
1613
+ <input id="toolChoiceSelect" type="hidden" value="" data-combobox-value>
1614
+ <span class="seg-thumb" aria-hidden="true"></span>
1615
+ <button type="button" role="radio" aria-checked="true" data-combobox-option data-value="" data-label="Default" onclick="selectComboboxOption('toolChoiceCombobox', this)">Default</button>
1616
+ <button type="button" role="radio" aria-checked="false" data-combobox-option data-value="auto" data-label="Auto" onclick="selectComboboxOption('toolChoiceCombobox', this)">Auto</button>
1617
+ <button type="button" role="radio" aria-checked="false" data-combobox-option data-value="required" data-label="Required" onclick="selectComboboxOption('toolChoiceCombobox', this)">Required</button>
1618
+ <button type="button" role="radio" aria-checked="false" data-combobox-option data-value="none" data-label="None" onclick="selectComboboxOption('toolChoiceCombobox', this)">None</button>
1619
+ </div>
1620
+ </div>
1621
+
1622
+ <div class="field">
1623
+ <div class="field-head">
1624
+ <label class="field-label" for="systemPromptInput">System prompt</label>
1625
+ </div>
1626
+ <textarea id="systemPromptInput" rows="3" class="control" placeholder="You are a helpful assistant."></textarea>
1627
+ </div>
1628
+
1629
+ <div class="field">
1630
+ <div class="field-head">
1631
+ <label class="field-label" for="toolsInput">Tools</label>
1632
+ </div>
1633
+ <textarea id="toolsInput" rows="4" spellcheck="false" placeholder='[{"name": "get_weather", "description": "...", "parameters": {...}}]' class="control code" oninput="validateTools()"></textarea>
1634
+ <p id="toolsError" class="field-error hidden">Not valid JSON. The request goes out without tools.</p>
1635
+ </div>
1636
+
1637
+ <div class="field">
1638
+ <div class="field-head">
1639
+ <label class="field-label" for="traceIdInput">Trace ID</label>
1640
+ </div>
1641
+ <input type="text" id="traceIdInput" spellcheck="false" placeholder="session_001" class="control">
1642
+ <p class="field-note">Requests with a trace ID are recorded for the tracer.</p>
1643
+ </div>
1644
+ </section>
1645
+ </div>
1646
+ </aside>
1647
+ <div class="scrim" onclick="toggleConfig()"></div>
1648
+
1649
+ <main class="main">
1650
+ <header class="topbar">
1651
+ <button type="button" class="icon-btn" onclick="toggleConfig()" aria-label="Toggle settings" title="Settings">
1652
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"></rect><path d="M9 4v16"></path></svg>
1653
+ </button>
1654
+ <div class="topbar-model">
1655
+ <span id="headerModel">gpt-6.1-sol</span>
1656
+ <span id="headerClientType" class="mono"></span>
1657
+ </div>
1658
+ <div class="topbar-actions">
1659
+ <a href="https://github.com/Prism-Shadow/model-message-stream-protocol" target="_blank" rel="noopener noreferrer" class="ghost-btn" title="GitHub">
1660
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.63-1.33-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02a9.6 9.6 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2Z"></path></svg>
1661
+ <span class="label-wide">GitHub</span>
1662
+ </a>
1663
+ <a href="/tracer/" target="_blank" rel="noopener noreferrer" class="ghost-btn" title="Open Tracer">
1664
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"></path></svg>
1665
+ <span class="label-wide">Open Tracer</span>
1666
+ </a>
1667
+ <button type="button" class="ghost-btn" onclick="clearChat()" title="New chat">
1668
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
1669
+ <span class="label-wide">New chat</span>
1670
+ </button>
1671
+ </div>
1672
+ </header>
1673
+
1674
+ <div class="messages" id="messagesContainer">
1675
+ <div class="empty-state" id="emptyState">
1676
+ <h2>Start a conversation</h2>
1677
+ <p>Messages go to <span class="mono" id="emptyStateModel">gpt-6.1-sol</span>, and every stream item shows as it arrives.</p>
1678
+ </div>
1679
+ <div class="thread hidden" id="thread"></div>
1680
+ </div>
1681
+
1682
+ <div class="composer-wrap">
1683
+ <div class="composer" id="composer">
1684
+ <div id="imagePreviewContainer" class="previews hidden"></div>
1685
+ <textarea id="messageInput" rows="1" placeholder="Message gpt-6.1-sol" aria-label="Message"></textarea>
1686
+ <div class="composer-bar">
1687
+ <input type="file" id="imageInput" accept="image/*" multiple class="hidden" onchange="handleImageSelect(event)">
1688
+ <button type="button" class="icon-btn" onclick="document.getElementById('imageInput').click()" aria-label="Attach images" title="Attach images">
1689
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.4 11.1-8.8 8.8a5.5 5.5 0 0 1-7.8-7.8l8.8-8.8a3.7 3.7 0 0 1 5.2 5.2l-8.8 8.8a1.8 1.8 0 0 1-2.6-2.6l8.1-8.1"></path></svg>
1690
+ </button>
1691
+ <span class="composer-hint">Enter to send, Shift+Enter for a new line</span>
1692
+ <button type="button" class="send-btn" id="sendButton" onclick="sendMessage()" aria-label="Send" title="Send" disabled>
1693
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"></path></svg>
1694
+ </button>
1695
+ <button type="button" class="send-btn hidden" id="stopButton" onclick="stopGeneration()" aria-label="Stop" title="Stop" disabled>
1696
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3"></rect></svg>
1697
+ </button>
1698
+ </div>
1699
+ </div>
1700
+ </div>
1701
+ </main>
1702
+ </div>
1703
+
1704
+ <script>
1705
+ let isStreaming = false;
1706
+ let sessionId = Math.random().toString(36).substring(7);
1707
+ let selectedImages = [];
1708
+ let lastMessageTimestamp = null;
1709
+ let currentAbortController = null;
1710
+
1711
+ const ICONS = {
1712
+ sparkle: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2c.4 4.6 2.4 7.4 10 10-7.6 2.6-9.6 5.4-10 10-.4-4.6-2.4-7.4-10-10 7.6-2.6 9.6-5.4 10-10Z"></path></svg>',
1713
+ chevron: '<svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>',
1714
+ tool: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0 5 5L22 14l-8 8-2.3-2.3a4 4 0 0 0-5-5L2 10l8-8Z"></path></svg>',
1715
+ copy: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"></rect><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"></path></svg>',
1716
+ check: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>',
1717
+ audio: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z"></path><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14"></path></svg>',
1718
+ close: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>'
1719
+ };
1720
+
1721
+ function escapeHtml(text) {
1722
+ const div = document.createElement('div');
1723
+ div.textContent = text;
1724
+ return div.innerHTML;
1725
+ }
1726
+
1727
+ function formatTimestamp(ms) {
1728
+ if (!ms) return '';
1729
+ const d = new Date(ms);
1730
+ const pad = n => n.toString().padStart(2, '0');
1731
+ return \`\${d.getFullYear()}-\${pad(d.getMonth()+1)}-\${pad(d.getDate())} \${pad(d.getHours())}:\${pad(d.getMinutes())}:\${pad(d.getSeconds())}\`;
1732
+ }
1733
+
1734
+ function formatClock(ms) {
1735
+ if (!ms) return '';
1736
+ const d = new Date(ms);
1737
+ const pad = n => n.toString().padStart(2, '0');
1738
+ return \`\${pad(d.getHours())}:\${pad(d.getMinutes())}\`;
1739
+ }
1740
+
1741
+ function formatDuration(ms) {
1742
+ if (ms < 1000) return \`\${Math.round(ms)} ms\`;
1743
+ if (ms < 60000) return \`\${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s\`;
1744
+ return \`\${Math.floor(ms / 60000)} min \${Math.round((ms % 60000) / 1000)} s\`;
1745
+ }
1746
+
1747
+ function formatCount(n) {
1748
+ return Number(n).toLocaleString('en-US');
1749
+ }
1750
+
1751
+ const AUDIO_MIME_TYPES = ['audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/webm', 'audio/flac', 'audio/aac', 'audio/mp4'];
1752
+
1753
+ function isAudioMimeType(mimeType) {
1754
+ const value = (mimeType || '').toLowerCase();
1755
+ return !value || value === 'application/octet-stream' || value.startsWith('audio/');
1756
+ }
1757
+
1758
+ function base64ToBytes(base64) {
1759
+ const binary = atob(base64);
1760
+ const bytes = new Uint8Array(binary.length);
1761
+ for (let i = 0; i < binary.length; i++) {
1762
+ bytes[i] = binary.charCodeAt(i);
1763
+ }
1764
+ return bytes;
1765
+ }
1766
+
1767
+ function bytesToBase64(bytes) {
1768
+ let binary = '';
1769
+ const chunkSize = 0x8000;
1770
+ for (let i = 0; i < bytes.length; i += chunkSize) {
1771
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
1772
+ }
1773
+ return btoa(binary);
1774
+ }
1775
+
1776
+ function concatBase64Chunks(chunks) {
1777
+ const parts = chunks.map(base64ToBytes);
1778
+ const bytes = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
1779
+ let offset = 0;
1780
+ for (const part of parts) {
1781
+ bytes.set(part, offset);
1782
+ offset += part.length;
1783
+ }
1784
+ return bytes;
1785
+ }
1786
+
1787
+ function pcmFormatFromMimeType(mimeType) {
1788
+ // Gemini TTS labels its raw PCM as "audio/l16; rate=24000; channels=1"
1789
+ const params = {};
1790
+ for (const parameter of (mimeType || '').split(';').slice(1)) {
1791
+ const [key, value] = parameter.split('=');
1792
+ params[key.trim()] = Number(value);
1793
+ }
1794
+ return { sampleRate: params.rate || 24000, channels: params.channels || 1 };
1795
+ }
1796
+
1797
+ function pcmBytesToWavDataUrl(pcmBytes, sampleRate = 24000, channels = 1, bitsPerSample = 16) {
1798
+ const header = new ArrayBuffer(44);
1799
+ const view = new DataView(header);
1800
+ const byteRate = sampleRate * channels * bitsPerSample / 8;
1801
+ const blockAlign = channels * bitsPerSample / 8;
1802
+
1803
+ const writeString = (offset, value) => {
1804
+ for (let i = 0; i < value.length; i++) {
1805
+ view.setUint8(offset + i, value.charCodeAt(i));
1806
+ }
1807
+ };
1808
+
1809
+ writeString(0, 'RIFF');
1810
+ view.setUint32(4, 36 + pcmBytes.length, true);
1811
+ writeString(8, 'WAVE');
1812
+ writeString(12, 'fmt ');
1813
+ view.setUint32(16, 16, true);
1814
+ view.setUint16(20, 1, true);
1815
+ view.setUint16(22, channels, true);
1816
+ view.setUint32(24, sampleRate, true);
1817
+ view.setUint32(28, byteRate, true);
1818
+ view.setUint16(32, blockAlign, true);
1819
+ view.setUint16(34, bitsPerSample, true);
1820
+ writeString(36, 'data');
1821
+ view.setUint32(40, pcmBytes.length, true);
1822
+
1823
+ const wavBytes = new Uint8Array(44 + pcmBytes.length);
1824
+ wavBytes.set(new Uint8Array(header), 0);
1825
+ wavBytes.set(pcmBytes, 44);
1826
+ return \`data:audio/wav;base64,\${bytesToBase64(wavBytes)}\`;
1827
+ }
1828
+
1829
+ function renderAudioPlayer(mimeType, chunks) {
1830
+ const bytes = concatBase64Chunks(chunks);
1831
+ if (AUDIO_MIME_TYPES.includes(mimeType)) {
1832
+ return \`\${ICONS.audio}<audio controls preload="metadata"><source src="data:\${mimeType};base64,\${bytesToBase64(bytes)}" type="\${mimeType}"></audio>\`;
1833
+ }
1834
+
1835
+ const format = pcmFormatFromMimeType(mimeType);
1836
+ const wavDataUrl = pcmBytesToWavDataUrl(bytes, format.sampleRate, format.channels);
1837
+ return \`\${ICONS.audio}<audio controls preload="metadata"><source src="\${wavDataUrl}" type="audio/wav"></audio>\`;
1838
+ }
1839
+
1840
+ function audioProgressLabel(audioStream) {
1841
+ if (AUDIO_MIME_TYPES.includes(audioStream.mimeType)) {
1842
+ return \`Receiving audio, \${Math.round(audioStream.bytes / 1024)} KB\`;
1843
+ }
1844
+
1845
+ // raw PCM carries no duration, so 16-bit samples turn the byte count into seconds
1846
+ const format = pcmFormatFromMimeType(audioStream.mimeType);
1847
+ const seconds = audioStream.bytes / (format.sampleRate * format.channels * 2);
1848
+ return \`Receiving audio, \${seconds.toFixed(1)} s\`;
1849
+ }
1850
+
1851
+ function appendAudioChunk(contentDiv, item, audioStream) {
1852
+ if (!audioStream.container) {
1853
+ audioStream.mimeType = (item.mime_type || '').toLowerCase();
1854
+ audioStream.container = document.createElement('div');
1855
+ audioStream.container.className = 'audio-box';
1856
+ audioStream.container.innerHTML = \`\${ICONS.audio}<span class="shimmer"></span>\`;
1857
+ contentDiv.appendChild(audioStream.container);
1858
+ }
1859
+
1860
+ audioStream.chunks.push(item.data);
1861
+ audioStream.bytes += Math.floor(item.data.length * 3 / 4);
1862
+ audioStream.container.querySelector('span').textContent = audioProgressLabel(audioStream);
1863
+ }
1864
+
1865
+ function finalizeAudioStream(audioStream, autoplay = false) {
1866
+ if (audioStream.finalized || !audioStream.container) {
1867
+ return;
1868
+ }
1869
+
1870
+ audioStream.finalized = true;
1871
+ audioStream.container.innerHTML = renderAudioPlayer(audioStream.mimeType, audioStream.chunks);
1872
+ if (autoplay) {
1873
+ // a browser that blocks autoplay leaves the player sitting there ready to press
1874
+ audioStream.container.querySelector('audio').play().catch(() => {});
1875
+ }
1876
+ }
1877
+
1878
+ function renderInlineData(item) {
1879
+ const mimeType = (item.mime_type || '').toLowerCase();
1880
+ if (mimeType.startsWith('image/')) {
1881
+ return \`<figure class="media"><img src="data:\${mimeType || 'image/png'};base64,\${item.data}" alt="Generated image"></figure>\`;
1882
+ }
1883
+
1884
+ return \`<div class="inline-data">Inline data: \${escapeHtml(item.mime_type || 'application/octet-stream')}</div>\`;
1885
+ }
1886
+
1887
+ function renderEmbedding(item) {
1888
+ const values = Array.isArray(item.embedding) ? item.embedding.slice(0, 5) : [];
1889
+ const size = Array.isArray(item.embedding) ? item.embedding.length : 0;
1890
+ const preview = escapeHtml(\`[\${values.join(', ')}\${size > values.length ? ', …' : ''}]\`);
1891
+ return \`<div class="embedding-content"><div class="embedding-head"><span>Embedding</span><span class="mono">\${formatCount(size)} dimensions</span></div><code>\${preview}</code></div>\`;
1892
+ }
1893
+
1894
+ // Every stream item gets the element its kind reads best in; the stream loop only feeds it text.
1895
+ function openStreamItem(contentDiv, kind, started = performance.now()) {
1896
+ const item = { kind, text: '', name: '', started, root: null, container: null };
1897
+ if (kind === 'thinking') {
1898
+ item.root = document.createElement('div');
1899
+ item.root.className = 'thinking open';
1900
+ item.root.innerHTML = \`<button type="button" class="thinking-head" onclick="toggleThinking(this)" aria-expanded="true">\${ICONS.sparkle}<span class="thinking-label shimmer">Thinking</span>\${ICONS.chevron}</button><div class="thinking-body"><div class="thinking-inner"><div class="thinking-text"></div></div></div>\`;
1901
+ item.container = item.root.querySelector('.thinking-text');
1902
+ } else if (kind === 'tool_call') {
1903
+ item.root = document.createElement('div');
1904
+ item.root.className = 'tool';
1905
+ item.root.innerHTML = \`<div class="tool-head"><span class="tool-icon">\${ICONS.tool}</span><span class="tool-name"></span><span class="tool-state shimmer">Calling</span></div><pre class="tool-args"></pre>\`;
1906
+ item.container = item.root.querySelector('.tool-args');
1907
+ } else {
1908
+ item.root = document.createElement('div');
1909
+ item.root.className = 'text-content';
1910
+ item.container = item.root;
1911
+ }
1912
+ contentDiv.appendChild(item.root);
1913
+ return item;
1914
+ }
1915
+
1916
+ function finishStreamItem(item) {
1917
+ if (!item) {
1918
+ return;
1919
+ }
1920
+ if (item.kind === 'thinking') {
1921
+ // a thinking item that carries only a signature has nothing to show
1922
+ if (!item.text.trim()) {
1923
+ item.root.remove();
1924
+ return;
1925
+ }
1926
+ const label = item.root.querySelector('.thinking-label');
1927
+ label.classList.remove('shimmer');
1928
+ label.textContent = \`Thought for \${formatDuration(performance.now() - item.started)}\`;
1929
+ // the answer matters more once it starts; the thinking stays one click away
1930
+ setThinkingOpen(item.root, false);
1931
+ } else if (item.kind === 'tool_call') {
1932
+ const state = item.root.querySelector('.tool-state');
1933
+ state.classList.remove('shimmer');
1934
+ state.textContent = 'Tool call';
1935
+ }
1936
+ }
1937
+
1938
+ function setThinkingOpen(root, open) {
1939
+ root.classList.toggle('open', open);
1940
+ root.querySelector('.thinking-head').setAttribute('aria-expanded', open ? 'true' : 'false');
1941
+ }
1942
+
1943
+ function toggleThinking(button) {
1944
+ const root = button.closest('.thinking');
1945
+ setThinkingOpen(root, !root.classList.contains('open'));
1946
+ }
1947
+
1948
+ // summaries title their paragraphs in **bold**, the only markdown worth reading here
1949
+ function renderThinking(text) {
1950
+ return escapeHtml(text.trim()).replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
1951
+ }
1952
+
1953
+ function prettyArguments(text) {
1954
+ try {
1955
+ return JSON.stringify(JSON.parse(text), null, 2);
1956
+ } catch (error) {
1957
+ return text;
1958
+ }
1959
+ }
1960
+
1961
+ function addImageFiles(files) {
1962
+ const maxFileSize = 10 * 1024 * 1024;
1963
+ const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
1964
+
1965
+ Array.from(files).forEach(file => {
1966
+ if (!allowedTypes.includes(file.type)) {
1967
+ alert(\`File "\${file.name}" is not a valid image type. Please upload JPEG, PNG, GIF, or WebP images.\`);
1968
+ return;
1969
+ }
1970
+
1971
+ if (file.size > maxFileSize) {
1972
+ alert(\`File "\${file.name}" is too large. Maximum file size is 10MB.\`);
1973
+ return;
1974
+ }
1975
+
1976
+ const reader = new FileReader();
1977
+ reader.onload = function(e) {
1978
+ const base64Data = e.target.result;
1979
+ if (typeof base64Data === 'string' && base64Data.startsWith('data:image/')) {
1980
+ selectedImages.push(base64Data);
1981
+ updateImagePreview();
1982
+ }
1983
+ };
1984
+ reader.readAsDataURL(file);
1985
+ });
1986
+ }
1987
+
1988
+ function handleImageSelect(event) {
1989
+ const files = event.target.files;
1990
+ if (!files || files.length === 0) return;
1991
+ addImageFiles(files);
1992
+ event.target.value = '';
1993
+ }
1994
+
1995
+ function updateImagePreview() {
1996
+ const container = document.getElementById('imagePreviewContainer');
1997
+ updateSendState();
1998
+ if (selectedImages.length === 0) {
1999
+ container.classList.add('hidden');
2000
+ container.innerHTML = '';
2001
+ return;
2002
+ }
2003
+
2004
+ container.classList.remove('hidden');
2005
+ container.innerHTML = selectedImages.map((img, idx) => \`
2006
+ <div class="preview">
2007
+ <img src="\${img}" alt="Attached image \${idx + 1}">
2008
+ <button type="button" onclick="removeImage(\${idx})" aria-label="Remove image \${idx + 1}">\${ICONS.close}</button>
2009
+ </div>
2010
+ \`).join('');
2011
+ }
2012
+
2013
+ function removeImage(idx) {
2014
+ selectedImages.splice(idx, 1);
2015
+ updateImagePreview();
2016
+ }
2017
+
2018
+ function isNarrowScreen() {
2019
+ return window.matchMedia('(max-width: 900px)').matches;
2020
+ }
2021
+
2022
+ function toggleConfig() {
2023
+ const app = document.getElementById('app');
2024
+ if (isNarrowScreen()) {
2025
+ app.classList.toggle('sidebar-open');
2026
+ } else {
2027
+ app.classList.toggle('sidebar-collapsed');
2028
+ }
2029
+ requestAnimationFrame(updateAllSegmentThumbs);
2030
+ }
2031
+
2032
+ function setTheme(theme) {
2033
+ document.documentElement.dataset.theme = theme;
2034
+ try {
2035
+ localStorage.setItem('mmsp.playground.theme', theme);
2036
+ } catch (error) {
2037
+ // a browser that refuses storage keeps the choice for this page only
2038
+ }
2039
+ updateThemeToggle();
2040
+ }
2041
+
2042
+ function updateThemeToggle() {
2043
+ const stored = document.documentElement.dataset.theme;
2044
+ const theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
2045
+ document.querySelectorAll('#themeToggle [data-theme-choice]').forEach((button) => {
2046
+ button.setAttribute('aria-checked', button.dataset.themeChoice === theme ? 'true' : 'false');
2047
+ });
2048
+ updateSegmentThumb(document.getElementById('themeToggle'));
2049
+ }
2050
+
2051
+ // the thumb glides under the checked segment of a segmented control
2052
+ function updateSegmentThumb(root) {
2053
+ const thumb = root && root.querySelector('.seg-thumb');
2054
+ const checked = root && root.querySelector('[aria-checked="true"]');
2055
+ if (!thumb || !checked || !checked.offsetWidth) {
2056
+ return;
2057
+ }
2058
+ thumb.style.width = checked.offsetWidth + 'px';
2059
+ thumb.style.transform = \`translateX(\${checked.offsetLeft}px)\`;
2060
+ }
2061
+
2062
+ function updateAllSegmentThumbs() {
2063
+ document.querySelectorAll('.segmented').forEach(updateSegmentThumb);
2064
+ }
2065
+
2066
+ function closeCombobox(comboboxId) {
2067
+ const root = document.getElementById(comboboxId);
2068
+ if (!root) {
2069
+ return;
2070
+ }
2071
+ const menu = root.querySelector('[data-combobox-menu]');
2072
+ const button = root.querySelector('[data-combobox-button]');
2073
+ if (!menu || !button) {
2074
+ return;
2075
+ }
2076
+ menu.classList.add('hidden');
2077
+ button.setAttribute('aria-expanded', 'false');
2078
+ }
2079
+
2080
+ function closeComboboxes(exceptId) {
2081
+ document.querySelectorAll('[data-combobox]').forEach((root) => {
2082
+ if (root.id !== exceptId) {
2083
+ closeCombobox(root.id);
2084
+ }
2085
+ });
2086
+ }
2087
+
2088
+ function visibleOptions(menu) {
2089
+ return Array.from(menu.querySelectorAll('[data-combobox-option]')).filter((option) => !option.classList.contains('hidden'));
2090
+ }
2091
+
2092
+ function toggleCombobox(comboboxId) {
2093
+ const root = document.getElementById(comboboxId);
2094
+ const menu = root.querySelector('[data-combobox-menu]');
2095
+ const isOpen = !menu.classList.contains('hidden');
2096
+ closeComboboxes(comboboxId);
2097
+ if (isOpen) {
2098
+ closeCombobox(comboboxId);
2099
+ return;
2100
+ }
2101
+ menu.classList.remove('hidden');
2102
+ root.querySelector('[data-combobox-button]').setAttribute('aria-expanded', 'true');
2103
+
2104
+ const filter = menu.querySelector('.menu-search input');
2105
+ if (filter) {
2106
+ filter.value = '';
2107
+ filterModelOptions();
2108
+ filter.focus();
2109
+ }
2110
+ const selected = menu.querySelector('[data-combobox-option][aria-selected="true"]');
2111
+ if (selected) {
2112
+ selected.scrollIntoView({ block: 'nearest' });
2113
+ if (!filter) {
2114
+ selected.focus();
2115
+ }
2116
+ }
2117
+ }
2118
+
2119
+ function selectComboboxOption(comboboxId, option) {
2120
+ const root = document.getElementById(comboboxId);
2121
+ root.querySelector('[data-combobox-value]').value = option.dataset.value || '';
2122
+ const label = root.querySelector('[data-combobox-label]');
2123
+ if (label) {
2124
+ label.textContent = option.dataset.label || 'Default';
2125
+ }
2126
+
2127
+ root.querySelectorAll('[data-combobox-option]').forEach((item) => {
2128
+ const isSelected = item === option;
2129
+ item.setAttribute(item.getAttribute('role') === 'radio' ? 'aria-checked' : 'aria-selected', isSelected ? 'true' : 'false');
2130
+ });
2131
+ updateSegmentThumb(root.classList.contains('segmented') ? root : null);
2132
+
2133
+ const wasOpen = root.querySelector('[data-combobox-button][aria-expanded="true"]');
2134
+ closeCombobox(comboboxId);
2135
+ if (wasOpen) {
2136
+ wasOpen.focus();
2137
+ }
2138
+ if (comboboxId === 'modelCombobox') {
2139
+ handleModelSelectChange();
2140
+ } else if (comboboxId === 'clientTypeCombobox' && !settingClientType) {
2141
+ handleClientTypeChange();
2142
+ }
2143
+
2144
+ updateHeader();
2145
+ saveConfig();
2146
+ }
2147
+
2148
+ function handleComboboxKeydown(event, comboboxId) {
2149
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
2150
+ event.preventDefault();
2151
+ toggleCombobox(comboboxId);
2152
+ } else if (event.key === 'Escape') {
2153
+ closeCombobox(comboboxId);
2154
+ }
2155
+ }
2156
+
2157
+ // arrows walk the open menu, Escape hands focus back to its button
2158
+ function handleMenuKeydown(event, comboboxId) {
2159
+ const root = document.getElementById(comboboxId);
2160
+ const menu = root.querySelector('[data-combobox-menu]');
2161
+ const options = visibleOptions(menu);
2162
+ const index = options.indexOf(document.activeElement);
2163
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
2164
+ event.preventDefault();
2165
+ const step = event.key === 'ArrowDown' ? 1 : -1;
2166
+ const next = index < 0 ? (step > 0 ? 0 : options.length - 1) : Math.min(Math.max(index + step, 0), options.length - 1);
2167
+ if (options[next]) {
2168
+ options[next].focus();
2169
+ }
2170
+ } else if (event.key === 'Enter' && index < 0 && options.length) {
2171
+ event.preventDefault();
2172
+ selectComboboxOption(comboboxId, options[0]);
2173
+ } else if (event.key === 'Escape') {
2174
+ event.preventDefault();
2175
+ closeCombobox(comboboxId);
2176
+ root.querySelector('[data-combobox-button]').focus();
2177
+ }
2178
+ }
2179
+
2180
+ function filterModelOptions() {
2181
+ const input = document.getElementById('modelFilterInput');
2182
+ const query = input.value.trim().toLowerCase();
2183
+ let shown = 0;
2184
+ document.querySelectorAll('#modelComboboxMenu [data-combobox-option]').forEach((option) => {
2185
+ const text = \`\${option.dataset.label} \${option.dataset.value}\`.toLowerCase();
2186
+ const match = !query || option.dataset.value === '__custom__' || text.includes(query);
2187
+ option.classList.toggle('hidden', !match);
2188
+ if (match && option.dataset.value !== '__custom__') {
2189
+ shown += 1;
2190
+ }
2191
+ });
2192
+ document.getElementById('modelFilterEmpty').classList.toggle('hidden', shown > 0);
2193
+ }
2194
+
2195
+ document.addEventListener('click', (event) => {
2196
+ const target = event.target;
2197
+ if (!(target instanceof Element)) {
2198
+ return;
2199
+ }
2200
+ if (!target.closest('[data-combobox]')) {
2201
+ closeComboboxes();
2202
+ }
2203
+ });
2204
+
2205
+ // the client types, their model families, and the endpoint each type reaches when no base
2206
+ // URL is given (the environment's or the vendor's own), as the server knows them
2207
+ const PLAYGROUND = __PLAYGROUND_DEFAULTS__;
2208
+ // the base URL the field was last filled with, which counts as no base URL at all
2209
+ let filledBaseUrl = '';
2210
+ // set while the page picks a client type itself, which is not the user changing it
2211
+ let settingClientType = false;
2212
+
2213
+ const CLIENT_TYPE_DESCRIPTIONS = {
2214
+ 'openai-official': 'OpenAI',
2215
+ 'anthropic-official': 'Anthropic',
2216
+ 'gemini-official': 'Google Gemini',
2217
+ 'zai-official': 'Z.AI',
2218
+ 'moonshot-official': 'Moonshot',
2219
+ 'deepseek-official': 'DeepSeek',
2220
+ 'minimax-official': 'MiniMax',
2221
+ 'openai-responses': 'OpenAI Responses',
2222
+ 'openai-chat': 'OpenAI Chat Completions',
2223
+ 'openai-chat-vllm-adapter': 'Chat Completions on vLLM',
2224
+ 'openai-embedding': 'OpenAI Embeddings',
2225
+ 'ant-messages': 'Anthropic Messages',
2226
+ 'gemini-generate-content': 'Gemini generateContent'
2227
+ };
2228
+
2229
+ function clientTypeOption(value, label, description) {
2230
+ const option = document.createElement('button');
2231
+ option.type = 'button';
2232
+ option.setAttribute('role', 'option');
2233
+ option.setAttribute('aria-selected', 'false');
2234
+ option.className = 'combo-option';
2235
+ option.setAttribute('data-combobox-option', '');
2236
+ option.dataset.value = value;
2237
+ option.dataset.label = label;
2238
+ option.dataset.description = description;
2239
+ const text = document.createElement('span');
2240
+ text.textContent = label;
2241
+ if (value) {
2242
+ text.className = 'mono';
2243
+ }
2244
+ option.appendChild(text);
2245
+ option.onclick = () => selectComboboxOption('clientTypeCombobox', option);
2246
+ return option;
2247
+ }
2248
+
2249
+ function populateClientTypes() {
2250
+ const menu = document.getElementById('clientTypeComboboxMenu');
2251
+ menu.appendChild(clientTypeOption('', 'Auto', 'The official client the model id names'));
2252
+ [['Official: the vendor’s own API', PLAYGROUND.official], ['Compatible: any endpoint serving the protocol', PLAYGROUND.compatible]].forEach(([title, types]) => {
2253
+ const header = document.createElement('div');
2254
+ header.className = 'menu-heading';
2255
+ header.textContent = title;
2256
+ menu.appendChild(header);
2257
+ types.forEach((type) => menu.appendChild(clientTypeOption(type, type, CLIENT_TYPE_DESCRIPTIONS[type] || '')));
2258
+ });
2259
+ selectComboboxOption('clientTypeCombobox', menu.querySelector('[data-combobox-option]'));
2260
+ }
2261
+
2262
+ function setClientType(value) {
2263
+ const option = document.querySelector('#clientTypeComboboxMenu [data-combobox-option][data-value="' + value + '"]')
2264
+ || document.querySelector('#clientTypeComboboxMenu [data-combobox-option][data-value=""]');
2265
+ settingClientType = true;
2266
+ try {
2267
+ selectComboboxOption('clientTypeCombobox', option);
2268
+ } finally {
2269
+ settingClientType = false;
2270
+ }
2271
+ }
2272
+
2273
+ function familyClientType(model) {
2274
+ const lowered = (model || '').toLowerCase();
2275
+ const family = PLAYGROUND.families.find(([prefix]) => lowered.startsWith(prefix));
2276
+ return family ? family[1] : '';
2277
+ }
2278
+
2279
+ function selectedModelOption() {
2280
+ const modelSelect = document.getElementById('modelSelect');
2281
+ if (modelSelect.value === '__custom__') {
2282
+ return null;
2283
+ }
2284
+ return document.querySelector(
2285
+ '#modelComboboxMenu [data-combobox-option][data-value="' + modelSelect.value + '"]'
2286
+ );
2287
+ }
2288
+
2289
+ function getSelectedClientType() {
2290
+ return document.getElementById('clientTypeSelect').value;
2291
+ }
2292
+
2293
+ // the client the request will reach: the one chosen, else the one the model id names
2294
+ function effectiveClientType() {
2295
+ return getSelectedClientType() || familyClientType(getSelectedModel());
2296
+ }
2297
+
2298
+ function updateBaseUrlTag() {
2299
+ const value = document.getElementById('baseUrlInput').value.trim();
2300
+ document.getElementById('baseUrlDefaultTag').classList.toggle('hidden', !value || value !== filledBaseUrl);
2301
+ }
2302
+
2303
+ function fillBaseUrl(value) {
2304
+ document.getElementById('baseUrlInput').value = value;
2305
+ filledBaseUrl = value;
2306
+ updateBaseUrlTag();
2307
+ }
2308
+
2309
+ // the top bar, the empty state and the composer name the model a message goes to
2310
+ function updateHeader() {
2311
+ const model = getSelectedModel() || 'a custom model';
2312
+ const clientType = effectiveClientType();
2313
+ document.getElementById('headerModel').textContent = model;
2314
+ document.getElementById('headerClientType').textContent = clientType || 'client type required';
2315
+ document.getElementById('emptyStateModel').textContent = model;
2316
+ document.getElementById('messageInput').placeholder = \`Message \${model}\`;
2317
+ }
2318
+
2319
+ function handleModelSelectChange() {
2320
+ const useCustom = document.getElementById('modelSelect').value === '__custom__';
2321
+ document.getElementById('customModelWrapper').classList.toggle('hidden', !useCustom);
2322
+ if (useCustom) {
2323
+ document.getElementById('customModelInput').focus();
2324
+ return;
2325
+ }
2326
+
2327
+ // a model keeps its client type and base URL: a built-in starts from the ones its id
2328
+ // names, a listed model from the ones its listing ran under
2329
+ const option = selectedModelOption();
2330
+ const clientType = (option && option.dataset.clientType) || familyClientType(option && option.dataset.value);
2331
+ setClientType(clientType);
2332
+ const defaultUrl = PLAYGROUND.baseUrls[effectiveClientType()] || '';
2333
+ fillBaseUrl((option && option.dataset.baseUrl) || defaultUrl);
2334
+ filledBaseUrl = defaultUrl;
2335
+ updateBaseUrlTag();
2336
+ }
2337
+
2338
+ function handleClientTypeChange() {
2339
+ const option = selectedModelOption();
2340
+ if (option) {
2341
+ option.dataset.clientType = getSelectedClientType();
2342
+ }
2343
+ // an untouched base URL follows the client type to its default
2344
+ const input = document.getElementById('baseUrlInput');
2345
+ if (input.value.trim() === filledBaseUrl) {
2346
+ fillBaseUrl(PLAYGROUND.baseUrls[effectiveClientType()] || '');
2347
+ if (option) {
2348
+ delete option.dataset.baseUrl;
2349
+ }
2350
+ } else {
2351
+ filledBaseUrl = PLAYGROUND.baseUrls[effectiveClientType()] || '';
2352
+ updateBaseUrlTag();
2353
+ }
2354
+ saveConfig();
2355
+ }
2356
+
2357
+ function handleBaseUrlInput() {
2358
+ const option = selectedModelOption();
2359
+ if (option) {
2360
+ option.dataset.baseUrl = document.getElementById('baseUrlInput').value.trim();
2361
+ }
2362
+ updateBaseUrlTag();
2363
+ }
2364
+
2365
+ function getSelectedModel() {
2366
+ const modelSelect = document.getElementById('modelSelect');
2367
+ if (modelSelect.value === '__custom__') {
2368
+ return document.getElementById('customModelInput').value.trim();
2369
+ }
2370
+ return modelSelect.value;
2371
+ }
2372
+
2373
+ function toggleApiKeyVisibility() {
2374
+ const input = document.getElementById('apiKeyInput');
2375
+ const toggle = document.getElementById('apiKeyVisibilityToggle');
2376
+ const showIcon = document.getElementById('apiKeyVisibilityShowIcon');
2377
+ const hideIcon = document.getElementById('apiKeyVisibilityHideIcon');
2378
+ const shouldShow = input.type === 'password';
2379
+
2380
+ input.type = shouldShow ? 'text' : 'password';
2381
+ toggle.setAttribute('aria-label', shouldShow ? 'Hide API key' : 'Show API key');
2382
+ toggle.setAttribute('title', shouldShow ? 'Hide API key' : 'Show API key');
2383
+ showIcon.classList.toggle('hidden', !shouldShow);
2384
+ hideIcon.classList.toggle('hidden', shouldShow);
2385
+ }
2386
+
2387
+ function addListedModels(modelIds) {
2388
+ const menu = document.getElementById('modelComboboxMenu');
2389
+ const options = Array.from(menu.querySelectorAll('[data-combobox-option]'));
2390
+ const known = new Set(options.map((option) => option.dataset.value));
2391
+ const customOption = options.find((option) => option.dataset.value === '__custom__') || null;
2392
+ // a listed model is served by the endpoint that listed it, so it takes the current
2393
+ // client type and base URL
2394
+ const clientType = effectiveClientType();
2395
+ const baseUrl = document.getElementById('baseUrlInput').value.trim();
2396
+
2397
+ let added = 0;
2398
+ modelIds.forEach((modelId) => {
2399
+ if (known.has(modelId)) {
2400
+ return;
2401
+ }
2402
+
2403
+ const option = document.createElement('button');
2404
+ option.type = 'button';
2405
+ option.setAttribute('role', 'option');
2406
+ option.setAttribute('aria-selected', 'false');
2407
+ option.className = 'combo-option';
2408
+ option.setAttribute('data-combobox-option', '');
2409
+ option.dataset.value = modelId;
2410
+ option.dataset.label = modelId;
2411
+ option.dataset.description = clientType ? \`listed through \${clientType}\` : 'listed';
2412
+ option.dataset.listed = 'true';
2413
+ if (clientType) {
2414
+ option.dataset.clientType = clientType;
2415
+ }
2416
+ if (baseUrl) {
2417
+ option.dataset.baseUrl = baseUrl;
2418
+ }
2419
+ option.onclick = () => selectComboboxOption('modelCombobox', option);
2420
+
2421
+ const label = document.createElement('span');
2422
+ label.textContent = modelId;
2423
+ option.appendChild(label);
2424
+ menu.insertBefore(option, customOption);
2425
+ known.add(modelId);
2426
+ added += 1;
2427
+ });
2428
+
2429
+ return added;
2430
+ }
2431
+
2432
+ async function listModels() {
2433
+ const button = document.getElementById('listModelsButton');
2434
+ const status = document.getElementById('listModelsStatus');
2435
+ const error = document.getElementById('listModelsError');
2436
+
2437
+ button.disabled = true;
2438
+ status.textContent = 'Listing…';
2439
+ error.classList.add('hidden');
2440
+ try {
2441
+ const response = await fetch('/api/models', {
2442
+ method: 'POST',
2443
+ headers: { 'Content-Type': 'application/json' },
2444
+ body: JSON.stringify({ config: getConfig() })
2445
+ });
2446
+ const data = await response.json();
2447
+ if (!response.ok) {
2448
+ throw new Error(data.error || 'Request failed');
2449
+ }
2450
+
2451
+ const added = addListedModels(data.models);
2452
+ status.textContent = data.models.length + ' models, ' + added + ' added';
2453
+ } catch (err) {
2454
+ status.textContent = 'Failed';
2455
+ error.textContent = err.message || String(err);
2456
+ error.classList.remove('hidden');
2457
+ } finally {
2458
+ button.disabled = false;
2459
+ }
2460
+ }
2461
+
2462
+ function getExtraHeaders() {
2463
+ const input = document.getElementById('extraHeadersInput');
2464
+ const raw = input.value.trim();
2465
+ input.classList.remove('invalid');
2466
+ if (!raw) {
2467
+ return null;
2468
+ }
2469
+ try {
2470
+ const parsed = JSON.parse(raw);
2471
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
2472
+ return parsed;
2473
+ }
2474
+ } catch (error) {
2475
+ // the invalid marker below covers both a parse failure and a non-object
2476
+ }
2477
+ input.classList.add('invalid');
2478
+ return null;
2479
+ }
2480
+
2481
+ // the tools as typed, or null when the field is empty or not JSON, which marks it
2482
+ function validateTools() {
2483
+ const input = document.getElementById('toolsInput');
2484
+ const raw = input.value.trim();
2485
+ let tools = null;
2486
+ let valid = true;
2487
+ if (raw) {
2488
+ try {
2489
+ tools = JSON.parse(raw);
2490
+ } catch (error) {
2491
+ valid = false;
2492
+ }
2493
+ }
2494
+ input.classList.toggle('invalid', !valid);
2495
+ document.getElementById('toolsError').classList.toggle('hidden', valid);
2496
+ return tools;
2497
+ }
2498
+
2499
+ function getConfig() {
2500
+ const config = {
2501
+ model: getSelectedModel()
2502
+ };
2503
+
2504
+ const apiKey = document.getElementById('apiKeyInput').value.trim();
2505
+ if (apiKey) {
2506
+ config.api_key = apiKey;
2507
+ }
2508
+
2509
+ const clientType = getSelectedClientType();
2510
+ if (clientType) {
2511
+ config.client_type = clientType;
2512
+ }
2513
+
2514
+ // the endpoint filled in for the client type is where it goes anyway, so it stays out of
2515
+ // the request: a base URL sent without a key refuses the environment's OpenAI and
2516
+ // Anthropic keys
2517
+ const baseUrl = document.getElementById('baseUrlInput').value.trim();
2518
+ if (baseUrl && baseUrl !== filledBaseUrl) {
2519
+ config.base_url = baseUrl;
2520
+ }
2521
+
2522
+ const extraHeaders = getExtraHeaders();
2523
+ if (extraHeaders) {
2524
+ config.default_headers = extraHeaders;
2525
+ }
2526
+
2527
+ const thinkingLevel = document.getElementById('thinkingLevelSelect').value;
2528
+ if (thinkingLevel) {
2529
+ config.thinking_level = thinkingLevel;
2530
+ }
2531
+
2532
+ const thinkingSummary = document.getElementById('thinkingSummaryCheckbox').value;
2533
+ if (thinkingSummary) {
2534
+ config.thinking_summary = JSON.parse(thinkingSummary);
2535
+ }
2536
+
2537
+ const toolChoice = document.getElementById('toolChoiceSelect').value;
2538
+ if (toolChoice && toolChoice !== 'auto') {
2539
+ config.tool_choice = toolChoice;
2540
+ }
2541
+
2542
+ const systemPrompt = document.getElementById('systemPromptInput').value.trim();
2543
+ if (systemPrompt) {
2544
+ config.system_prompt = systemPrompt;
2545
+ }
2546
+
2547
+ const tools = validateTools();
2548
+ if (tools) {
2549
+ config.tools = tools;
2550
+ }
2551
+
2552
+ const traceId = document.getElementById('traceIdInput').value.trim();
2553
+ if (traceId) {
2554
+ config.trace_id = traceId;
2555
+ }
2556
+
2557
+ return config;
2558
+ }
2559
+
2560
+ const CONFIG_STORAGE_KEY = 'mmsp.playground.config';
2561
+ // saved as typed rather than as parsed values, so an unfinished JSON edit survives too
2562
+ const CONFIG_TEXT_INPUTS = [
2563
+ 'apiKeyInput', 'baseUrlInput', 'extraHeadersInput', 'systemPromptInput', 'toolsInput', 'traceIdInput'
2564
+ ];
2565
+ const CONFIG_COMBOBOXES = [
2566
+ ['thinkingLevelCombobox', 'thinkingLevelSelect'],
2567
+ ['thinkingSummaryCombobox', 'thinkingSummaryCheckbox'],
2568
+ ['toolChoiceCombobox', 'toolChoiceSelect']
2569
+ ];
2570
+ let restoringConfig = false;
2571
+
2572
+ function comboboxOption(comboboxId, value) {
2573
+ return document.querySelector('#' + comboboxId + ' [data-combobox-option][data-value="' + value + '"]');
2574
+ }
2575
+
2576
+ function saveConfig() {
2577
+ if (restoringConfig) {
2578
+ return;
2579
+ }
2580
+
2581
+ const saved = { model: getSelectedModel(), client_type: getSelectedClientType() };
2582
+ CONFIG_TEXT_INPUTS.forEach((id) => {
2583
+ saved[id] = document.getElementById(id).value;
2584
+ });
2585
+ CONFIG_COMBOBOXES.forEach(([, valueId]) => {
2586
+ saved[valueId] = document.getElementById(valueId).value;
2587
+ });
2588
+ try {
2589
+ localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(saved));
2590
+ } catch (error) {
2591
+ // a browser that refuses storage still runs the playground, just without the memory
2592
+ }
2593
+ }
2594
+
2595
+ function restoreConfig() {
2596
+ let saved = null;
2597
+ try {
2598
+ saved = JSON.parse(localStorage.getItem(CONFIG_STORAGE_KEY) || 'null');
2599
+ } catch (error) {
2600
+ saved = null;
2601
+ }
2602
+ if (!saved) {
2603
+ return;
2604
+ }
2605
+
2606
+ restoringConfig = true;
2607
+ try {
2608
+ CONFIG_COMBOBOXES.forEach(([comboboxId, valueId]) => {
2609
+ const option = comboboxOption(comboboxId, saved[valueId] || '');
2610
+ if (option) {
2611
+ selectComboboxOption(comboboxId, option);
2612
+ }
2613
+ });
2614
+
2615
+ // a model the menu does not list — one that was listed, or typed in — comes back as a custom entry
2616
+ const modelOption = saved.model ? comboboxOption('modelCombobox', saved.model) : null;
2617
+ if (modelOption) {
2618
+ selectComboboxOption('modelCombobox', modelOption);
2619
+ } else if (saved.model) {
2620
+ document.getElementById('customModelInput').value = saved.model;
2621
+ selectComboboxOption('modelCombobox', comboboxOption('modelCombobox', '__custom__'));
2622
+ // the custom option focuses its id field on selection, which a page load should not do
2623
+ document.getElementById('customModelInput').blur();
2624
+ }
2625
+ if (typeof saved.client_type === 'string') {
2626
+ setClientType(saved.client_type);
2627
+ handleClientTypeChange();
2628
+ }
2629
+ // a base URL typed over the filled default is kept on the model it was typed for
2630
+ CONFIG_TEXT_INPUTS.forEach((id) => {
2631
+ if (typeof saved[id] === 'string') {
2632
+ document.getElementById(id).value = saved[id];
2633
+ }
2634
+ });
2635
+ if (document.getElementById('baseUrlInput').value.trim() !== filledBaseUrl) {
2636
+ handleBaseUrlInput();
2637
+ }
2638
+ updateBaseUrlTag();
2639
+ validateTools();
2640
+ getExtraHeaders();
2641
+ updateHeader();
2642
+ } finally {
2643
+ restoringConfig = false;
2644
+ }
2645
+ }
2646
+
2647
+ // follows the stream only while the reader is at the bottom, so scrolling up to read stays put
2648
+ function isNearBottom() {
2649
+ const container = document.getElementById('messagesContainer');
2650
+ return container.scrollHeight - container.scrollTop - container.clientHeight < 80;
2651
+ }
2652
+
2653
+ function scrollToBottom(force = false) {
2654
+ const container = document.getElementById('messagesContainer');
2655
+ if (force || stickToBottom) {
2656
+ container.scrollTop = container.scrollHeight;
2657
+ }
2658
+ }
2659
+
2660
+ let stickToBottom = true;
2661
+ document.getElementById('messagesContainer').addEventListener('scroll', () => {
2662
+ stickToBottom = isNearBottom();
2663
+ });
2664
+
2665
+ function showThread() {
2666
+ document.getElementById('emptyState').classList.add('hidden');
2667
+ document.getElementById('thread').classList.remove('hidden');
2668
+ }
2669
+
2670
+ function addMessageCard(role, content, metadata = null, images = [], timestamp = null, tookMs = null) {
2671
+ const thread = document.getElementById('thread');
2672
+ showThread();
2673
+
2674
+ const card = document.createElement('div');
2675
+ const isUser = role === 'user';
2676
+ card.className = \`msg \${isUser ? 'msg-user' : 'msg-assistant'}\`;
2677
+
2678
+ let html = '';
2679
+ if (isUser) {
2680
+ html += \`<div class="msg-head"><span class="msg-timestamp" title="\${timestamp ? formatTimestamp(timestamp) : ''}">\${formatClock(timestamp)}</span></div>\`;
2681
+ html += '<div class="bubble">';
2682
+ if (images && images.length > 0) {
2683
+ html += '<div class="bubble-images">';
2684
+ images.forEach((img, idx) => {
2685
+ html += \`<img src="\${img}" alt="Image \${idx + 1}">\`;
2686
+ });
2687
+ html += '</div>';
2688
+ }
2689
+ html += \`<div class="message-content">\${escapeHtml(content || '')}</div></div>\`;
2690
+ } else {
2691
+ html += \`<div class="msg-head"><span class="msg-model">\${escapeHtml(getSelectedModel())}</span><span class="msg-took">\${tookMs !== null ? formatDuration(tookMs) : ''}</span><span class="msg-timestamp">\${timestamp ? formatClock(timestamp) : ''}</span></div>\`;
2692
+ html += \`<div class="message-content">\${escapeHtml(content || '')}</div>\`;
2693
+ }
2694
+
2695
+ if (metadata) {
2696
+ html += renderMetadata(metadata);
2697
+ }
2698
+
2699
+ card.innerHTML = html;
2700
+ thread.appendChild(card);
2701
+ scrollToBottom(true);
2702
+
2703
+ return card;
2704
+ }
2705
+
2706
+ function renderMetadata(metadata, tookMs = null, copyable = false) {
2707
+ let metadataHtml = '<div class="msg-foot">';
2708
+ if (metadata.finish_reason) {
2709
+ metadataHtml += \`<span class="reason reason-\${escapeHtml(metadata.finish_reason)}" title="Finish reason">\${escapeHtml(metadata.finish_reason)}</span>\`;
2710
+ }
2711
+ const parts = [];
2712
+ if (metadata.cached_tokens) parts.push(\`<span>Cached <b>\${formatCount(metadata.cached_tokens)}</b></span>\`);
2713
+ if (metadata.prompt_tokens) parts.push(\`<span>Prompt <b>\${formatCount(metadata.prompt_tokens)}</b></span>\`);
2714
+ if (metadata.thoughts_tokens) parts.push(\`<span>Thoughts <b>\${formatCount(metadata.thoughts_tokens)}</b></span>\`);
2715
+ if (metadata.response_tokens) parts.push(\`<span>Response <b>\${formatCount(metadata.response_tokens)}</b></span>\`);
2716
+ if (metadata.total_tokens) parts.push(\`<span>Total <b>\${formatCount(metadata.total_tokens)}</b></span>\`);
2717
+ if (parts.length) {
2718
+ metadataHtml += \`<span class="usage" title="Token usage">\${parts.join('')}</span>\`;
2719
+ }
2720
+ if (tookMs !== null) {
2721
+ metadataHtml += \`<span class="usage"><span>Took <b>\${formatDuration(tookMs)}</b></span></span>\`;
2722
+ }
2723
+ if (copyable) {
2724
+ metadataHtml += \`<button type="button" class="icon-btn copy-btn" onclick="copyMessage(this)" aria-label="Copy the text" title="Copy the text">\${ICONS.copy}</button>\`;
2725
+ }
2726
+ metadataHtml += '</div>';
2727
+ return metadataHtml;
2728
+ }
2729
+
2730
+ async function copyMessage(button) {
2731
+ const card = button.closest('.msg');
2732
+ const text = Array.from(card.querySelectorAll('.text-content')).map((node) => node.textContent).join('\\n\\n');
2733
+ try {
2734
+ await navigator.clipboard.writeText(text);
2735
+ button.innerHTML = ICONS.check;
2736
+ setTimeout(() => {
2737
+ button.innerHTML = ICONS.copy;
2738
+ }, 1400);
2739
+ } catch (error) {
2740
+ console.error('Copy failed:', error);
2741
+ }
2742
+ }
2743
+
2744
+ // shown until the first delta arrives, with the time the model has taken so far
2745
+ function showPending(contentDiv) {
2746
+ const pending = document.createElement('div');
2747
+ pending.className = 'pending';
2748
+ pending.innerHTML = \`<span class="pending-grid" aria-hidden="true">\${'<i></i>'.repeat(9)}</span><span class="shimmer">Waiting for the model</span><span class="elapsed">0.0 s</span>\`;
2749
+ contentDiv.appendChild(pending);
2750
+ const started = performance.now();
2751
+ const elapsed = pending.querySelector('.elapsed');
2752
+ const timer = setInterval(() => {
2753
+ elapsed.textContent = \`\${((performance.now() - started) / 1000).toFixed(1)} s\`;
2754
+ }, 100);
2755
+ return {
2756
+ clear() {
2757
+ clearInterval(timer);
2758
+ pending.remove();
2759
+ }
2760
+ };
2761
+ }
2762
+
2763
+ function setStreamingControls(streaming) {
2764
+ const sendButton = document.getElementById('sendButton');
2765
+ const stopButton = document.getElementById('stopButton');
2766
+ sendButton.classList.toggle('hidden', streaming);
2767
+ stopButton.disabled = !streaming;
2768
+ stopButton.classList.toggle('hidden', !streaming);
2769
+ updateSendState();
2770
+ }
2771
+
2772
+ function updateSendState() {
2773
+ const input = document.getElementById('messageInput');
2774
+ document.getElementById('sendButton').disabled = isStreaming || (!input.value.trim() && selectedImages.length === 0);
2775
+ }
2776
+
2777
+ function stopGeneration() {
2778
+ if (!isStreaming) return;
2779
+
2780
+ fetch('/api/abort', {
2781
+ method: 'POST',
2782
+ headers: {
2783
+ 'Content-Type': 'application/json',
2784
+ },
2785
+ body: JSON.stringify({
2786
+ session_id: sessionId
2787
+ }),
2788
+ keepalive: true
2789
+ }).catch(error => {
2790
+ console.error('Error interrupting chat:', error);
2791
+ });
2792
+
2793
+ if (currentAbortController) {
2794
+ currentAbortController.abort();
2795
+ }
2796
+ }
2797
+
2798
+ function markInterrupted(contentDiv) {
2799
+ const interruptedDiv = document.createElement('div');
2800
+ interruptedDiv.className = 'interrupted';
2801
+ interruptedDiv.textContent = 'Interrupted';
2802
+ contentDiv.appendChild(interruptedDiv);
2803
+ }
2804
+
2805
+ function showError(contentDiv, message) {
2806
+ const errorDiv = document.createElement('div');
2807
+ errorDiv.className = 'error-banner';
2808
+ errorDiv.textContent = message;
2809
+ contentDiv.appendChild(errorDiv);
2810
+ }
2811
+
2812
+ async function sendMessage() {
2813
+ const input = document.getElementById('messageInput');
2814
+ const message = input.value.trim();
2815
+
2816
+ if ((!message && selectedImages.length === 0) || isStreaming) return;
2817
+
2818
+ isStreaming = true;
2819
+ currentAbortController = new AbortController();
2820
+ setStreamingControls(true);
2821
+ input.value = '';
2822
+ resizeMessageInput();
2823
+
2824
+ const currentImages = [...selectedImages];
2825
+ selectedImages = [];
2826
+ updateImagePreview();
2827
+
2828
+ const userSendTime = Date.now();
2829
+ addMessageCard('user', message, null, currentImages, userSendTime);
2830
+
2831
+ const assistantCard = addMessageCard('assistant', '');
2832
+ const contentDiv = assistantCard.querySelector('.message-content');
2833
+ const pending = showPending(contentDiv);
2834
+ // items never interleave in a stream, so every delta belongs to the one item still open
2835
+ let openItem = null;
2836
+ // a spoken response streams as many small chunks that only play as one clip
2837
+ let audioStream = null;
2838
+ // where the next item's time starts: the request, then the end of the item before it
2839
+ let itemMark = performance.now();
2840
+
2841
+ try {
2842
+ const config = getConfig();
2843
+ const content_items = [];
2844
+
2845
+ if (message) {
2846
+ content_items.push({ type: 'text.done', text: message });
2847
+ }
2848
+
2849
+ currentImages.forEach(img => {
2850
+ content_items.push({ type: 'image_url.done', image_url: img });
2851
+ });
2852
+
2853
+ const response = await fetch('/api/chat', {
2854
+ method: 'POST',
2855
+ headers: {
2856
+ 'Content-Type': 'application/json',
2857
+ },
2858
+ body: JSON.stringify({
2859
+ message: {
2860
+ role: 'user',
2861
+ content_items: content_items
2862
+ },
2863
+ config: config,
2864
+ session_id: sessionId
2865
+ }),
2866
+ signal: currentAbortController.signal
2867
+ });
2868
+
2869
+ if (!response.ok || !response.body) {
2870
+ let errorMessage = \`Request failed with status \${response.status}\`;
2871
+ try {
2872
+ const errorPayload = await response.clone().json();
2873
+ if (errorPayload && errorPayload.error) {
2874
+ errorMessage = errorPayload.error;
2875
+ }
2876
+ } catch (e) {
2877
+ const errorText = await response.text();
2878
+ if (errorText) {
2879
+ errorMessage = errorText;
2880
+ }
2881
+ }
2882
+ throw new Error(errorMessage);
2883
+ }
2884
+
2885
+ const reader = response.body.getReader();
2886
+ const decoder = new TextDecoder();
2887
+ let metadata = null;
2888
+ let lastCreatedAt = null;
2889
+ let buffer = '';
2890
+
2891
+ while (true) {
2892
+ const { done, value } = await reader.read();
2893
+ if (done) break;
2894
+
2895
+ const chunk = decoder.decode(value);
2896
+ buffer += chunk;
2897
+ if (!buffer.endsWith('\\n\\n')) continue;
2898
+
2899
+ const lines = buffer.split('\\n');
2900
+ buffer = '';
2901
+ for (const line of lines) {
2902
+ if (line.startsWith('data: ')) {
2903
+ const data = line.slice(6);
2904
+ if (data === '[DONE]') continue;
2905
+
2906
+ try {
2907
+ const event = JSON.parse(data);
2908
+
2909
+ if (event.error) {
2910
+ pending.clear();
2911
+ showError(contentDiv, \`Error: \${event.error}\`);
2912
+ continue;
2913
+ }
2914
+
2915
+ if (event.event_type === 'stop') {
2916
+ const usage = event.usage_metadata;
2917
+ const inputTokens = (usage.cached_tokens || 0) + (usage.prompt_tokens || 0);
2918
+ const outputTokens = (usage.thoughts_tokens || 0) + (usage.response_tokens || 0);
2919
+ const totalTokens = inputTokens + outputTokens;
2920
+ metadata = {
2921
+ cached_tokens: usage.cached_tokens || 0,
2922
+ prompt_tokens: usage.prompt_tokens || 0,
2923
+ thoughts_tokens: usage.thoughts_tokens || 0,
2924
+ response_tokens: usage.response_tokens || 0,
2925
+ total_tokens: totalTokens,
2926
+ finish_reason: event.finish_reason
2927
+ };
2928
+ lastCreatedAt = event.created_at;
2929
+ continue;
2930
+ }
2931
+
2932
+ if (event.content_items.length) {
2933
+ pending.clear();
2934
+ }
2935
+
2936
+ // a delta event carries a fragment of the open item or its done item, whose complete
2937
+ // content replaces the fragments shown so far; an image or an embedding shows once
2938
+ // done, and thinking inline data not at all
2939
+ for (const item of event.content_items) {
2940
+ if (item.type === 'text.delta' || item.type === 'text.done') {
2941
+ openItem = openItem || openStreamItem(contentDiv, 'text', itemMark);
2942
+ openItem.text = item.type === 'text.done' ? item.text : openItem.text + item.text;
2943
+ openItem.container.textContent = openItem.text;
2944
+ } else if (item.type === 'thinking.delta' || item.type === 'thinking.done') {
2945
+ openItem = openItem || openStreamItem(contentDiv, 'thinking', itemMark);
2946
+ openItem.text = item.type === 'thinking.done' ? item.thinking : openItem.text + item.thinking;
2947
+ openItem.container.innerHTML = renderThinking(openItem.text);
2948
+ } else if (item.type === 'tool_call.delta' || item.type === 'tool_call.done') {
2949
+ openItem = openItem || openStreamItem(contentDiv, 'tool_call', itemMark);
2950
+ // only the first delta names the call, and only the done item has parsed arguments
2951
+ openItem.name = item.name || openItem.name;
2952
+ openItem.text = item.type === 'tool_call.done' ? JSON.stringify(item.arguments, null, 2) : openItem.text + item.arguments;
2953
+ openItem.root.querySelector('.tool-name').textContent = openItem.name;
2954
+ openItem.container.textContent = item.type === 'tool_call.done' ? openItem.text : prettyArguments(openItem.text);
2955
+ } else if (item.type === 'inline_data.delta' && isAudioMimeType(item.mime_type)) {
2956
+ audioStream = audioStream || { mimeType: '', chunks: [], bytes: 0, container: null, finalized: false };
2957
+ appendAudioChunk(contentDiv, item, audioStream);
2958
+ } else if (item.type === 'inline_data.done' && isAudioMimeType(item.mime_type)) {
2959
+ audioStream.chunks = [item.data];
2960
+ finalizeAudioStream(audioStream, true);
2961
+ audioStream = null;
2962
+ } else if (item.type === 'inline_data.done') {
2963
+ contentDiv.insertAdjacentHTML('beforeend', renderInlineData(item));
2964
+ } else if (item.type === 'embedding.done') {
2965
+ contentDiv.insertAdjacentHTML('beforeend', renderEmbedding(item));
2966
+ }
2967
+
2968
+ if (item.type.endsWith('.done')) {
2969
+ finishStreamItem(openItem);
2970
+ openItem = null;
2971
+ itemMark = performance.now();
2972
+ }
2973
+ }
2974
+
2975
+ scrollToBottom();
2976
+ } catch (e) {
2977
+ console.error('Error parsing event:', e);
2978
+ }
2979
+ }
2980
+ }
2981
+ }
2982
+
2983
+ if (lastCreatedAt) {
2984
+ const timestampEl = assistantCard.querySelector('.msg-timestamp');
2985
+ if (timestampEl) {
2986
+ timestampEl.textContent = formatClock(lastCreatedAt);
2987
+ timestampEl.title = formatTimestamp(lastCreatedAt);
2988
+ }
2989
+ }
2990
+
2991
+ const endTime = Date.now();
2992
+ const responseTimeMs = endTime - userSendTime;
2993
+ lastMessageTimestamp = endTime;
2994
+ const tookEl = assistantCard.querySelector('.msg-took');
2995
+ if (tookEl) {
2996
+ tookEl.textContent = formatDuration(responseTimeMs);
2997
+ }
2998
+
2999
+ if (metadata) {
3000
+ const metadataHtml = renderMetadata(metadata, responseTimeMs, Boolean(contentDiv.querySelector('.text-content')));
3001
+ // appended rather than re-parsed into the card, which would restart a playing clip
3002
+ assistantCard.insertAdjacentHTML('beforeend', metadataHtml);
3003
+ }
3004
+
3005
+ } catch (error) {
3006
+ if (error.name === 'AbortError') {
3007
+ if (audioStream) {
3008
+ finalizeAudioStream(audioStream);
3009
+ }
3010
+ markInterrupted(contentDiv);
3011
+ } else {
3012
+ showError(contentDiv, \`Error: \${error.message}\`);
3013
+ console.error('Error:', error);
3014
+ }
3015
+ lastMessageTimestamp = Date.now();
3016
+ } finally {
3017
+ pending.clear();
3018
+ finishStreamItem(openItem);
3019
+ isStreaming = false;
3020
+ currentAbortController = null;
3021
+ setStreamingControls(false);
3022
+ scrollToBottom();
3023
+ input.focus();
3024
+ }
3025
+ }
3026
+
3027
+ function resetThread() {
3028
+ sessionId = Math.random().toString(36).substring(7);
3029
+ lastMessageTimestamp = null;
3030
+ document.getElementById('thread').innerHTML = '';
3031
+ document.getElementById('thread').classList.add('hidden');
3032
+ document.getElementById('emptyState').classList.remove('hidden');
3033
+ }
3034
+
3035
+ function clearChat() {
3036
+ const hasMessages = document.getElementById('thread').children.length > 0;
3037
+ if (isStreaming) {
3038
+ stopGeneration();
3039
+ }
3040
+ if (hasMessages && !confirm('Start a new chat? This conversation will be cleared.')) {
3041
+ return;
3042
+ }
3043
+ fetch('/api/clear', {
3044
+ method: 'POST',
3045
+ headers: {
3046
+ 'Content-Type': 'application/json',
3047
+ },
3048
+ body: JSON.stringify({
3049
+ session_id: sessionId
3050
+ })
3051
+ }).then(() => {
3052
+ resetThread();
3053
+ document.getElementById('messageInput').focus();
3054
+ }).catch(error => {
3055
+ console.error('Error clearing chat:', error);
3056
+ });
3057
+ }
3058
+
3059
+ document.getElementById('messageInput').addEventListener('keydown', function(e) {
3060
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
3061
+ e.preventDefault();
3062
+ sendMessage();
3063
+ }
3064
+ });
3065
+
3066
+ const textarea = document.getElementById('messageInput');
3067
+ function resizeMessageInput() {
3068
+ const maxHeight = 200;
3069
+ textarea.style.height = 'auto';
3070
+ const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
3071
+ textarea.style.height = nextHeight + 'px';
3072
+ textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
3073
+ updateSendState();
3074
+ }
3075
+ textarea.addEventListener('input', resizeMessageInput);
3076
+
3077
+ // images pasted or dropped onto the composer attach like picked ones
3078
+ textarea.addEventListener('paste', (event) => {
3079
+ const files = Array.from(event.clipboardData ? event.clipboardData.files : []);
3080
+ if (files.length) {
3081
+ event.preventDefault();
3082
+ addImageFiles(files);
3083
+ }
3084
+ });
3085
+ const composer = document.getElementById('composer');
3086
+ composer.addEventListener('dragover', (event) => {
3087
+ event.preventDefault();
3088
+ composer.classList.add('dragging');
3089
+ });
3090
+ composer.addEventListener('dragleave', () => composer.classList.remove('dragging'));
3091
+ composer.addEventListener('drop', (event) => {
3092
+ event.preventDefault();
3093
+ composer.classList.remove('dragging');
3094
+ if (event.dataTransfer && event.dataTransfer.files.length) {
3095
+ addImageFiles(event.dataTransfer.files);
3096
+ }
3097
+ });
3098
+
3099
+ document.getElementById('configPanel').addEventListener('input', saveConfig);
3100
+ // setting up the page selects its defaults, which is not a change to save over the stored one
3101
+ restoringConfig = true;
3102
+ populateClientTypes();
3103
+ handleModelSelectChange();
3104
+ restoringConfig = false;
3105
+ restoreConfig();
3106
+ updateHeader();
3107
+ resizeMessageInput();
3108
+
3109
+ updateThemeToggle();
3110
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeToggle);
3111
+ window.addEventListener('resize', updateAllSegmentThumbs);
3112
+ // the thumbs measure their segments, which the web font changes once it loads
3113
+ document.fonts.ready.then(updateAllSegmentThumbs);
3114
+ updateAllSegmentThumbs();
3115
+ if (!isNarrowScreen()) {
3116
+ textarea.focus();
3117
+ }
3118
+ </script>
3119
+ </body>
3120
+ </html>
3121
+ `;
3122
+ app.get("/", (_req, res) => {
3123
+ res.send(CHAT_TEMPLATE.replace("__PLAYGROUND_DEFAULTS__", playgroundDefaults()));
3124
+ });
3125
+ app.post("/api/chat", async (req, res) => {
3126
+ const { message, config, session_id } = req.body;
3127
+ if (!message) {
3128
+ return res.status(400).json({ error: "No message provided" });
3129
+ }
3130
+ const sessionId = session_id || "default";
3131
+ res.setHeader("Content-Type", "text/event-stream");
3132
+ res.setHeader("Cache-Control", "no-cache");
3133
+ res.setHeader("Connection", "keep-alive");
3134
+ const abortController = new AbortController();
3135
+ sessionAbortControllers.get(sessionId)?.abort();
3136
+ sessionAbortControllers.set(sessionId, abortController);
3137
+ let completed = false;
3138
+ res.on("close", () => {
3139
+ if (!completed) {
3140
+ abortController.abort();
3141
+ }
3142
+ });
3143
+ try {
3144
+ const clientOptions = getClientOptions(config || {});
3145
+ if (!sessionClients.has(sessionId) ||
3146
+ clientOptionsChanged(sessionClientOptions.get(sessionId), clientOptions)) {
3147
+ sessionClients.set(sessionId, new autoClient_1.AutoLLMClient(clientOptions));
3148
+ sessionClientOptions.set(sessionId, clientOptions);
3149
+ }
3150
+ const client = sessionClients.get(sessionId);
3151
+ const requestConfig = getRequestConfig(config || {});
3152
+ for await (const event of client.streamingResponseStateful({
3153
+ message,
3154
+ config: requestConfig,
3155
+ signal: abortController.signal,
3156
+ })) {
3157
+ const serializedEvent = serializeForJson(event);
3158
+ res.write(`data: ${JSON.stringify(serializedEvent)}\n\n`);
3159
+ }
3160
+ completed = true;
3161
+ res.write("data: [DONE]\n\n");
3162
+ res.end();
3163
+ }
3164
+ catch (error) {
3165
+ if (abortController.signal.aborted) {
3166
+ completed = true;
3167
+ if (!res.writableEnded && !res.destroyed) {
3168
+ res.write("data: [DONE]\n\n");
3169
+ res.end();
3170
+ }
3171
+ return;
3172
+ }
3173
+ // a failed response has no stop event, so the page gets the error as an event of its own,
3174
+ // named by its class when it carries no message, since the page shows no empty error
3175
+ const errorEvent = {
3176
+ error: error instanceof Error
3177
+ ? error.message || error.constructor.name
3178
+ : String(error),
3179
+ };
3180
+ completed = true;
3181
+ res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
3182
+ res.write("data: [DONE]\n\n");
3183
+ res.end();
3184
+ }
3185
+ finally {
3186
+ if (sessionAbortControllers.get(sessionId) === abortController) {
3187
+ sessionAbortControllers.delete(sessionId);
3188
+ }
3189
+ }
3190
+ });
3191
+ app.post("/api/abort", (req, res) => {
3192
+ const { session_id } = req.body;
3193
+ const sessionId = session_id || "default";
3194
+ const abortController = sessionAbortControllers.get(sessionId);
3195
+ if (!abortController) {
3196
+ return res.json({ status: "idle" });
3197
+ }
3198
+ abortController.abort();
3199
+ return res.json({ status: "aborted" });
3200
+ });
3201
+ app.post("/api/clear", (req, res) => {
3202
+ const { session_id } = req.body;
3203
+ const sessionId = session_id || "default";
3204
+ const abortController = sessionAbortControllers.get(sessionId);
3205
+ if (abortController) {
3206
+ abortController.abort();
3207
+ sessionAbortControllers.delete(sessionId);
3208
+ }
3209
+ if (sessionClients.has(sessionId)) {
3210
+ const client = sessionClients.get(sessionId);
3211
+ client.clearHistory();
3212
+ sessionClients.delete(sessionId);
3213
+ sessionClientOptions.delete(sessionId);
3214
+ }
3215
+ res.json({ status: "success" });
3216
+ });
3217
+ app.post("/api/models", async (req, res) => {
3218
+ const { config } = req.body;
3219
+ try {
3220
+ const client = new autoClient_1.AutoLLMClient(getClientOptions(config || {}));
3221
+ res.json({ models: await client.listModels() });
3222
+ }
3223
+ catch (error) {
3224
+ // a rejected key, an unreachable base URL, or a client that cannot list
3225
+ res.status(400).json({
3226
+ error: error instanceof Error ? error.message : String(error),
3227
+ });
3228
+ }
3229
+ });
3230
+ return app;
3231
+ }
3232
+ /**
3233
+ * Start the playground web server.
3234
+ *
3235
+ * @param host - Host address to bind to
3236
+ * @param port - Port number to listen on
3237
+ */
3238
+ function startPlaygroundServer(host = "127.0.0.1", port = 25751) {
3239
+ // the playground exists to show what a model and its endpoint actually send, so unknown
3240
+ // stream output fails loudly here unless the caller says otherwise
3241
+ process.env.MMSP_DEBUG = process.env.MMSP_DEBUG ?? "1";
3242
+ const app = createChatApp();
3243
+ app.listen(port, host, () => {
3244
+ console.log(`Starting LLM Playground at http://${host}:${port}`);
3245
+ });
3246
+ }