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.
Files changed (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve, dirname } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ const args = process.argv.slice(2);
7
+ const flag = (name, fallback) => {
8
+ const i = args.indexOf(`--${name}`);
9
+ return i === -1 ? fallback : args[i + 1];
10
+ };
11
+
12
+ // `--config <path>` (or `validate <path>`) points every command at a config
13
+ // file, JS or JSON, exactly like $TINYWEBUI_CONFIG does.
14
+ const configArg = flag('config') ?? (args[0] === 'validate' && args[1] && !args[1].startsWith('--') ? args[1] : undefined);
15
+ if (configArg) process.env.TINYWEBUI_CONFIG = resolve(configArg);
16
+
17
+ if (args.includes('--help') || args.includes('-h')) {
18
+ console.log(`tinywebui [start] [--port 7777] [--host 127.0.0.1] [--config path]
19
+ tinywebui set-password turn on password login ("single" auth mode)
20
+ tinywebui validate [path] check the config files; prints the fingerprint, exits 1 on problems
21
+ tinywebui migrate [--check]
22
+ bring the database to the current schema (--check: exit 1 if behind)
23
+ tinywebui doctor check config, database, model endpoint and MCP servers
24
+ tinywebui config show same as effective
25
+ tinywebui models pull <fast|balanced|quality|multilingual> [--dir path]
26
+ fetch an embedding bundle for hybrid retrieval
27
+ tinywebui models ensure pull the configured model only if missing (npm start runs this)
28
+ tinywebui models verify load the configured embedding model the way startup does
29
+ tinywebui effective [--role user|admin]
30
+ what is in effect and how each setting can change
31
+ tinywebui fingerprint the config's fingerprint (secrets never included)
32
+ tinywebui users list users and the decisions pinned in the files
33
+ tinywebui users set <id> [--role admin|user] [--status pending|approved|disabled] [--clear]
34
+ record a decision in config.json (a running server reloads it)
35
+ tinywebui schema JSON Schema for the config file
36
+
37
+ Config comes from ./tinywebui.config.js if it exists, then
38
+ ./tinywebui.config.json ($TINYWEBUI_CONFIG may point at either), and MCP
39
+ servers from mcp.json next to it (or $TINYWEBUI_MCP).
40
+
41
+ tinywebui.config.js exports the options start() takes, as an object or an
42
+ (async) function returning one:
43
+
44
+ export default {
45
+ config: { model: 'anthropic/claude-sonnet-5', apiKey: process.env.KEY },
46
+ mcpServers: { files: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] } },
47
+ };
48
+
49
+ Keys under "config" win over the JSON file and are locked in the settings
50
+ panel. See README for the rest.
51
+ `);
52
+ process.exit(0);
53
+ }
54
+
55
+ // Resolve the JS config before src/config/config.js is imported: that module fixes
56
+ // its JSON path at load time from $TINYWEBUI_CONFIG, so a .js path there has
57
+ // to be swapped for the JSON file beside it first.
58
+ const envPath = process.env.TINYWEBUI_CONFIG && resolve(process.env.TINYWEBUI_CONFIG);
59
+ let jsPath = null;
60
+ if (envPath && /\.[cm]?js$/.test(envPath)) {
61
+ jsPath = envPath;
62
+ process.env.TINYWEBUI_CONFIG = resolve(dirname(envPath), 'tinywebui.config.json');
63
+ } else if (!envPath) {
64
+ for (const name of ['tinywebui.config.js', 'tinywebui.config.mjs']) {
65
+ const p = resolve(process.cwd(), name);
66
+ if (existsSync(p)) { jsPath = p; break; }
67
+ }
68
+ }
69
+
70
+ if (args[0] === 'set-password') {
71
+ const { setPassword } = await import('../src/set-password.js');
72
+ try { await setPassword(); process.exit(0); }
73
+ catch (err) { console.error(`[tinywebui] ${err.message}`); process.exit(1); }
74
+ }
75
+
76
+ try {
77
+ let opts = {};
78
+ if (jsPath && !existsSync(jsPath)) throw new Error(`config file not found: ${jsPath}`);
79
+ if (jsPath) {
80
+ const mod = await import(pathToFileURL(jsPath).href);
81
+ const exported = mod.default ?? mod;
82
+ opts = (typeof exported === 'function' ? await exported() : exported) || {};
83
+ // stderr, so a command's stdout stays pure JSON for scripts.
84
+ console.error(`[tinywebui] options: ${jsPath}`);
85
+ }
86
+ const COMMANDS = ['validate', 'effective', 'fingerprint', 'users', 'schema', 'migrate', 'doctor', 'config', 'models'];
87
+ if (COMMANDS.includes(args[0])) {
88
+ const { runCli } = await import('../src/cli.js');
89
+ process.exit(await runCli(args[0], args.slice(1), opts));
90
+ }
91
+ if (args[0] && !args[0].startsWith('--') && args[0] !== 'start') {
92
+ console.error(`[tinywebui] unknown command "${args[0]}" (see --help)`);
93
+ process.exit(1);
94
+ }
95
+ const { start } = await import('../src/server.js');
96
+ // Command-line flags beat the file; the file beats the built-in defaults.
97
+ await start({
98
+ ...opts,
99
+ port: Number(flag('port', opts.port ?? process.env.PORT ?? 7777)),
100
+ host: flag('host', opts.host ?? '127.0.0.1'),
101
+ });
102
+ } catch (err) {
103
+ console.error(`[tinywebui] ${err.message}`);
104
+ process.exit(1);
105
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "tinywebui",
3
+ "version": "0.1.0",
4
+ "description": "Ultralight chat UI. Bring your own OpenAI-compatible endpoint (OpenRouter, OpenAI, Ollama, ...), your own system prompt, and your own MCP servers. That's it.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/TinySuiteHQ/TinyWebUI.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "bin": {
14
+ "tinywebui": "bin/tinywebui.js"
15
+ },
16
+ "main": "src/server.js",
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "public",
21
+ "README.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22.13"
25
+ },
26
+ "scripts": {
27
+ "prestart": "node bin/tinywebui.js models ensure",
28
+ "start": "node bin/tinywebui.js",
29
+ "predev": "node bin/tinywebui.js models ensure",
30
+ "dev": "node --watch-path=./src --watch-path=./bin bin/tinywebui.js",
31
+ "stop": "pkill -f 'bin/tinywebui.js' || true",
32
+ "test": "node --test --test-timeout=30000",
33
+ "eval": "node evals/run.js"
34
+ },
35
+ "dependencies": {
36
+ "@jsquash/avif": "^2.1.1",
37
+ "@modelcontextprotocol/sdk": "^1.12.0",
38
+ "cron-parser": "^5.10.1",
39
+ "heic-convert": "^2.1.0",
40
+ "jpeg-js": "^0.4.4",
41
+ "mammoth": "^1.12.3",
42
+ "unpdf": "^1.8.1"
43
+ },
44
+ "license": "AGPL-3.0-or-later",
45
+ "optionalDependencies": {
46
+ "@huggingface/tokenizers": "^0.2.0",
47
+ "onnxruntime-node": "^1.30.0"
48
+ }
49
+ }
package/public/app.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Entry point and the page's only wiring. Modules do nothing when imported;
3
+ * each exports an init that attaches its listeners, and anything it needs
4
+ * from another area is handed in here -- so every cross-module call can be
5
+ * traced from this file.
6
+ */
7
+ import { $ } from './core/dom.js';
8
+ import { api } from './core/api.js';
9
+ import { loadAccess, can } from './core/access.js';
10
+ import { FEATURE } from './shared/features.js';
11
+ import { initChat, newChat, openChat, sendHeld } from './chat/chat.js';
12
+ import { initTranscript } from './chat/transcript.js';
13
+ import { initQueue } from './chat/queue.js';
14
+ import { initSidebar, loadChats } from './chat/sidebar.js';
15
+ import { initAttachments } from './chat/attachments.js';
16
+ import { initLibrary } from './chat/library.js';
17
+ import { initComposer } from './chat/composer.js';
18
+ import { PANEL, initPanels, openPanel } from './panels/panels.js';
19
+ import {
20
+ initSettings, onSettingsOpen, onSettingsClose,
21
+ loadConfig, loadMcp, loadTools, isLocked, isReadOnly, configuredModel
22
+ } from './panels/settings.js';
23
+ import { initAutomations, loadAutomations } from './panels/automations.js';
24
+ import { loadUsage } from './panels/usage.js';
25
+ import { initAdmin, loadAdmin } from './panels/admin.js';
26
+
27
+ /** 'single' mode with no session: show the password screen and stop there. */
28
+ async function needsLogin() {
29
+ const me = await loadAccess();
30
+ if (me.authMode !== 'single' || me.user) return false;
31
+ const box = $('login');
32
+ box.hidden = false;
33
+ $('loginPassword').focus();
34
+ $('loginForm').onsubmit = async (e) => {
35
+ e.preventDefault();
36
+ $('loginMsg').textContent = '';
37
+ try {
38
+ await api.post('/api/auth/login', { password: $('loginPassword').value });
39
+ return location.reload();
40
+ } catch (err) {
41
+ $('loginMsg').textContent = err.message;
42
+ $('loginPassword').select();
43
+ }
44
+ };
45
+ return true;
46
+ }
47
+
48
+ function wire() {
49
+ initTranscript({ openChat });
50
+ initQueue({ sendHeld });
51
+ initSidebar({ openChat, newChat });
52
+ initAttachments();
53
+ initLibrary();
54
+ initChat();
55
+ initComposer({
56
+ config: { load: loadConfig, loadTools, loadMcp, isLocked, isReadOnly, model: configuredModel },
57
+ openMcp: () => openPanel(PANEL.MCP)
58
+ });
59
+ initSettings();
60
+ initAutomations();
61
+ initPanels({
62
+ [PANEL.SETTINGS]: { onOpen: onSettingsOpen, onClose: onSettingsClose, closeId: 'cancel' },
63
+ // The raw editor and the tool list are reloaded on open, so they reflect
64
+ // any toggles made from the composer menu.
65
+ [PANEL.MCP]: { onOpen: () => Promise.all([loadMcp(), loadTools()]) },
66
+ [PANEL.AUTOMATIONS]: { onOpen: loadAutomations },
67
+ [PANEL.STATISTICS]: { onOpen: loadUsage },
68
+ [PANEL.ADMIN]: { onOpen: loadAdmin }
69
+ });
70
+ }
71
+
72
+ if (!(await needsLogin())) {
73
+ wire();
74
+ newChat();
75
+ // Tools render read-only for non-admins, so they wait on the config.
76
+ loadConfig().then(() => can(FEATURE.TOOLS) && loadTools());
77
+ if (can(FEATURE.MCP)) loadMcp();
78
+ initAdmin();
79
+ loadChats();
80
+ $('input').focus();
81
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Document attachments: files picked, dropped, or pasted are only staged
3
+ * client-side -- nothing is uploaded, extracted, or written to the store
4
+ * until the message is actually sent. Before that, removing one just drops
5
+ * it from the local queue; there is nothing server-side to clean up.
6
+ */
7
+ import { $, el } from '../core/dom.js';
8
+ import { can } from '../core/access.js';
9
+ import { state } from '../core/state.js';
10
+ import { addError } from './transcript.js';
11
+ import { api } from '../core/api.js';
12
+
13
+ const input = $('input');
14
+ const DOC_PREVIEW_COUNT = 3;
15
+ let docsExpanded = false;
16
+
17
+ /** Opens the document as uploaded (a PDF in the viewer, a .docx downloads); older ones open as extracted text. */
18
+ function openDocument(id) {
19
+ window.open(`/api/documents/${id}/original`, '_blank', 'noopener');
20
+ }
21
+
22
+ const DOC_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3">'
23
+ + '<path d="M4 1.5h5.5L12.5 4.5V14.5H4z"/><path d="M9.5 1.5V4.5H12.5"/>'
24
+ + '<path d="M5.75 8h4.5M5.75 10.25h4.5M5.75 12h3"/></svg>';
25
+ const IMAGE_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3">'
26
+ + '<rect x="1.5" y="2.5" width="13" height="11" rx="1"/>'
27
+ + '<circle cx="5.5" cy="6.5" r="1.25"/><path d="M2 12l3.5-4 3 3.5 2-2.5L14 12"/></svg>';
28
+
29
+ /** Opens an image attachment full-size in a new tab, from its stored bytes. */
30
+ function openImage(img) {
31
+ window.open(`data:${img.mime};base64,${img.data}`, '_blank');
32
+ }
33
+
34
+ /** Every document and image ever attached to this chat, oldest first. */
35
+ function artifacts() {
36
+ return [...state.chatDocuments.map((d) => ({ kind: 'doc', ...d })),
37
+ ...state.chatImages.map((i) => ({ kind: 'image', ...i }))];
38
+ }
39
+
40
+ export function renderChatDocs() {
41
+ const box = $('chatDocs');
42
+ box.innerHTML = '';
43
+ const items = artifacts();
44
+ box.hidden = items.length === 0;
45
+ if (!items.length) return;
46
+
47
+ const label = el('span', 'label');
48
+ label.textContent = 'artifacts';
49
+ box.appendChild(label);
50
+
51
+ const shown = docsExpanded ? items : items.slice(0, DOC_PREVIEW_COUNT);
52
+ for (const a of shown) {
53
+ const item = el('button', 'rail-item');
54
+ item.type = 'button';
55
+ const icon = el('span', 'icon');
56
+ if (a.kind === 'doc') {
57
+ item.title = `${a.filename} · ${a.char_len.toLocaleString('en-US')} chars`;
58
+ icon.innerHTML = DOC_ICON;
59
+ item.onclick = () => openDocument(a.id);
60
+ } else {
61
+ item.title = a.filename || 'attached image';
62
+ icon.innerHTML = IMAGE_ICON;
63
+ item.onclick = () => openImage(a);
64
+ }
65
+ const name = el('span', 'text');
66
+ name.textContent = a.filename || 'image';
67
+ item.append(icon, name);
68
+ box.appendChild(item);
69
+ }
70
+
71
+ const hidden = items.length - shown.length;
72
+ if (hidden > 0 || docsExpanded && items.length > DOC_PREVIEW_COUNT) {
73
+ const more = el('button', 'rail-more');
74
+ more.type = 'button';
75
+ more.textContent = docsExpanded ? 'show less' : `+${hidden} more`;
76
+ more.onclick = () => { docsExpanded = !docsExpanded; renderChatDocs(); };
77
+ box.appendChild(more);
78
+ }
79
+ }
80
+
81
+ /** Called when switching chats, so a new chat doesn't inherit the last one's state. */
82
+ export function resetChatDocsView() {
83
+ docsExpanded = false;
84
+ carried = { docs: [], images: [] };
85
+ }
86
+
87
+ export function renderAttachments() {
88
+ const box = $('attachments');
89
+ box.innerHTML = '';
90
+ box.hidden = state.pendingAttachments.length === 0;
91
+ for (const a of state.pendingAttachments) {
92
+ const name = a.existing?.filename ?? a.file.name;
93
+ const chip = el('span', a.isImage ? 'attachment-chip attachment-chip-image' : 'attachment-chip');
94
+ if (a.isImage) {
95
+ const thumb = el('img', 'attachment-thumb');
96
+ thumb.alt = name;
97
+ thumb.title = a.error ? `${name}: ${a.error}` : name;
98
+ if (a.normalized) thumb.src = `data:${a.normalized.mime};base64,${a.normalized.data}`;
99
+ else thumb.classList.add(a.error ? 'broken' : 'pending');
100
+ chip.appendChild(thumb);
101
+ } else {
102
+ chip.appendChild(document.createTextNode(name));
103
+ if (a.error) {
104
+ chip.classList.add('attachment-chip-error');
105
+ chip.title = `${name}: ${a.error}`;
106
+ }
107
+ }
108
+ const remove = el('span', 'remove');
109
+ remove.textContent = '✕';
110
+ remove.title = 'remove attachment';
111
+ remove.onclick = () => {
112
+ state.pendingAttachments = state.pendingAttachments.filter((x) => x !== a);
113
+ renderAttachments();
114
+ };
115
+ chip.appendChild(remove);
116
+ box.appendChild(chip);
117
+ }
118
+ }
119
+
120
+ function toBase64(file) {
121
+ return new Promise((resolve, reject) => {
122
+ const reader = new FileReader();
123
+ reader.onload = () => resolve(String(reader.result).split(',')[1] || '');
124
+ reader.onerror = () => reject(reader.error);
125
+ reader.readAsDataURL(file);
126
+ });
127
+ }
128
+
129
+ // Some browsers report an empty type for HEIC/AVIF, so a name-based fallback
130
+ // is what keeps those on the image path instead of the text-extraction one.
131
+ const IMAGE_EXT = /\.(heic|heif|avif|jpe?g|png|webp|gif)$/i;
132
+ function isImageFile(file) {
133
+ return file.type.startsWith('image/') || (!file.type && IMAGE_EXT.test(file.name));
134
+ }
135
+
136
+ /**
137
+ * Normalizes a staged image the moment it's picked, not when the message is
138
+ * sent -- that's what lets the composer preview it and the optimistic
139
+ * transcript thumbnail show real, decodable bytes instead of a HEIC/AVIF the
140
+ * browser can't render, which used to show broken until the turn finished
141
+ * and the chat reloaded with what the server had already converted.
142
+ */
143
+ async function normalizeStaged(entry) {
144
+ try {
145
+ const dataBase64 = await toBase64(entry.file);
146
+ entry.normalized = await api.post('/api/images/normalize', { mime: entry.file.type, dataBase64 });
147
+ } catch (err) {
148
+ entry.error = err.message;
149
+ }
150
+ renderAttachments();
151
+ }
152
+
153
+ // Mirrors what src/files/documents.js can extract. The server stays authoritative;
154
+ // this only exists so an obviously unusable file is flagged on its chip and
155
+ // blocks sending, instead of being discovered after the message already went.
156
+ const DOC_EXT = new Set([
157
+ '.pdf', '.docx',
158
+ '.txt', '.md', '.markdown', '.csv', '.tsv', '.json', '.log',
159
+ '.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h',
160
+ '.css', '.html', '.xml', '.yaml', '.yml', '.sh', '.sql'
161
+ ]);
162
+ function unsupportedReason(file) {
163
+ const i = file.name.lastIndexOf('.');
164
+ const ext = i === -1 ? '' : file.name.slice(i).toLowerCase();
165
+ if (ext === '' || DOC_EXT.has(ext)) return null;
166
+ if (ext === '.doc') return 'legacy .doc is not supported -- save it as .docx or .pdf';
167
+ return `unsupported file type "${ext}"`;
168
+ }
169
+
170
+ /**
171
+ * Queues a document uploaded in an earlier chat. Like a file it costs nothing
172
+ * until the message is sent; then the server copies it into this chat.
173
+ */
174
+ export function stageExisting(doc) {
175
+ if (state.pendingAttachments.some((e) => e.existing?.id === doc.id)) return;
176
+ state.pendingAttachments.push({ existing: doc });
177
+ renderAttachments();
178
+ }
179
+
180
+ /** Queues a file (or a pasted-text stand-in) client-side. No network yet. */
181
+ function stageAttachment(file) {
182
+ // Dropped and pasted files arrive here too, not only through the + menu.
183
+ if (!can(isImageFile(file) ? 'images' : 'attachments')) {
184
+ addError(`attach "${file.name}": ${isImageFile(file) ? 'images are' : 'attachments are'} turned off for your account`);
185
+ return;
186
+ }
187
+ const entry = { file };
188
+ if (isImageFile(file)) {
189
+ entry.isImage = true;
190
+ entry.ready = normalizeStaged(entry);
191
+ } else {
192
+ entry.error = unsupportedReason(file);
193
+ if (entry.error) addError(`attach "${file.name}": ${entry.error}`);
194
+ }
195
+ state.pendingAttachments.push(entry);
196
+ renderAttachments();
197
+ }
198
+
199
+ /**
200
+ * Staged entries that can't be sent (unsupported type, image that failed to
201
+ * normalize). Waits for in-flight image normalization so the answer is final.
202
+ */
203
+ export async function invalidAttachments() {
204
+ await Promise.all(state.pendingAttachments.map((e) => e.ready));
205
+ return state.pendingAttachments.filter((e) => e.error);
206
+ }
207
+
208
+ /**
209
+ * Uploads every staged file now that the message is actually being sent --
210
+ * this is the first point any of it is extracted or written to the store.
211
+ * If any upload fails, nothing is sent: the caller aborts and the failed
212
+ * files are put back on the composer.
213
+ *
214
+ * Images take a different path than every other file here: there is no text
215
+ * to extract, so there is nothing for read_document to look up later. They
216
+ * ride straight into the message as base64, the same call that sends the
217
+ * text, instead of going through the documents endpoint first.
218
+ */
219
+ // Uploads that succeeded during a send that was then aborted because a sibling
220
+ // failed. They're already in the store, so the retry reuses them rather than
221
+ // uploading the same file twice.
222
+ let carried = { docs: [], images: [] };
223
+
224
+ export async function commitAttachments(chatId) {
225
+ const staged = state.pendingAttachments;
226
+ state.pendingAttachments = [];
227
+ renderAttachments();
228
+ const docs = [...carried.docs];
229
+ const images = [...carried.images];
230
+ carried = { docs: [], images: [] };
231
+ const failed = [];
232
+ for (const entry of staged) {
233
+ const { file } = entry;
234
+ const name = entry.existing?.filename ?? file.name;
235
+ try {
236
+ if (entry.existing) {
237
+ const data = await api.post(`/api/chats/${chatId}/documents/attach`, { documentId: entry.existing.id });
238
+ docs.push(data.document);
239
+ continue;
240
+ }
241
+ if (entry.isImage) {
242
+ await entry.ready;
243
+ if (!entry.normalized) throw new Error(entry.error || 'could not process that image');
244
+ images.push({ filename: file.name, mime: entry.normalized.mime, dataBase64: entry.normalized.data });
245
+ continue;
246
+ }
247
+ const dataBase64 = await toBase64(file);
248
+ // The chat may not exist yet -- the upload route creates it lazily, the
249
+ // same way the first /api/chat call does.
250
+ const data = await api.post(`/api/chats/${chatId}/documents`, { filename: file.name, mime: file.type, dataBase64 });
251
+ docs.push(data.document);
252
+ } catch (err) {
253
+ entry.error = err.message;
254
+ failed.push(entry);
255
+ addError(`attach "${name}": ${err.message}`);
256
+ }
257
+ }
258
+ if (failed.length) {
259
+ // Nothing is sent if any attachment failed: the failed ones go back on
260
+ // the composer (flagged) so the user can remove or replace them.
261
+ carried = { docs, images };
262
+ state.pendingAttachments = failed;
263
+ renderAttachments();
264
+ return { docs: [], images: [], failed };
265
+ }
266
+ if (docs.length || images.length) {
267
+ // A file the chat already had comes back as its existing document.
268
+ state.chatDocuments.push(...docs.filter((d) => !state.chatDocuments.some((x) => x.id === d.id)));
269
+ // The staged shape carries dataBase64; the artifacts rail reads the same
270
+ // {mime, data} shape everything replayed from the store uses.
271
+ state.chatImages.push(...images.map((i) => ({ filename: i.filename, mime: i.mime, data: i.dataBase64 })));
272
+ renderChatDocs();
273
+ }
274
+ return { docs, images, failed };
275
+ }
276
+
277
+ /** Opens the browser's file picker; what is picked lands in the tray. */
278
+ export const pickFiles = () => $('fileInput').click();
279
+
280
+ // A long paste reads like ChatGPT's: it becomes an attachment instead of
281
+ // filling the box with a wall of text the user would have to scroll past.
282
+ const PASTE_AS_FILE_THRESHOLD = 2000;
283
+
284
+ export function initAttachments() {
285
+ $('fileInput').addEventListener('change', () => {
286
+ for (const f of $('fileInput').files) stageAttachment(f);
287
+ $('fileInput').value = '';
288
+ });
289
+
290
+ for (const ev of ['dragover', 'dragenter']) {
291
+ $('form').addEventListener(ev, (e) => e.preventDefault());
292
+ }
293
+ $('form').addEventListener('drop', (e) => {
294
+ e.preventDefault();
295
+ for (const f of e.dataTransfer.files) stageAttachment(f);
296
+ });
297
+
298
+ input.addEventListener('paste', (e) => {
299
+ const text = e.clipboardData?.getData('text/plain') || '';
300
+ if (text.length < PASTE_AS_FILE_THRESHOLD) return;
301
+ e.preventDefault();
302
+ stageAttachment(new File([text], 'pasted.txt', { type: 'text/plain' }));
303
+ });
304
+ }