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
package/bin/tinywebui.js
ADDED
|
@@ -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
|
+
}
|