tinywebui 0.1.3 → 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 -63
  3. package/package.json +25 -8
  4. package/public/app.js +40 -14
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -49
  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 +83 -20
  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 -138
  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 -451
  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 +98 -66
  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 -100
  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 +428 -392
  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
package/src/chat/runs.js CHANGED
@@ -4,11 +4,8 @@ import { effectiveConfig } from '../config/models.js';
4
4
  import { logger } from '../log.js';
5
5
  import { EVENT } from '../../public/shared/events.js';
6
6
  import { CORPUS } from '../store/index.js';
7
-
8
7
  const log = logger('retrieval');
9
-
10
8
  const RETAIN_MS = 5 * 60 * 1000;
11
-
12
9
  /**
13
10
  * Turns in flight, by chat id.
14
11
  *
@@ -27,159 +24,173 @@ const RETAIN_MS = 5 * 60 * 1000;
27
24
  * `onRunIdle(chatId)`, called once a chat has nothing running.
28
25
  */
29
26
  export function createRuns(app) {
30
- const { store } = app;
31
- const runs = new Map();
32
-
33
- const get = (chatId) => runs.get(chatId);
34
- const isRunning = (chatId) => {
35
- const run = runs.get(chatId);
36
- return Boolean(run && !run.done);
37
- };
38
-
39
- /** Tells everyone watching a chat's run what is queued now. */
40
- const announceQueue = (chatId) => runs.get(chatId)?.emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chatId) });
41
-
42
- /**
43
- * `lead` is a queued follow-up that starts this run: stored after baseCount
44
- * and sent as an event, so a live view and a reload each show it once.
45
- */
46
- function start({ chat, tools, onFinish, historyFromSeq = null, unattended = false, model = null, lead = null, instructions = null }) {
47
- const run = {
48
- events: [],
49
- subs: new Set(),
50
- ac: new AbortController(),
51
- done: false,
52
- // Where the store stood when the turn began, including the user message
53
- // that started it. A client reopening mid-turn replays up to here and
54
- // plays the events over the top.
55
- baseCount: store.messages.list(chat.id).length,
56
- // Tool calls waiting on the user, by call id -> { name, resolve }.
57
- approvals: new Map(),
58
- // The ask_user question waiting on the user: { id, settle }, or null.
59
- question: null,
60
- unattended
27
+ const { store } = app;
28
+ const runs = new Map();
29
+ const get = (chatId) => runs.get(chatId);
30
+ const isRunning = (chatId) => {
31
+ const run = runs.get(chatId);
32
+ return Boolean(run && !run.done);
61
33
  };
62
- runs.set(chat.id, run);
63
- // A stop settles every open question as "no", so the loop can unwind
64
- // instead of waiting forever on a prompt nobody will answer now.
65
- run.ac.signal.addEventListener('abort', () => {
66
- for (const { resolve } of run.approvals.values()) resolve('deny');
67
- run.approvals.clear();
68
- // Stop ends the run; the question is closed so no late answer revives it.
69
- run.question?.settle('cancelled');
70
- }, { once: true });
71
- // Unattended runs get no asker: the loop refuses gated calls itself.
72
- const approve = unattended ? null : ({ id, name }) =>
73
- new Promise((resolve) => run.approvals.set(id, { name, resolve }));
74
-
34
+ /** Tells everyone watching a chat's run what is queued now. */
35
+ const announceQueue = (chatId) => runs.get(chatId)?.emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chatId) });
75
36
  /**
76
- * Puts one ask_user question to the user and waits: for an answer, a
77
- * skip, the timeout, or a stop. Persisted first, so the row decides who
78
- * settled it; the timer is read from the live config at ask time.
37
+ * `lead` is a queued follow-up that starts this run: stored after baseCount
38
+ * and sent as an event, so a live view and a reload each show it once.
79
39
  */
