tinywebui 0.1.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/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- package/src/tools/task_tool.js +63 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns which chat is open and the form that sends into it -- the orchestrator
|
|
3
|
+
* that ties the transcript, the attachment tray and the sidebar together.
|
|
4
|
+
*/
|
|
5
|
+
import { $ } from '../core/dom.js';
|
|
6
|
+
import { state } from '../core/state.js';
|
|
7
|
+
import { addUser, replay, addError, pinToBottom } from './transcript.js';
|
|
8
|
+
import { consume } from './stream.js';
|
|
9
|
+
import { commitAttachments, invalidAttachments, renderAttachments, renderChatDocs, resetChatDocsView } from './attachments.js';
|
|
10
|
+
import { loadChats, clearSearch, closeSideDrawer } from './sidebar.js';
|
|
11
|
+
import { resetOutline } from './outline.js';
|
|
12
|
+
import { renderTasks, dropFinishedTasks } from './tasks.js';
|
|
13
|
+
import { renderQueue, enqueue, reclaimQueue, holdFollowup } from './queue.js';
|
|
14
|
+
import { api } from '../core/api.js';
|
|
15
|
+
import { newId } from '../core/id.js';
|
|
16
|
+
|
|
17
|
+
const input = $('input');
|
|
18
|
+
let chatLoadCtrl = null;
|
|
19
|
+
|
|
20
|
+
function cancelChatLoad() {
|
|
21
|
+
chatLoadCtrl?.abort();
|
|
22
|
+
chatLoadCtrl = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function initChat() {
|
|
26
|
+
input.addEventListener('input', () => {
|
|
27
|
+
input.style.height = 'auto';
|
|
28
|
+
input.style.height = Math.min(input.scrollHeight, 190) + 'px';
|
|
29
|
+
if (state.busy) setBusy(true);
|
|
30
|
+
});
|
|
31
|
+
input.addEventListener('keydown', (e) => {
|
|
32
|
+
if (e.key !== 'Enter' || e.shiftKey) return;
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
// While a turn runs, Alt+Enter holds it for later; plain Enter steers it.
|
|
35
|
+
if (e.altKey && state.busy) holdInput();
|
|
36
|
+
else $('form').requestSubmit();
|
|
37
|
+
});
|
|
38
|
+
$('form').addEventListener('submit', onSubmit);
|
|
39
|
+
|
|
40
|
+
// A run is the server's, so the dot in the sidebar can change without this tab
|
|
41
|
+
// doing anything at all. Poll only while there is something to watch.
|
|
42
|
+
setInterval(async () => {
|
|
43
|
+
if (!state.busy && !state.chats.some((c) => c.running)) return;
|
|
44
|
+
await loadChats();
|
|
45
|
+
const id = state.chat?.id;
|
|
46
|
+
if (!state.busy && id && state.chats.some((chat) => chat.id === id && chat.running)) rejoin(id);
|
|
47
|
+
}, 4000);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parks what is typed above the composer until its send button is clicked. */
|
|
51
|
+
function holdInput() {
|
|
52
|
+
const text = input.value.trim();
|
|
53
|
+
if (!text || !state.chat.id) return;
|
|
54
|
+
input.value = '';
|
|
55
|
+
input.dispatchEvent(new Event('input'));
|
|
56
|
+
holdFollowup(text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A held follow-up, confirmed: into the running turn, or as the next message. */
|
|
60
|
+
export function sendHeld(text) {
|
|
61
|
+
if (!state.busy) input.value = [text, input.value.trim()].filter(Boolean).join('\n\n');
|
|
62
|
+
else input.value = text;
|
|
63
|
+
queueOrSend();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function queueOrSend() {
|
|
67
|
+
if (state.busy) queueInput('steer');
|
|
68
|
+
else $('form').requestSubmit();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sends what is typed to the running turn instead of starting a new one. */
|
|
72
|
+
async function queueInput(kind) {
|
|
73
|
+
const text = input.value.trim();
|
|
74
|
+
if (!text || !state.chat.id) return;
|
|
75
|
+
input.value = '';
|
|
76
|
+
input.dispatchEvent(new Event('input'));
|
|
77
|
+
// 409: the turn finished in the meantime, so this is just the next message.
|
|
78
|
+
if (!(await enqueue(kind, text))) {
|
|
79
|
+
input.value = text;
|
|
80
|
+
setBusy(false);
|
|
81
|
+
$('form').requestSubmit();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** After a stream ends: follow the run a follow-up started, or take back what never went. */
|
|
86
|
+
async function afterRun({ next } = {}, id) {
|
|
87
|
+
if (next && state.chat.id === id) return rejoin(id);
|
|
88
|
+
if (state.chat.id === id) await reclaimQueue();
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Cancels whatever stream read the view currently on screen owns. */
|
|
93
|
+
function leaveView() {
|
|
94
|
+
state.viewCtrl?.abort();
|
|
95
|
+
state.viewCtrl = null;
|
|
96
|
+
if (state.busy) setBusy(false);
|
|
97
|
+
renderQueue([]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function newChat() {
|
|
101
|
+
cancelChatLoad();
|
|
102
|
+
leaveView();
|
|
103
|
+
state.chat = { id: null, title: 'New chat' };
|
|
104
|
+
renderTasks();
|
|
105
|
+
$('wrap').innerHTML = '';
|
|
106
|
+
pinToBottom();
|
|
107
|
+
state.pendingAttachments = [];
|
|
108
|
+
renderAttachments();
|
|
109
|
+
state.chatDocuments = [];
|
|
110
|
+
state.chatImages = [];
|
|
111
|
+
resetChatDocsView();
|
|
112
|
+
renderChatDocs();
|
|
113
|
+
resetOutline();
|
|
114
|
+
// Starting fresh is a clear signal that browsing is done; a lingering
|
|
115
|
+
// search would otherwise still be sitting over the sidebar underneath it,
|
|
116
|
+
// and on mobile the drawer would otherwise still be covering the composer.
|
|
117
|
+
clearSearch();
|
|
118
|
+
closeSideDrawer();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function openChat(id) {
|
|
122
|
+
// A slower earlier click must never replace the chat selected most recently.
|
|
123
|
+
cancelChatLoad();
|
|
124
|
+
const ctrl = new AbortController();
|
|
125
|
+
chatLoadCtrl = ctrl;
|
|
126
|
+
let found;
|
|
127
|
+
try {
|
|
128
|
+
found = await api.get(`/api/chats/${id}`, { signal: ctrl.signal });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (chatLoadCtrl === ctrl) {
|
|
131
|
+
chatLoadCtrl = null;
|
|
132
|
+
if (err.name !== 'AbortError') addError(`Could not open chat (${err.message})`);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (chatLoadCtrl !== ctrl) return;
|
|
137
|
+
chatLoadCtrl = null;
|
|
138
|
+
leaveView();
|
|
139
|
+
state.chat = { id: found.id, title: found.title };
|
|
140
|
+
renderTasks(found.tasks || []);
|
|
141
|
+
$('wrap').innerHTML = '';
|
|
142
|
+
pinToBottom();
|
|
143
|
+
state.pendingAttachments = [];
|
|
144
|
+
renderAttachments();
|
|
145
|
+
state.chatDocuments = found.documents || [];
|
|
146
|
+
// No dedicated images endpoint -- every image already rides inline on the
|
|
147
|
+
// user message that attached it, so the rail is built from the same
|
|
148
|
+
// history the transcript renders rather than a second round trip.
|
|
149
|
+
state.chatImages = (found.messages || [])
|
|
150
|
+
.filter((m) => m.role === 'user' && m.images?.length)
|
|
151
|
+
.flatMap((m) => m.images);
|
|
152
|
+
resetChatDocsView();
|
|
153
|
+
renderChatDocs();
|
|
154
|
+
resetOutline();
|
|
155
|
+
// Mid-turn, the server hands back only the settled part of the transcript;
|
|
156
|
+
// the rest arrives as events, exactly as it did for the tab that started it.
|
|
157
|
+
replay(found.messages, found.documents);
|
|
158
|
+
renderQueue(found.queued || []);
|
|
159
|
+
clearSearch();
|
|
160
|
+
closeSideDrawer();
|
|
161
|
+
if (found.running) rejoin(id);
|
|
162
|
+
else reclaimQueue();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Follows a turn already in flight, from the top of its event buffer. */
|
|
166
|
+
async function rejoin(id) {
|
|
167
|
+
const ctrl = new AbortController();
|
|
168
|
+
state.viewCtrl = ctrl;
|
|
169
|
+
setBusy(true);
|
|
170
|
+
let outcome;
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(`/api/chats/${id}/stream?from=0`, { signal: ctrl.signal });
|
|
173
|
+
if (res.ok) outcome = await consume(res);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// Navigating away aborts this on purpose; anything else just means the
|
|
176
|
+
// transcript is in the store and reopening picks it up.
|
|
177
|
+
}
|
|
178
|
+
// A later navigation already swapped in its own controller and reset
|
|
179
|
+
// `busy` for its own view -- stepping on either here would be wrong.
|
|
180
|
+
if (state.viewCtrl === ctrl) {
|
|
181
|
+
state.viewCtrl = null;
|
|
182
|
+
setBusy(false);
|
|
183
|
+
loadChats();
|
|
184
|
+
await afterRun(outcome, id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const SVG = (d) => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
|
|
189
|
+
const SEND_ICON = SVG('<path d="M12 19V5M5 12l7-7 7 7"/>');
|
|
190
|
+
const STOP_ICON = SVG('<rect x="7" y="7" width="10" height="10" rx="1.5" fill="currentColor"/>');
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* While a turn runs, send becomes stop -- the run outlives this tab either
|
|
194
|
+
* way -- unless something is typed, which the arrow then queues on the turn.
|
|
195
|
+
*/
|
|
196
|
+
function setBusy(on) {
|
|
197
|
+
const wasBusy = state.busy;
|
|
198
|
+
state.busy = on;
|
|
199
|
+
const stop = on && !input.value.trim();
|
|
200
|
+
// Icon-only button: the arrow sends, the square stops; the label says which.
|
|
201
|
+
$('send').innerHTML = stop ? STOP_ICON : SEND_ICON;
|
|
202
|
+
$('send').setAttribute('aria-label', stop ? 'Stop' : on ? 'Queue message (Alt+Enter: hold for later)' : 'Send');
|
|
203
|
+
$('send').title = on && !stop ? 'Enter: next step · Alt+Enter: hold for later' : '';
|
|
204
|
+
$('send').classList.toggle('stop', stop);
|
|
205
|
+
input.placeholder = on ? 'Add a message to this turn…' : 'Type a message…';
|
|
206
|
+
if (!on && wasBusy) input.focus();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function onSubmit(e) {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
if (state.busy) {
|
|
212
|
+
if (input.value.trim()) return queueInput('steer');
|
|
213
|
+
// Closing the tab no longer stops a turn, so there has to be a way to
|
|
214
|
+
// actually mean it.
|
|
215
|
+
if (!state.chat.id) return;
|
|
216
|
+
try {
|
|
217
|
+
await api.post(`/api/chats/${state.chat.id}/stop`);
|
|
218
|
+
} catch (err) { addError(`couldn't stop: ${err.message}`); }
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const text = input.value.trim();
|
|
222
|
+
const hasAttachments = state.pendingAttachments.length > 0;
|
|
223
|
+
if (!text && !hasAttachments) return;
|
|
224
|
+
// A message must never go out without an attachment the user thinks is on
|
|
225
|
+
// it -- the model would act on the text alone.
|
|
226
|
+
const invalid = hasAttachments ? await invalidAttachments() : [];
|
|
227
|
+
if (invalid.length) {
|
|
228
|
+
addError(`remove ${invalid.map((a) => `"${a.file.name}"`).join(', ')} before sending`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
input.value = '';
|
|
232
|
+
input.style.height = 'auto';
|
|
233
|
+
dropFinishedTasks();
|
|
234
|
+
setBusy(true);
|
|
235
|
+
const ctrl = new AbortController();
|
|
236
|
+
state.viewCtrl = ctrl;
|
|
237
|
+
|
|
238
|
+
// Assigned synchronously (before any await) so the upload below and the
|
|
239
|
+
// /api/chat call after it land on the same not-yet-created chat.
|
|
240
|
+
if (!state.chat.id) state.chat.id = newId();
|
|
241
|
+
// This is the first point anything staged is actually uploaded, extracted
|
|
242
|
+
// and written to the store -- removing a chip before now never touched it.
|
|
243
|
+
const { docs, images, failed } = await commitAttachments(state.chat.id);
|
|
244
|
+
if (failed.length) {
|
|
245
|
+
input.value = text;
|
|
246
|
+
setBusy(false);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!text && !docs.length && !images.length) { setBusy(false); return; }
|
|
250
|
+
|
|
251
|
+
const placeholder = !text ? (images.length ? '(attached image)' : '(attached document)') : text;
|
|
252
|
+
addUser(placeholder, null, docs, images);
|
|
253
|
+
|
|
254
|
+
let outcome;
|
|
255
|
+
try {
|
|
256
|
+
// Only the new turn goes up. The server replays the rest from its own copy,
|
|
257
|
+
// so a page-sized tool result crosses the wire once rather than every turn.
|
|
258
|
+
const res = await fetch('/api/chat', {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: { 'content-type': 'application/json' },
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
chatId: state.chat.id,
|
|
263
|
+
message: placeholder,
|
|
264
|
+
documentIds: docs.map((a) => a.id),
|
|
265
|
+
images
|
|
266
|
+
}),
|
|
267
|
+
signal: ctrl.signal
|
|
268
|
+
});
|
|
269
|
+
outcome = await consume(res);
|
|
270
|
+
loadChats();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err.name !== 'AbortError') addError(err.message);
|
|
273
|
+
} finally {
|
|
274
|
+
// A later navigation already swapped in its own controller and reset
|
|
275
|
+
// `busy` for its own view -- stepping on either here would be wrong.
|
|
276
|
+
if (state.viewCtrl === ctrl) {
|
|
277
|
+
state.viewCtrl = null;
|
|
278
|
+
setBusy(false);
|
|
279
|
+
afterRun(outcome, state.chat.id);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's two menus: + (attach files, turn tools and MCP servers on
|
|
3
|
+
* and off) and the model chip (switch model). Both are popovers anchored to
|
|
4
|
+
* the composer card and rebuilt from the server every time they open, so they
|
|
5
|
+
* never show a state the server has already moved past.
|
|
6
|
+
*/
|
|
7
|
+
import { $, el, installLink } from '../core/dom.js';
|
|
8
|
+
import { can, whoami, loadAccess } from '../core/access.js';
|
|
9
|
+
import { addError } from './transcript.js';
|
|
10
|
+
import { pickFiles } from './attachments.js';
|
|
11
|
+
import { openLibrary } from './library.js';
|
|
12
|
+
import { FEATURE } from '../shared/features.js';
|
|
13
|
+
import { api } from '../core/api.js';
|
|
14
|
+
|
|
15
|
+
const form = $('form');
|
|
16
|
+
const attachBtn = $('attach');
|
|
17
|
+
const modelBtn = $('modelBtn');
|
|
18
|
+
|
|
19
|
+
let open = null; // { pop, btn }
|
|
20
|
+
|
|
21
|
+
// Handed in by initComposer: the deployment config lives in the settings
|
|
22
|
+
// panel, and the MCP panel is what "Manage MCP servers" opens.
|
|
23
|
+
let config;
|
|
24
|
+
let openMcp;
|
|
25
|
+
|
|
26
|
+
function closeMenu() {
|
|
27
|
+
if (!open) return;
|
|
28
|
+
open.pop.remove();
|
|
29
|
+
open.btn.setAttribute('aria-expanded', 'false');
|
|
30
|
+
open = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function openMenu(btn, side, build) {
|
|
34
|
+
const same = open?.btn === btn;
|
|
35
|
+
closeMenu();
|
|
36
|
+
if (same) return;
|
|
37
|
+
const pop = el('div', `popover popover-${side}`);
|
|
38
|
+
pop.setAttribute('role', 'menu');
|
|
39
|
+
form.appendChild(pop);
|
|
40
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
41
|
+
open = { pop, btn };
|
|
42
|
+
build(pop);
|
|
43
|
+
fitToViewport(pop);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Opens the popover on whichever side of the composer has more room and caps
|
|
48
|
+
* its height to that room, so it never hangs off the viewport (short phone
|
|
49
|
+
* screens, the centred empty-chat composer, an open mobile keyboard).
|
|
50
|
+
*/
|
|
51
|
+
function fitToViewport(pop) {
|
|
52
|
+
const gap = 8;
|
|
53
|
+
const rect = form.getBoundingClientRect();
|
|
54
|
+
const vh = window.visualViewport?.height ?? window.innerHeight;
|
|
55
|
+
const above = rect.top - gap * 2;
|
|
56
|
+
const below = vh - rect.bottom - gap * 2;
|
|
57
|
+
const up = above >= below;
|
|
58
|
+
pop.style.top = up ? 'auto' : 'calc(100% + .5rem)';
|
|
59
|
+
pop.style.bottom = up ? 'calc(100% + .5rem)' : 'auto';
|
|
60
|
+
pop.style.transformOrigin = `${up ? 'bottom' : 'top'} ${pop.classList.contains('popover-right') ? 'right' : 'left'}`;
|
|
61
|
+
pop.style.maxHeight = `${Math.max(120, Math.min(448, up ? above : below))}px`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `deps.config`: { load, loadTools, loadMcp, isLocked, isReadOnly, model } from
|
|
66
|
+
* the settings panel. `deps.openMcp()`: shows the MCP servers panel.
|
|
67
|
+
*/
|
|
68
|
+
export function initComposer(deps) {
|
|
69
|
+
({ config, openMcp } = deps);
|
|
70
|
+
window.visualViewport?.addEventListener('resize', () => { if (open) fitToViewport(open.pop); });
|
|
71
|
+
document.addEventListener('pointerdown', (e) => {
|
|
72
|
+
if (open && !open.pop.contains(e.target) && !open.btn.contains(e.target)) closeMenu();
|
|
73
|
+
});
|
|
74
|
+
document.addEventListener('keydown', (e) => {
|
|
75
|
+
if (e.key === 'Escape' && open) { const b = open.btn; closeMenu(); b.focus(); }
|
|
76
|
+
});
|
|
77
|
+
attachBtn.addEventListener('click', () => openMenu(attachBtn, 'left', buildToolsMenu));
|
|
78
|
+
modelBtn.addEventListener('click', () => openMenu(modelBtn, 'right', buildModelMenu));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An on/off switch; `onToggle(next)` returns a promise, and the switch holds still until it settles. */
|
|
82
|
+
function toggle(on, label, onToggle) {
|
|
83
|
+
const sw = el('button', 'switch');
|
|
84
|
+
sw.type = 'button';
|
|
85
|
+
sw.setAttribute('role', 'switch');
|
|
86
|
+
sw.setAttribute('aria-checked', String(on));
|
|
87
|
+
sw.setAttribute('aria-label', label);
|
|
88
|
+
// Tool and server switches are deployment config: admins only.
|
|
89
|
+
if (config.isReadOnly()) { sw.disabled = true; sw.title = 'Managed by your administrator'; }
|
|
90
|
+
sw.onclick = async (e) => {
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
sw.disabled = true;
|
|
93
|
+
try { await onToggle(!on); } catch (err) { addError(err.message); sw.disabled = false; }
|
|
94
|
+
};
|
|
95
|
+
return sw;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ---- + menu: attach and tools ---- */
|
|
99
|
+
|
|
100
|
+
async function buildToolsMenu(pop) {
|
|
101
|
+
if (can(FEATURE.ATTACHMENTS) || can(FEATURE.IMAGES)) {
|
|
102
|
+
const attach = el('button', 'pop-item');
|
|
103
|
+
attach.type = 'button';
|
|
104
|
+
attach.setAttribute('role', 'menuitem');
|
|
105
|
+
attach.innerHTML = '<svg class="ic" 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 11.5 12.5 20a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6L15.5 7"/></svg><span>Attach files</span>';
|
|
106
|
+
attach.onclick = () => { closeMenu(); pickFiles(); };
|
|
107
|
+
pop.appendChild(attach);
|
|
108
|
+
// Documents only: an image has no stored text to reuse.
|
|
109
|
+
if (can(FEATURE.ATTACHMENTS)) {
|
|
110
|
+
const earlier = el('button', 'pop-item');
|
|
111
|
+
earlier.type = 'button';
|
|
112
|
+
earlier.setAttribute('role', 'menuitem');
|
|
113
|
+
earlier.innerHTML = '<svg class="ic" 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 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg><span>Attach from earlier chats</span>';
|
|
114
|
+
earlier.onclick = () => { closeMenu(); openLibrary(); };
|
|
115
|
+
pop.appendChild(earlier);
|
|
116
|
+
}
|
|
117
|
+
if (!can(FEATURE.TOOLS)) return;
|
|
118
|
+
pop.appendChild(el('hr', 'pop-sep'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const body = el('div', 'pop-scroll');
|
|
122
|
+
body.appendChild(Object.assign(el('div', 'pop-note'), { textContent: 'Loading tools…' }));
|
|
123
|
+
pop.appendChild(body);
|
|
124
|
+
|
|
125
|
+
const manage = el('button', 'pop-item pop-foot');
|
|
126
|
+
manage.type = 'button';
|
|
127
|
+
manage.textContent = 'Manage MCP servers';
|
|
128
|
+
manage.onclick = () => { closeMenu(); openMcp(); };
|
|
129
|
+
if (can(FEATURE.MCP)) pop.appendChild(manage);
|
|
130
|
+
|
|
131
|
+
let data;
|
|
132
|
+
try { data = await api.get('/api/tools'); } catch (err) {
|
|
133
|
+
body.firstChild.textContent = `Could not load tools: ${err.message}`;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
renderTools(body, data);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Groups: built-ins, then one per MCP server. A group's tools expand under it. */
|
|
140
|
+
function renderTools(body, data) {
|
|
141
|
+
body.innerHTML = '';
|
|
142
|
+
const refresh = (out) => { renderTools(body, out); config.loadTools(); config.load(); };
|
|
143
|
+
const toggleTool = async (name, on) => refresh(await api.post('/api/tools/toggle', { name, disabled: !on }));
|
|
144
|
+
|
|
145
|
+
const group = ({ title, tools, status, error, install, serverName, serverOn }) => {
|
|
146
|
+
const box = el('details', 'pop-group');
|
|
147
|
+
const head = el('summary', 'pop-item');
|
|
148
|
+
const enabled = tools.filter((t) => !t.disabled).length;
|
|
149
|
+
const dot = el('span', `pop-dot ${status || 'ok'}`);
|
|
150
|
+
const name = Object.assign(el('span', 'pop-name'), { textContent: title });
|
|
151
|
+
const meta = Object.assign(el('span', 'pop-meta'), {
|
|
152
|
+
textContent: status === 'disabled' ? 'off'
|
|
153
|
+
: status === 'error' ? (install ? `needs ${install.name}` : 'not connected')
|
|
154
|
+
: `${enabled}/${tools.length}`
|
|
155
|
+
});
|
|
156
|
+
if (error) head.title = error;
|
|
157
|
+
head.append(dot, name, meta);
|
|
158
|
+
if (serverName) {
|
|
159
|
+
head.appendChild(toggle(serverOn, `${title} server`, async (next) => {
|
|
160
|
+
const out = await api.post(`/api/mcp/servers/${encodeURIComponent(serverName)}/toggle`, { disabled: !next });
|
|
161
|
+
refresh(out);
|
|
162
|
+
config.loadMcp();
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
box.appendChild(head);
|
|
166
|
+
const list = el('div', 'pop-tools');
|
|
167
|
+
for (const t of tools) {
|
|
168
|
+
const row = el('div', 'pop-tool');
|
|
169
|
+
const n = Object.assign(el('span', 'pop-tool-name'), { textContent: t.name, title: t.description || '' });
|
|
170
|
+
row.append(n, toggle(!t.disabled, t.name, (next) => toggleTool(t.name, next)));
|
|
171
|
+
list.appendChild(row);
|
|
172
|
+
}
|
|
173
|
+
if (status === 'error' && error) {
|
|
174
|
+
const note = Object.assign(el('div', 'pop-note'), { textContent: error });
|
|
175
|
+
if (install) note.append(' ', installLink(install));
|
|
176
|
+
list.appendChild(note);
|
|
177
|
+
} else if (!tools.length) {
|
|
178
|
+
list.appendChild(Object.assign(el('div', 'pop-note'), { textContent: 'No tools' }));
|
|
179
|
+
}
|
|
180
|
+
box.appendChild(list);
|
|
181
|
+
return box;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const total = [...data.internal, ...data.servers.flatMap((s) => s.tools)];
|
|
185
|
+
const on = total.filter((t) => !t.disabled).length;
|
|
186
|
+
body.appendChild(Object.assign(el('div', 'pop-label'), { textContent: `Tools · ${on} of ${total.length} on` }));
|
|
187
|
+
if (data.internal.length) body.appendChild(group({ title: 'Built-in', tools: data.internal }));
|
|
188
|
+
for (const s of data.servers) {
|
|
189
|
+
body.appendChild(group({
|
|
190
|
+
title: s.name, tools: s.tools, status: s.status, error: s.error, install: s.install,
|
|
191
|
+
serverName: s.name, serverOn: s.status !== 'disabled'
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
if (!data.servers.length) {
|
|
195
|
+
body.appendChild(Object.assign(el('div', 'pop-note'), { textContent: 'No MCP servers yet. Add them under MCP servers.' }));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ---- model menu ---- */
|
|
200
|
+
|
|
201
|
+
async function buildModelMenu(pop) {
|
|
202
|
+
// Tier 3: the pill is a personal choice among your role's models, saved
|
|
203
|
+
// per user; it never changes anyone else's model.
|
|
204
|
+
const personal = whoami().authMode === 'trusted-header';
|
|
205
|
+
const current = personal ? whoami().model : config.model();
|
|
206
|
+
if (!personal && config.isLocked('model')) {
|
|
207
|
+
pop.appendChild(Object.assign(el('div', 'pop-note'), {
|
|
208
|
+
textContent: config.isReadOnly() ? 'The model is managed by your administrator.' : 'The model is set in code and cannot be switched here.'
|
|
209
|
+
}));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const search = el('input', 'pop-search');
|
|
214
|
+
search.type = 'search';
|
|
215
|
+
search.placeholder = 'Search models or type an id';
|
|
216
|
+
search.setAttribute('aria-label', 'Search models');
|
|
217
|
+
pop.appendChild(search);
|
|
218
|
+
const list = el('div', 'pop-scroll');
|
|
219
|
+
list.appendChild(Object.assign(el('div', 'pop-note'), { textContent: 'Loading models…' }));
|
|
220
|
+
pop.appendChild(list);
|
|
221
|
+
search.focus();
|
|
222
|
+
|
|
223
|
+
const choose = async (id) => {
|
|
224
|
+
closeMenu();
|
|
225
|
+
if (!id || id === current) return;
|
|
226
|
+
try {
|
|
227
|
+
if (personal) { await api.post('/api/me/prefs', { model: id }); await loadAccess(); }
|
|
228
|
+
else await api.post('/api/config', { model: id });
|
|
229
|
+
} catch (err) { return addError(err.message); }
|
|
230
|
+
await config.load();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
let models = [];
|
|
234
|
+
let supported = true;
|
|
235
|
+
// A configured catalog is closed: labels only, no typing arbitrary ids.
|
|
236
|
+
let closed = false;
|
|
237
|
+
try {
|
|
238
|
+
const out = await api.get('/api/models');
|
|
239
|
+
models = out.models || [];
|
|
240
|
+
supported = out.supported;
|
|
241
|
+
closed = Boolean(out.catalog);
|
|
242
|
+
} catch { supported = false; }
|
|
243
|
+
if (closed) search.placeholder = 'Search models';
|
|
244
|
+
|
|
245
|
+
const render = () => {
|
|
246
|
+
const q = search.value.trim().toLowerCase();
|
|
247
|
+
list.innerHTML = '';
|
|
248
|
+
const hits = models.filter((m) => !q || (!closed && m.id.toLowerCase().includes(q))
|
|
249
|
+
|| (m.name || '').toLowerCase().includes(q) || (m.description || '').toLowerCase().includes(q));
|
|
250
|
+
for (const m of hits.slice(0, 200)) {
|
|
251
|
+
const b = el('button', 'pop-item pop-model');
|
|
252
|
+
b.type = 'button';
|
|
253
|
+
b.dataset.id = m.id;
|
|
254
|
+
b.setAttribute('role', 'menuitemradio');
|
|
255
|
+
b.setAttribute('aria-checked', String(m.id === current));
|
|
256
|
+
b.appendChild(Object.assign(el('span', 'pop-name'), { textContent: closed ? m.name : m.id }));
|
|
257
|
+
const meta = closed ? m.description : m.name;
|
|
258
|
+
if (meta) b.appendChild(Object.assign(el('span', 'pop-meta'), { textContent: meta }));
|
|
259
|
+
b.onclick = () => choose(m.id);
|
|
260
|
+
list.appendChild(b);
|
|
261
|
+
}
|
|
262
|
+
// Typing an exact id always works, listed or not -- local runtimes and
|
|
263
|
+
// private deployments often serve models /models never mentions.
|
|
264
|
+
const typed = search.value.trim();
|
|
265
|
+
if (typed && !closed && !models.some((m) => m.id === typed)) {
|
|
266
|
+
const b = el('button', 'pop-item');
|
|
267
|
+
b.type = 'button';
|
|
268
|
+
b.textContent = `Use "${typed}"`;
|
|
269
|
+
b.onclick = () => choose(typed);
|
|
270
|
+
list.appendChild(b);
|
|
271
|
+
}
|
|
272
|
+
if (!list.children.length) {
|
|
273
|
+
list.appendChild(Object.assign(el('div', 'pop-note'), {
|
|
274
|
+
textContent: supported ? 'No models match.' : 'This provider does not list its models. Type a model id and press Enter.'
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
search.oninput = render;
|
|
279
|
+
search.onkeydown = (e) => {
|
|
280
|
+
if (e.key !== 'Enter') return;
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
// Exact id first, then the top match, then whatever was typed.
|
|
283
|
+
const q = search.value.trim();
|
|
284
|
+
const first = list.querySelector('.pop-model')?.dataset.id;
|
|
285
|
+
if (closed) return first && choose(first);
|
|
286
|
+
choose(models.some((m) => m.id === q) ? q : first || q);
|
|
287
|
+
};
|
|
288
|
+
render();
|
|
289
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Attach from earlier chats": a picker over every file the user has already
|
|
3
|
+
* uploaded, so a document used once can be used again without finding the
|
|
4
|
+
* original on disk. Picking only stages it; the copy into this chat is made
|
|
5
|
+
* when the message is sent (see attachments.js).
|
|
6
|
+
*/
|
|
7
|
+
import { $, el } from '../core/dom.js';
|
|
8
|
+
import { api } from '../core/api.js';
|
|
9
|
+
import { stageExisting } from './attachments.js';
|
|
10
|
+
import { addError } from './transcript.js';
|
|
11
|
+
|
|
12
|
+
const dialog = $('libraryDialog');
|
|
13
|
+
const list = $('libraryList');
|
|
14
|
+
const filter = $('libraryFilter');
|
|
15
|
+
const confirm = $('libraryAttach');
|
|
16
|
+
|
|
17
|
+
let docs = [];
|
|
18
|
+
const picked = new Set();
|
|
19
|
+
|
|
20
|
+
const extOf = (name) => (name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toUpperCase() : 'FILE');
|
|
21
|
+
const when =(ms) => new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
22
|
+
|
|
23
|
+
function render() {
|
|
24
|
+
const q = filter.value.trim().toLowerCase();
|
|
25
|
+
list.innerHTML = '';
|
|
26
|
+
const shown = docs.filter((d) => !q || d.filename.toLowerCase().includes(q));
|
|
27
|
+
if (!shown.length) {
|
|
28
|
+
list.appendChild(Object.assign(el('div', 'empty'), { textContent: docs.length ? 'No file matches.' : 'Nothing uploaded in earlier chats yet.' }));
|
|
29
|
+
}
|
|
30
|
+
for (const d of shown) {
|
|
31
|
+
const row = el('label', 'library-row');
|
|
32
|
+
const box = el('input');
|
|
33
|
+
box.type = 'checkbox';
|
|
34
|
+
box.checked = picked.has(d.id);
|
|
35
|
+
box.onchange = () => { box.checked ? picked.add(d.id) : picked.delete(d.id); sync(); };
|
|
36
|
+
const name = el('span', 'library-name');
|
|
37
|
+
name.textContent = d.filename;
|
|
38
|
+
const type = el('span', 'library-meta');
|
|
39
|
+
type.textContent = extOf(d.filename);
|
|
40
|
+
const date = el('span', 'library-meta');
|
|
41
|
+
date.textContent = when(d.created_at);
|
|
42
|
+
row.append(box, name, type, date);
|
|
43
|
+
list.appendChild(row);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sync() {
|
|
48
|
+
confirm.disabled = picked.size === 0;
|
|
49
|
+
confirm.textContent = picked.size ? `attach ${picked.size}` : 'attach';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function openLibrary() {
|
|
53
|
+
picked.clear();
|
|
54
|
+
filter.value = '';
|
|
55
|
+
sync();
|
|
56
|
+
list.innerHTML = '';
|
|
57
|
+
list.appendChild(Object.assign(el('div', 'empty'), { textContent: 'Loading…' }));
|
|
58
|
+
dialog.showModal();
|
|
59
|
+
try {
|
|
60
|
+
docs = (await api.get('/api/documents')).documents;
|
|
61
|
+
render();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
dialog.close();
|
|
64
|
+
addError(`could not list earlier files: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function initLibrary() {
|
|
69
|
+
filter.addEventListener('input', render);
|
|
70
|
+
$('libraryCancel').addEventListener('click', () => dialog.close());
|
|
71
|
+
$('libraryForm').addEventListener('submit', (e) => {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
for (const d of docs) if (picked.has(d.id)) stageExisting(d);
|
|
74
|
+
dialog.close();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation minimap: one flat tick per question, vertically centered
|
|
3
|
+
* on the right edge -- ChatGPT-nav style. At rest it's just marks; hovering
|
|
4
|
+
* (or focusing) a tick reveals that question's text and clicking it jumps
|
|
5
|
+
* the transcript there.
|
|
6
|
+
*/
|
|
7
|
+
import { $, el } from '../core/dom.js';
|
|
8
|
+
|
|
9
|
+
let items = []; // { target: Element, text: string }
|
|
10
|
+
|
|
11
|
+
export function resetOutline() {
|
|
12
|
+
items = [];
|
|
13
|
+
render();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Called once per user turn as it's added to the transcript. */
|
|
17
|
+
export function addQuestion(target, text) {
|
|
18
|
+
items.push({ target, text: text || '(attached document)' });
|
|
19
|
+
render();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function render() {
|
|
23
|
+
const box = $('chatOutline');
|
|
24
|
+
box.innerHTML = '';
|
|
25
|
+
box.hidden = items.length === 0;
|
|
26
|
+
if (!items.length) return;
|
|
27
|
+
|
|
28
|
+
for (const it of items) {
|
|
29
|
+
const btn = el('button', 'outline-tick');
|
|
30
|
+
btn.type = 'button';
|
|
31
|
+
btn.setAttribute('aria-label', it.text);
|
|
32
|
+
const bar = el('span', 'bar');
|
|
33
|
+
const tip = el('span', 'tip');
|
|
34
|
+
tip.textContent = it.text.replace(/\s+/g, ' ').trim();
|
|
35
|
+
btn.append(tip, bar);
|
|
36
|
+
btn.onclick = () => it.target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
37
|
+
box.appendChild(btn);
|
|
38
|
+
}
|
|
39
|
+
}
|