tinywebui 0.1.4 → 0.1.5

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 (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -1,63 +1,66 @@
1
- /** A small, chat-scoped checklist that the model can maintain during a task. */
2
1
  export const MANAGE_TASKS = 'manage_tasks';
3
-
4
2
  export function taskToolDef() {
5
- return {
6
- type: 'function',
7
- function: {
8
- name: MANAGE_TASKS,
9
- description: 'Keep a visible checklist for this chat. Create tasks when a request has multiple steps. Mark a task in_progress when you start it and completed as soon as it is done; before your final answer, make sure no finished task is still open. Update several tasks at once with ids. This is a checklist, not a scheduled automation.',
10
- parameters: {
11
- type: 'object',
12
- properties: {
13
- action: { type: 'string', enum: ['add', 'update', 'list'] },
14
- title: { type: 'string', description: 'Short task title for add.' },
15
- id: { type: 'string', description: 'Task ID returned by add or list, for update.' },
16
- ids: { type: 'array', items: { type: 'string' }, description: 'Several task IDs to set to the same status, for update.' },
17
- status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: 'New status for update.' }
18
- },
19
- required: ['action']
20
- }
21
- }
22
- };
3
+ return {
4
+ type: 'function',
5
+ function: {
6
+ name: MANAGE_TASKS,
7
+ description: 'Keep a visible checklist for this chat. Create tasks when a request has multiple steps. Mark a task in_progress when you start it and completed as soon as it is done; before your final answer, make sure no finished task is still open. Update several tasks at once with ids. This is a checklist, not a scheduled automation.',
8
+ parameters: {
9
+ type: 'object',
10
+ properties: {
11
+ action: { type: 'string', enum: ['add', 'update', 'list'] },
12
+ title: { type: 'string', description: 'Short task title for add.' },
13
+ id: { type: 'string', description: 'Task ID returned by add or list, for update.' },
14
+ ids: { type: 'array', items: { type: 'string' }, description: 'Several task IDs to set to the same status, for update.' },
15
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: 'New status for update.' }
16
+ },
17
+ required: ['action']
18
+ }
19
+ }
20
+ };
23
21
  }
