@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.
- package/README.md +121 -0
- package/dist/ant_messages/client.d.ts +59 -0
- package/dist/ant_messages/client.d.ts.map +1 -0
- package/dist/ant_messages/client.js +419 -0
- package/dist/ant_messages/index.d.ts +2 -0
- package/dist/ant_messages/index.d.ts.map +1 -0
- package/dist/ant_messages/index.js +18 -0
- package/dist/anthropic_official/client.d.ts +63 -0
- package/dist/anthropic_official/client.d.ts.map +1 -0
- package/dist/anthropic_official/client.js +540 -0
- package/dist/anthropic_official/index.d.ts +2 -0
- package/dist/anthropic_official/index.d.ts.map +1 -0
- package/dist/anthropic_official/index.js +18 -0
- package/dist/autoClient.d.ts +96 -0
- package/dist/autoClient.d.ts.map +1 -0
- package/dist/autoClient.js +258 -0
- package/dist/baseClient.d.ts +111 -0
- package/dist/baseClient.d.ts.map +1 -0
- package/dist/baseClient.js +284 -0
- package/dist/deepseek_official/client.d.ts +60 -0
- package/dist/deepseek_official/client.d.ts.map +1 -0
- package/dist/deepseek_official/client.js +407 -0
- package/dist/deepseek_official/index.d.ts +2 -0
- package/dist/deepseek_official/index.d.ts.map +1 -0
- package/dist/deepseek_official/index.js +18 -0
- package/dist/errors.d.ts +84 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +140 -0
- package/dist/gemini_generate_content/client.d.ts +86 -0
- package/dist/gemini_generate_content/client.d.ts.map +1 -0
- package/dist/gemini_generate_content/client.js +809 -0
- package/dist/gemini_generate_content/index.d.ts +2 -0
- package/dist/gemini_generate_content/index.d.ts.map +1 -0
- package/dist/gemini_generate_content/index.js +18 -0
- package/dist/gemini_official/client.d.ts +87 -0
- package/dist/gemini_official/client.d.ts.map +1 -0
- package/dist/gemini_official/client.js +780 -0
- package/dist/gemini_official/index.d.ts +2 -0
- package/dist/gemini_official/index.d.ts.map +1 -0
- package/dist/gemini_official/index.js +18 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/integration/index.d.ts +1 -0
- package/dist/integration/index.d.ts.map +1 -0
- package/dist/integration/index.js +14 -0
- package/dist/integration/playground.d.ts +17 -0
- package/dist/integration/playground.d.ts.map +1 -0
- package/dist/integration/playground.js +3246 -0
- package/dist/integration/tracer.d.ts +188 -0
- package/dist/integration/tracer.d.ts.map +1 -0
- package/dist/integration/tracer.js +1928 -0
- package/dist/legacy.d.ts +13 -0
- package/dist/legacy.d.ts.map +1 -0
- package/dist/legacy.js +59 -0
- package/dist/minimax_official/client.d.ts +43 -0
- package/dist/minimax_official/client.d.ts.map +1 -0
- package/dist/minimax_official/client.js +346 -0
- package/dist/minimax_official/index.d.ts +2 -0
- package/dist/minimax_official/index.d.ts.map +1 -0
- package/dist/minimax_official/index.js +18 -0
- package/dist/moonshot_official/client.d.ts +73 -0
- package/dist/moonshot_official/client.d.ts.map +1 -0
- package/dist/moonshot_official/client.js +477 -0
- package/dist/moonshot_official/index.d.ts +2 -0
- package/dist/moonshot_official/index.d.ts.map +1 -0
- package/dist/moonshot_official/index.js +18 -0
- package/dist/openai_chat/client.d.ts +67 -0
- package/dist/openai_chat/client.d.ts.map +1 -0
- package/dist/openai_chat/client.js +419 -0
- package/dist/openai_chat/index.d.ts +2 -0
- package/dist/openai_chat/index.d.ts.map +1 -0
- package/dist/openai_chat/index.js +18 -0
- package/dist/openai_chat_vllm_adapter/client.d.ts +15 -0
- package/dist/openai_chat_vllm_adapter/client.d.ts.map +1 -0
- package/dist/openai_chat_vllm_adapter/client.js +118 -0
- package/dist/openai_chat_vllm_adapter/index.d.ts +2 -0
- package/dist/openai_chat_vllm_adapter/index.d.ts.map +1 -0
- package/dist/openai_chat_vllm_adapter/index.js +5 -0
- package/dist/openai_embedding/client.d.ts +46 -0
- package/dist/openai_embedding/client.d.ts.map +1 -0
- package/dist/openai_embedding/client.js +124 -0
- package/dist/openai_embedding/index.d.ts +2 -0
- package/dist/openai_embedding/index.d.ts.map +1 -0
- package/dist/openai_embedding/index.js +18 -0
- package/dist/openai_official/client.d.ts +61 -0
- package/dist/openai_official/client.d.ts.map +1 -0
- package/dist/openai_official/client.js +464 -0
- package/dist/openai_official/index.d.ts +2 -0
- package/dist/openai_official/index.d.ts.map +1 -0
- package/dist/openai_official/index.js +18 -0
- package/dist/openai_responses/client.d.ts +61 -0
- package/dist/openai_responses/client.d.ts.map +1 -0
- package/dist/openai_responses/client.js +449 -0
- package/dist/openai_responses/index.d.ts +2 -0
- package/dist/openai_responses/index.d.ts.map +1 -0
- package/dist/openai_responses/index.js +18 -0
- package/dist/registry.d.ts +49 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +798 -0
- package/dist/streamItems.d.ts +36 -0
- package/dist/streamItems.d.ts.map +1 -0
- package/dist/streamItems.js +183 -0
- package/dist/types.d.ts +190 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +37 -0
- package/dist/utils.d.ts +99 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +312 -0
- package/dist/zai_official/client.d.ts +78 -0
- package/dist/zai_official/client.d.ts.map +1 -0
- package/dist/zai_official/client.js +420 -0
- package/dist/zai_official/index.d.ts +2 -0
- package/dist/zai_official/index.d.ts.map +1 -0
- package/dist/zai_official/index.js +18 -0
- 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
|
+
}
|