80
- const askUser = unattended ? null : ({ question, choices, allowFreeText }) => {
81
- if (run.ac.signal.aborted) return Promise.resolve({ answered: false, reason: 'cancelled' });
82
- // Calls are sequential, so a second one can only arrive after the first
83
- // settled; this is the guard should that ever change.
84
- if (run.question) return Promise.resolve({ answered: false, reason: 'busy' });
85
- const id = randomUUID();
86
- const seconds = Math.max(0, Number(app.cfg.askUserTimeoutSeconds) || 0);
87
- const deadline = seconds ? Date.now() + seconds * 1000 : null;
88
- store.chats.addQuestion(chat.id, { id, question, choices, allowFreeText, deadline });
89
- return new Promise((resolve) => {
90
- let timer = null;
91
- const settle = (status, answer = null) => {
92
- if (!store.chats.settleQuestion(id, status, answer)) return false;
93
- clearTimeout(timer);
94
- if (run.question?.id === id) run.question = null;
95
- emit({ type: EVENT.QUESTION_DONE, id, status, answer });
96
- resolve(status === 'answered' ? { answered: true, answer } : { answered: false, reason: status });
97
- return true;
40
+ function start({ chat, tools, onFinish, historyFromSeq = null, unattended = false, model = null, lead = null, instructions = null }) {
41
+ const run = {
42
+ events: [],
43
+ subs: new Set(),
44
+ ac: new AbortController(),
45
+ done: false,
46
+ // Where the store stood when the turn began, including the user message
47
+ // that started it. A client reopening mid-turn replays up to here and
48
+ // plays the events over the top.
49
+ baseCount: store.messages.list(chat.id).length,
50
+ // Tool calls waiting on the user, by call id -> { name, resolve }.
51
+ approvals: new Map(),
52
+ // The ask_user question waiting on the user: { id, settle }, or null.
53
+ question: null,
54
+ unattended,
55
+ emit
98
56
  };
99
- run.question = { id, choices, allowFreeText, settle };
100
- if (deadline) timer = setTimeout(() => settle('timeout'), seconds * 1000);
101
- emit({ type: EVENT.QUESTION, id, question, choices, allowFreeText, deadline, timeoutSeconds: seconds });
102
- });
103
- };
104
-
105
- const emit = (event) => {
106
- run.events.push(event);
107
- for (const sub of run.subs) {
108
- try { sub.write(`data: ${JSON.stringify(event)}\n\n`); } catch { run.subs.delete(sub); }
109
- }
110
- };
111
-
112
- run.emit = emit;
113
- emit({ type: EVENT.CHAT, id: chat.id, title: chat.title });
114
- if (lead != null) {
115
- store.messages.add(chat.id, { role: 'user', content: lead });
116
- emit({ type: EVENT.USER, content: lead });
117
- }
118
- emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chat.id) });
119
-
120
- // Steering is interactive input: an unattended run never takes any, so
121
- // it cannot swallow what someone typed into a chat an automation shares.
122
- const takeInput = unattended ? null : () => {
123
- const items = store.chats.takeQueued(chat.id, { kind: 'steer' });
124
- if (items.length) emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chat.id) });
125
- return items.map((i) => i.content);
126
- };
127
-
128
- run.promise = (async () => {
129
- try {
130
- const base = effectiveConfig(app.cfg, model);
131
- const cfg = instructions ? { ...base, systemPrompt: `${base.systemPrompt}
57
+ runs.set(chat.id, run);
58
+ // A stop settles every open question as "no", so the loop can unwind
59
+ // instead of waiting forever on a prompt nobody will answer now.
60
+ run.ac.signal.addEventListener('abort', () => {
61
+ for (const { resolve } of run.approvals.values())
62
+ resolve('deny');
63
+ run.approvals.clear();
64
+ // Stop ends the run; the question is closed so no late answer revives it.
65
+ run.question?.settle('cancelled');
66
+ }, { once: true });
67
+ // Unattended runs get no asker: the loop refuses gated calls itself.
68
+ const approve = unattended ? null : ({ id, name }) => new Promise((resolve) => run.approvals.set(id, { name, resolve }));
69
+ /**
70
+ * Puts one ask_user question to the user and waits: for an answer, a
71
+ * skip, the timeout, or a stop. Persisted first, so the row decides who
72
+ * settled it; the timer is read from the live config at ask time.
73
+ */
74
+ const askUser = unattended ? null : ({ question, choices, allowFreeText }) => {
75
+ if (run.ac.signal.aborted)
76
+ return Promise.resolve({ answered: false, reason: 'cancelled' });
77
+ // Calls are sequential, so a second one can only arrive after the first
78
+ // settled; this is the guard should that ever change.
79
+ if (run.question)
80
+ return Promise.resolve({ answered: false, reason: 'busy' });
81
+ const id = randomUUID();
82
+ const seconds = Math.max(0, Number(app.cfg.askUserTimeoutSeconds) || 0);
83
+ const deadline = seconds ? Date.now() + seconds * 1000 : null;
84
+ store.chats.addQuestion(chat.id, { id, question, choices, allowFreeText, deadline });
85
+ return new Promise((resolve) => {
86
+ let timer;
87
+ const settle = (status, answer = null) => {
88
+ if (!store.chats.settleQuestion(id, status, answer))
89
+ return false;
90
+ clearTimeout(timer);
91
+ if (run.question?.id === id)
92
+ run.question = null;
93
+ emit({ type: EVENT.QUESTION_DONE, id, status, answer });
94
+ resolve(status === 'answered' ? { answered: true, answer: answer } : { answered: false, reason: status });
95
+ return true;
96
+ };
97
+ run.question = { id, choices, allowFreeText, settle };
98
+ if (deadline)
99
+ timer = setTimeout(() => settle('timeout'), seconds * 1000);
100
+ emit({ type: EVENT.QUESTION, id, question, choices, allowFreeText, deadline, timeoutSeconds: seconds });
101
+ });
102
+ };
103
+ function emit(event) {
104
+ run.events.push(event);
105
+ for (const sub of run.subs) {
106
+ try {
107
+ sub.write(`data: ${JSON.stringify(event)}\n\n`);
108
+ }
109
+ catch {
110
+ run.subs.delete(sub);
111
+ }
112
+ }
113
+ }
114
+ emit({ type: EVENT.CHAT, id: chat.id, title: chat.title });
115
+ if (lead != null) {
116
+ store.messages.add(chat.id, { role: 'user', content: lead });
117
+ emit({ type: EVENT.USER, content: lead });
118
+ }
119
+ emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chat.id) });
120
+ // Steering is interactive input: an unattended run never takes any, so
121
+ // it cannot swallow what someone typed into a chat an automation shares.
122
+ const takeInput = unattended ? null : () => {
123
+ const items = store.chats.takeQueued(chat.id, { kind: 'steer' });
124
+ if (items.length)
125
+ emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chat.id) });
126
+ return items.map((i) => i.content);
127
+ };
128
+ // Not awaited: the run outlives the request that started it.
129
+ void (async () => {
130
+ try {
131
+ const base = effectiveConfig(app.cfg, model);
132
+ const cfg = instructions ? { ...base, systemPrompt: `${base.systemPrompt}
132
133
 
133
134
  The user's own instructions:
134
135
  ${instructions}` } : base;
135
- await runChat({ cfg, chatId: chat.id, store, tools, hub: app.hub, emit, signal: run.ac.signal, historyFromSeq, unattended, approve, takeInput, askUser });
136
- } catch (err) {
137
- emit({ type: EVENT.ERROR, error: run.ac.signal.aborted ? 'Stopped.' : err.message });
138
- } finally {
139
- store.chats.touch(chat.id);
140
- run.done = true;
141
- app.retrieval.ingest(CORPUS.CHATS, chat.id).catch((err) => log.error(`embedding chat ${chat.id} failed: ${err.message}`));
142
- // The run would go idle here, so the oldest queued item starts the
143
- // next one -- in this same synchronous block, so nothing queued before
144
- // `done` flipped can be missed. A stop or failure delivers nothing:
145
- // the queue stays put and the client hands it back to the composer.
146
- const ok = !run.events.some((event) => event.type === EVENT.ERROR) && !run.ac.signal.aborted;
147
- const next = ok && !unattended ? store.chats.takeQueued(chat.id, { first: true })[0] : null;
148
- if (next) {
149
- emit({ type: EVENT.NEXT_RUN });
150
- start({ chat, tools, model, lead: next.content });
151
- }
152
- try { onFinish?.({
153
- ok,
154
- error: run.events.find((event) => event.type === EVENT.ERROR)?.error || (run.ac.signal.aborted ? 'Stopped.' : null),
155
- result: store.messages.list(chat.id).slice(run.baseCount).filter((m) => m.role === 'assistant').at(-1)?.content || ''
156
- }); } catch { /* a run observer cannot disrupt chat teardown */ }
157
- for (const sub of run.subs) { try { sub.end(); } catch { /* already gone */ } }
158
- run.subs.clear();
159
- // Held briefly so a client reconnecting a second later still gets the
160
- // tail of the turn rather than a 404.
161
- setTimeout(() => { if (runs.get(chat.id) === run) runs.delete(chat.id); }, RETAIN_MS).unref();
162
- queueMicrotask(() => app.onRunIdle(chat.id));
163
- }
164
- })();
165
-
166
- return run;
167
- }
168
-
169
- /** Points one response at a run: the backlog from `from`, then the live rest. */
170
- function attach(run, res, from) {
171
- res.writeHead(200, {
172
- 'content-type': 'text/event-stream',
173
- 'cache-control': 'no-cache',
174
- connection: 'keep-alive'
175
- });
176
- for (const ev of run.events.slice(from)) res.write(`data: ${JSON.stringify(ev)}\n\n`);
177
- if (run.done) return res.end();
178
- run.subs.add(res);
179
- // A viewer leaving is just a viewer leaving. The run keeps going.
180
- res.on('close', () => run.subs.delete(res));
181
- return undefined;
182
- }
183
-
184
- return { get, isRunning, start, attach, announceQueue };
136
+ await runChat({ cfg, chatId: chat.id, store, tools, hub: app.hub, emit, signal: run.ac.signal, historyFromSeq, unattended, approve, takeInput, askUser });
137
+ }
138
+ catch (err) {
139
+ emit({ type: EVENT.ERROR, error: run.ac.signal.aborted ? 'Stopped.' : err.message });
140
+ }
141
+ finally {
142
+ store.chats.touch(chat.id);
143
+ run.done = true;
144
+ app.retrieval.ingest(CORPUS.CHATS, chat.id).catch((err) => log.error(`embedding chat ${chat.id} failed: ${err.message}`));
145
+ // The run would go idle here, so the oldest queued item starts the
146
+ // next one -- in this same synchronous block, so nothing queued before
147
+ // `done` flipped can be missed. A stop or failure delivers nothing:
148
+ // the queue stays put and the client hands it back to the composer.
149
+ const ok = !run.events.some((event) => event.type === EVENT.ERROR) && !run.ac.signal.aborted;
150
+ const next = ok && !unattended ? store.chats.takeQueued(chat.id, { first: true })[0] : null;
151
+ if (next) {
152
+ emit({ type: EVENT.NEXT_RUN });
153
+ start({ chat, tools, model, lead: next.content });
154
+ }
155
+ try {
156
+ onFinish?.({
157
+ ok,
158
+ error: run.events.find((event) => event.type === EVENT.ERROR)?.error || (run.ac.signal.aborted ? 'Stopped.' : null),
159
+ result: store.messages.list(chat.id).slice(run.baseCount).filter((m) => m.role === 'assistant').at(-1)?.content || ''
160
+ });
161
+ }
162
+ catch { /* a run observer cannot disrupt chat teardown */ }
163
+ for (const sub of run.subs) {
164
+ try {
165
+ sub.end();
166
+ }
167
+ catch { /* already gone */ }
168
+ }
169
+ run.subs.clear();
170
+ // Held briefly so a client reconnecting a second later still gets the
171
+ // tail of the turn rather than a 404.
172
+ setTimeout(() => { if (runs.get(chat.id) === run)
173
+ runs.delete(chat.id); }, RETAIN_MS).unref();
174
+ queueMicrotask(() => app.onRunIdle(chat.id));
175
+ }
176
+ })();
177
+ return run;
178
+ }
179
+ /** Points one response at a run: the backlog from `from`, then the live rest. */
180
+ function attach(run, res, from) {
181
+ res.writeHead(200, {
182
+ 'content-type': 'text/event-stream',
183
+ 'cache-control': 'no-cache',
184
+ connection: 'keep-alive'
185
+ });
186
+ for (const ev of run.events.slice(from))
187
+ res.write(`data: ${JSON.stringify(ev)}\n\n`);
188
+ if (run.done)
189
+ return res.end();
190
+ run.subs.add(res);
191
+ // A viewer leaving is just a viewer leaving. The run keeps going.
192
+ res.on('close', () => run.subs.delete(res));
193
+ return undefined;
194
+ }
195
+ return { get, isRunning, start, attach, announceQueue };
185
196
  }