24
-
25
22
  export function callManageTasks(args, { store, chatId, onChange } = {}) {
26
- if (!store || !chatId) return 'Error: this tool requires a chat.';
27
- const { action } = args || {};
28
- if (action === 'list') return JSON.stringify({ tasks: store.chats.listTasks(chatId) });
29
- if (action === 'add') {
30
- const title = typeof args.title === 'string' ? args.title.trim() : '';
31
- if (!title || title.length > 200) return 'Error: title must be 1–200 characters.';
32
- if (store.chats.listTasks(chatId).length >= 50) return 'Error: this chat has reached the 50-task limit.';
33
- const task = store.chats.addTask(chatId, title);
34
- onChange?.(store.chats.listTasks(chatId));
35
- return JSON.stringify({ task });
36
- }
37
- if (action === 'update') {
38
- const ids = Array.isArray(args.ids) ? args.ids : [args.id];
39
- if (!ids.length || ids.some((id) => typeof id !== 'string' || !id)
40
- || !['pending', 'in_progress', 'completed'].includes(args.status)) {
41
- return 'Error: update requires a valid task id (or ids) and status.';
23
+ if (!store || !chatId)
24
+ return 'Error: this tool requires a chat.';
25
+ const { action } = args || {};
26
+ if (action === 'list')
27
+ return JSON.stringify({ tasks: store.chats.listTasks(chatId) });
28
+ if (action === 'add') {
29
+ const title = typeof args.title === 'string' ? args.title.trim() : '';
30
+ if (!title || title.length > 200)
31
+ return 'Error: title must be 1–200 characters.';
32
+ if (store.chats.listTasks(chatId).length >= 50)
33
+ return 'Error: this chat has reached the 50-task limit.';
34
+ const task = store.chats.addTask(chatId, title);
35
+ onChange?.(store.chats.listTasks(chatId));
36
+ return JSON.stringify({ task });
37
+ }
38
+ if (action === 'update') {
39
+ const ids = Array.isArray(args.ids) ? args.ids : [args.id];
40
+ if (!ids.length || ids.some((id) => typeof id !== 'string' || !id)
41
+ || !['pending', 'in_progress', 'completed'].includes(args.status)) {
42
+ return 'Error: update requires a valid task id (or ids) and status.';
43
+ }
44
+ const tasks = ids.map((id) => store.chats.updateTask(chatId, id, args.status));
45
+ const missing = ids.filter((_, i) => !tasks[i]);
46
+ if (missing.length === ids.length)
47
+ return 'Error: task not found in this chat.';
48
+ onChange?.(store.chats.listTasks(chatId));
49
+ if (!Array.isArray(args.ids))
50
+ return JSON.stringify({ task: tasks[0] });
51
+ return JSON.stringify({ tasks: tasks.filter(Boolean), ...(missing.length ? { not_found: missing } : {}) });
42
52
  }
43
- const tasks = ids.map((id) => store.chats.updateTask(chatId, id, args.status));
44
- const missing = ids.filter((_, i) => !tasks[i]);
45
- if (missing.length === ids.length) return 'Error: task not found in this chat.';
46
- onChange?.(store.chats.listTasks(chatId));
47
- if (!Array.isArray(args.ids)) return JSON.stringify({ task: tasks[0] });
48
- return JSON.stringify({ tasks: tasks.filter(Boolean), ...(missing.length ? { not_found: missing } : {}) });
49
- }
50
- return 'Error: action must be add, update, or list.';
53
+ return 'Error: action must be add, update, or list.';
51
54
  }
52
-
53
55
  /**
54
56
  * The open checklist, for the budget footer. The model otherwise sees tasks
55
57
  * only in the tool results that created them, which scroll out of attention
56
58
  * within a turn and are gone from view entirely on the next one.
57
59
  */
58
60
  export function openTasksNote(tasks) {
59
- const open = (tasks || []).filter((t) => t.status !== 'completed');
60
- if (!open.length) return '';
61
- const lines = open.map((t) => `- ${t.id} [${t.status}] ${t.title}`);
62
- return `\n[Open tasks -- update them with ${MANAGE_TASKS} as you finish each one:\n${lines.join('\n')}]`;
61
+ const open = (tasks || []).filter((t) => t.status !== 'completed');
62
+ if (!open.length)
63
+ return '';
64
+ const lines = open.map((t) => `- ${t.id} [${t.status}] ${t.title}`);
65
+ return `\n[Open tasks -- update them with ${MANAGE_TASKS} as you finish each one:\n${lines.join('\n')}]`;
63
66
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,184 +0,0 @@
1
- /**
2
- * The admin editors for connectors (endpoint + key) and the model catalog.
3
- * Keys are write-only: a row shows whether one is set (and its last four
4
- * characters), and a blank key field means "keep it". Everything is read back
5
- * from the DOM on save, so the rows are the draft until then.
6
- */
7
- import { $, el } from '../core/dom.js';
8
- import { api } from '../core/api.js';
9
-
10
- const TAGS = ['vision', 'reasoning', 'tools'];
11
- // Per-model settings with no field of their own; edited as one JSON object.
12
- const OVERRIDE_KEYS = ['systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'cacheMode', 'cacheTtl', 'extraBody', 'reasoningReplay'];
13
-
14
- function field(label, input, note) {
15
- const wrap = el('div');
16
- const l = el('label');
17
- l.textContent = label;
18
- wrap.append(l, input);
19
- if (note) wrap.appendChild(Object.assign(el('small'), { textContent: note }));
20
- return wrap;
21
- }
22
-
23
- const textInput = (value, placeholder) => Object.assign(el('input'), { value: value ?? '', placeholder: placeholder || '', spellcheck: false });
24
-
25
- function removeButton(row) {
26
- const b = Object.assign(el('button'), { type: 'button', textContent: 'Remove' });
27
- b.onclick = () => row.remove();
28
- return b;
29
- }
30
-
31
- /* ---------- connectors ---------- */
32
-
33
- function connectorRow(c = {}) {
34
- const row = el('div', 'cfg-row');
35
- row.dataset.kind = 'connector';
36
- const id = textInput(c.id, 'local');
37
- // An id is what models name; renaming a saved one would orphan them.
38
- if (c.id) id.readOnly = true;
39
- const label = textInput(c.label, 'Local models');
40
- const url = textInput(c.baseUrl, 'http://localhost:11434/v1');
41
- const key = Object.assign(textInput('', c.hasApiKey ? `set (…${c.keyHint || ''})` : 'none'), { type: 'password', autocomplete: 'off' });
42
- const clear = Object.assign(el('input'), { type: 'checkbox' });
43
- const grid = el('div', 'set-grid');
44
- grid.append(
45
- field('Id', id, 'What a model names in its connector field.'),
46
- field('Label', label),
47
- Object.assign(field('API URL', url), { className: 'span-2' }),
48
- Object.assign(field('API key', key, 'Write-only. Blank keeps the current key.'), { className: 'span-2' })
49
- );
50
- const test = Object.assign(el('button'), { type: 'button', textContent: 'Test connection' });
51
- const msg = el('span', 'usage');
52
- test.onclick = async () => {
53
- msg.textContent = 'testing…';
54
- try {
55
- const out = await api.post('/api/connectors/test', { id: id.value.trim(), baseUrl: url.value.trim(), apiKey: key.value });
56
- msg.textContent = out.ok ? `reachable${out.models != null ? ` · ${out.models} models` : ''}` : out.error || `status ${out.status}`;
57
- } catch (err) { msg.textContent = err.message; }
58
- };
59
- const clearLabel = el('label');
60
- clearLabel.append(clear, ' remove stored key');
61
- const actions = el('div', 'cfg-actions');
62
- actions.append(test, msg, ...(c.hasApiKey ? [clearLabel] : []), removeButton(row));
63
- row.append(grid, actions);
64
- row.read = () => ({
65
- id: id.value.trim(),
66
- ...(label.value.trim() ? { label: label.value.trim() } : {}),
67
- baseUrl: url.value.trim(),
68
- // undefined keeps the stored key on the server; null clears it.
69
- ...(key.value ? { apiKey: key.value } : clear.checked ? { apiKey: null } : {})
70
- });
71
- return row;
72
- }
73
-
74
- /* ---------- model catalog ---------- */
75
-
76
- function modelRow(m = {}, connectorIds = []) {
77
- const row = el('div', 'cfg-row');
78
- row.dataset.kind = 'model';
79
- const id = textInput(m.id, 'fast');
80
- const label = textInput(m.label, 'Quick');
81
- const description = textInput(m.description, 'Everyday questions');
82
- const model = textInput(m.model, 'vendor/model-id');
83
- const connector = el('select');
84
- connector.className = 'model-connector';
85
- const none = Object.assign(el('option'), { value: '', textContent: 'Default endpoint' });
86
- connector.appendChild(none);
87
- for (const cid of connectorIds) connector.appendChild(Object.assign(el('option'), { value: cid, textContent: cid }));
88
- if (m.connector && !connectorIds.includes(m.connector)) connector.appendChild(Object.assign(el('option'), { value: m.connector, textContent: m.connector }));
89
- connector.value = m.connector || '';
90
- const enabled = Object.assign(el('input'), { type: 'checkbox', checked: m.enabled !== false });
91
- const tags = el('div', 'cfg-tags');
92
- const tagBoxes = TAGS.map((t) => {
93
- const cb = Object.assign(el('input'), { type: 'checkbox', checked: (m.tags || []).includes(t) });
94
- const l = el('label');
95
- l.append(cb, ` ${t}`);
96
- tags.appendChild(l);
97
- return [t, cb];
98
- });
99
- const overrides = Object.assign(el('textarea'), { rows: 3, spellcheck: false, placeholder: '{ "temperature": 0.3 }' });
100
- const kept = Object.fromEntries(OVERRIDE_KEYS.filter((k) => m[k] !== undefined).map((k) => [k, m[k]]));
101
- overrides.value = Object.keys(kept).length ? JSON.stringify(kept, null, 2) : '';
102
- const enabledLabel = el('label');
103
- enabledLabel.append(enabled, ' enabled');
104
-
105
- const grid = el('div', 'set-grid');
106
- grid.append(
107
- field('Id', id, 'Stable handle: roles and preferences name this.'),
108
- field('Label', label, 'What people see.'),
109
- Object.assign(field('Description', description), { className: 'span-2' }),
110
- field('Provider model id', model, 'Blank: the id above.'),
111
- field('Connector', connector),
112
- Object.assign(field('Capabilities', tags), { className: 'span-2' }),
113
- Object.assign(field('Overrides (JSON)', overrides, `Optional: ${OVERRIDE_KEYS.join(', ')}.`), { className: 'span-2' })
114
- );
115
- const actions = el('div', 'cfg-actions');
116
- actions.append(enabledLabel, removeButton(row));
117
- row.append(grid, actions);
118
- row.read = () => {
119
- let extra = {};
120
- if (overrides.value.trim()) {
121
- try { extra = JSON.parse(overrides.value); } catch (err) { throw new Error(`model "${id.value.trim()}" overrides: ${err.message}`); }
122
- const bad = Object.keys(extra).filter((k) => !OVERRIDE_KEYS.includes(k));
123
- if (bad.length || !extra || typeof extra !== 'object' || Array.isArray(extra)) throw new Error(`model "${id.value.trim()}" overrides must be an object of: ${OVERRIDE_KEYS.join(', ')}`);
124
- }
125
- const chosen = tagBoxes.filter(([, cb]) => cb.checked).map(([t]) => t);
126
- return {
127
- id: id.value.trim(),
128
- ...(label.value.trim() ? { label: label.value.trim() } : {}),
129
- ...(description.value.trim() ? { description: description.value.trim() } : {}),
130
- ...(model.value.trim() ? { model: model.value.trim() } : {}),
131
- ...(connector.value ? { connector: connector.value } : {}),
132
- ...(chosen.length ? { tags: chosen } : {}),
133
- ...(enabled.checked ? {} : { enabled: false }),
134
- ...extra
135
- };
136
- };
137
- return row;
138
- }
139
-
140
- const rows = (box) => [...box.children];
141
- const connectorIds = () => rows($('connectorsBox')).map((r) => r.read().id).filter(Boolean);
142
-
143
- /** Rebuilds both editors from /api/config. */
144
- export function renderConnectors(cfg) {
145
- $('connectorsBox').replaceChildren(...(cfg.connectors || []).map((c) => connectorRow(c)));
146
- const ids = (cfg.connectors || []).map((c) => c.id);
147
- $('modelsBox').replaceChildren(...(cfg.models || []).map((m) => modelRow(m, ids)));
148
- }
149
-
150
- /** What the editors currently hold, as the config patch to save. Throws on bad JSON. */
151
- export function collectConnectors() {
152
- return {
153
- connectors: rows($('connectorsBox')).map((r) => r.read()),
154
- models: rows($('modelsBox')).map((r) => r.read())
155
- };
156
- }
157
-
158
- /** Locks or unlocks every control in the three admin editors. */
159
- export function lockConnectors(locked) {
160
- for (const id of ['connectorsBox', 'modelsBox']) for (const n of $(id).querySelectorAll('input,select,textarea,button')) n.disabled = locked;
161
- $('addConnector').disabled = locked;
162
- $('addModel').disabled = locked;
163
- }
164
-
165
- export function initConnectors() {
166
- $('addConnector').onclick = () => {
167
- $('connectorsBox').appendChild(connectorRow());
168
- // New connector ids become choices in every model row.
169
- refreshConnectorChoices();
170
- };
171
- $('addModel').onclick = () => $('modelsBox').appendChild(modelRow({}, connectorIds()));
172
- // Ids are typed after the row exists, so the model rows re-read them on focus.
173
- $('modelsBox').addEventListener('focusin', (e) => { if (e.target.classList?.contains('model-connector')) refreshConnectorChoices(); });
174
- }
175
-
176
- function refreshConnectorChoices() {
177
- const ids = connectorIds();
178
- for (const sel of $('modelsBox').querySelectorAll('.model-connector')) {
179
- const current = sel.value;
180
- sel.replaceChildren(Object.assign(el('option'), { value: '', textContent: 'Default endpoint' }),
181
- ...[...new Set([...ids, ...(current && !ids.includes(current) ? [current] : [])])].map((cid) => Object.assign(el('option'), { value: cid, textContent: cid })));
182
- sel.value = current;
183
- }
184
- }
@@ -1,43 +0,0 @@
1
- import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
2
- import { createInterface } from 'node:readline';
3
- import { createConfigSource } from './config/config.js';
4
- import { hashPassword } from './access/auth.js';
5
- import { MIN_PASSWORD_LENGTH } from './access/policy.js';
6
-
7
- /** Reads one line without echoing it when attached to a terminal. */
8
- function ask(prompt) {
9
- return new Promise((resolve) => {
10
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
11
- if (process.stdin.isTTY) rl._writeToOutput = (s) => { if (s.startsWith(prompt)) rl.output.write(prompt); };
12
- rl.question(prompt, (answer) => { rl.close(); if (process.stdin.isTTY) process.stdout.write('\n'); resolve(answer); });
13
- });
14
- }
15
-
16
- /**
17
- * `tinywebui set-password`: stores an scrypt hash of a new password in the
18
- * config file and switches authMode to 'single'. Existing sessions are
19
- * signed with sessionSecret, which is rotated so they all end here too.
20
- */
21
- export async function setPassword(opts = {}) {
22
- if (opts.config && Object.hasOwn(opts.config, 'authMode') && opts.config.authMode !== 'single') {
23
- throw new Error('authMode is set in code; change the JS config to use password login');
24
- }
25
- if (opts.config && Object.hasOwn(opts.config, 'authPassword')) {
26
- throw new Error('authPassword is set in code; change the JS config to update the password');
27
- }
28
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
29
- throw new Error('set-password needs an interactive terminal; for headless startup set TINYWEBUI_PASSWORD');
30
- }
31
- const file = createConfigSource(opts).path();
32
- if (!file) throw new Error('no config file to write to');
33
- const pw = await ask('New password: ');
34
- if (pw.length < MIN_PASSWORD_LENGTH) throw new Error(`use at least ${MIN_PASSWORD_LENGTH} characters`);
35
- if ((await ask('Repeat password: ')) !== pw) throw new Error('passwords do not match');
36
- const current = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
37
- current.authMode = 'single';
38
- current.authPassword = hashPassword(pw);
39
- delete current.sessionSecret; // regenerated on next start: every old session ends
40
- if (existsSync(file) && process.platform !== 'win32') chmodSync(file, 0o600);
41
- writeFileSync(file, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
42
- console.log(`[tinywebui] password set in ${file}`);
43
- }