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
@@ -1,232 +1,254 @@
1
- import { json, readJson, TURN_LIMIT } from '../http.js';
1
+ import { json, readJson, oneOf, TURN_LIMIT } from '../http.js';
2
2
  import { LockedError } from '../config/config.js';
3
+ import { hasEndpoint } from '../config/models.js';
3
4
  import { toView } from '../store/index.js';
4
5
  import { setOverride } from '../config/approval.js';
5
6
  import { actor } from '../access/auth_gate.js';
6
7
  import { normalizeUpload } from './documents.js';
7
8
  import { attachmentNote } from '../../public/shared/attachment_note.js';
8
9
  import { FEATURE } from '../../public/shared/features.js';
9
-
10
10
  const MAX_IMAGES = 8;
11
11
  const F = FEATURE.CHAT;
12
-
13
12
  /** Images and documents on a turn need their own features, whatever the route. */
14
13
  function refusedFeature(auth, { images, documentIds }) {
15
- if (Array.isArray(images) && images.length && !auth.features.has(FEATURE.IMAGES)) return FEATURE.IMAGES;
16
- if (Array.isArray(documentIds) && documentIds.length && !auth.features.has(FEATURE.ATTACHMENTS)) return FEATURE.ATTACHMENTS;
17
- return null;
14
+ if (Array.isArray(images) && images.length && !auth.features.has(FEATURE.IMAGES))
15
+ return FEATURE.IMAGES;
16
+ if (Array.isArray(documentIds) && documentIds.length && !auth.features.has(FEATURE.ATTACHMENTS))
17
+ return FEATURE.ATTACHMENTS;
18
+ return null;
18
19
  }
19
-
20
20
  export function chatRoutes({ config, instructionsFor, modelFor, runs, saveConfig, store, toolsFor }) {
21
- const noChat = (res) => json(res, 404, { error: 'no such chat' });
22
-
23
- return [
24
- // `running` is what puts the dot in the sidebar: a turn belongs to the
25
- // server, so a chat can be working while nothing is watching it.
26
- { method: 'GET', path: /^\/api\/chats$/, feature: F, handle: ({ res, auth }) => json(res, 200, {
27
- chats: store.chats.list(200, auth.userId).map((c) => ({ ...c, running: runs.isRunning(c.id) }))
28
- }) },
29
-
30
- { method: 'GET', path: /^\/api\/chats\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
31
- const found = store.chats.get(id, auth.userId);
32
- if (!found) return noChat(res);
33
- const run = runs.get(found.id);
34
- const live = run && !run.done;
35
- const messages = store.messages.list(found.id).map(toView);
36
- return json(res, 200, {
37
- id: found.id,
38
- title: found.title,
39
- folder: found.folder || null,
40
- epoch: found.epoch,
41
- // A turn in flight has already written some of itself to the store.
42
- // Cutting the transcript back to where the turn began lets the client
43
- // replay the settled part and then play the run's events over the top,
44
- // instead of rendering the same rounds twice.
45
- messages: live ? messages.slice(0, run.baseCount) : messages,
46
- running: Boolean(live),
47
- queued: store.chats.listQueued(found.id),
48
- tasks: store.chats.visibleTasks(found.id),
49
- documents: store.documents.list(found.id)
50
- });
51
- } },
52
-
53
- { method: 'DELETE', path: /^\/api\/chats\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
54
- if (!store.chats.get(id, auth.userId)) return noChat(res);
55
- store.chats.delete(id, auth.userId);
56
- return json(res, 200, { ok: true });
57
- } },
58
-
59
- { method: 'GET', path: /^\/api\/chats\/([\w.-]+)\/stream$/, feature: F, handle: ({ res, auth, url, params: [id] }) => {
60
- if (!store.chats.get(id, auth.userId)) return noChat(res);
61
- const run = runs.get(id);
62
- if (!run) return json(res, 404, { error: 'nothing running' });
63
- return runs.attach(run, res, Number(url.searchParams.get('from')) || 0);
64
- } },
65
-
66
- // Rewriting a question and answering it again. Asking the same question
67
- // again is the same operation with the same text, so there is one route.
68
- { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/edit$/, feature: F, handle: async ({ req, res, auth, params: [id] }) => {
69
- const found = store.chats.get(id, auth.userId);
70
- if (!found) return noChat(res);
71
- // Rewriting history under a turn that is still reading it would leave
72
- // the run answering a question that no longer exists.
73
- if (runs.isRunning(id)) return json(res, 409, { error: 'that chat is still working; stop it first' });
74
-
75
- const body = await readJson(req, TURN_LIMIT);
76
- const { seq, message, documentIds, removeDocumentIds, images } = body;
77
- const refused = refusedFeature(auth, body);
78
- if (refused) return json(res, 403, { error: 'feature_disabled', feature: refused });
79
- const text = String(message ?? '').trim();
80
- if (!text) return json(res, 400, { error: 'message is required' });
81
- const target = store.messages.list(id).find((m) => m.seq === Number(seq));
82
- if (!target || target.role !== 'user') {
83
- return json(res, 400, { error: 'seq must name a question of your own' });
84
- }
85
-
86
- store.chats.truncateFrom(id, Number(seq));
87
- // A document is chat-scoped, not part of the message row that just got
88
- // truncated, so dropping one during edit takes an explicit delete --
89
- // this is the only place that happens. Nothing survives that would
90
- // still reference it: truncateFrom already took every later message
91
- // (the only other place a reference could live) with it.
92
- for (const docId of Array.isArray(removeDocumentIds) ? removeDocumentIds : []) {
93
- const doc = store.documents.get(docId, auth.userId);
94
- if (doc?.chat_id === id) store.documents.delete(docId, auth.userId);
95
- }
96
- let content = text;
97
- for (const docId of Array.isArray(documentIds) ? documentIds : []) {
98
- const doc = store.documents.get(docId, auth.userId);
99
- if (doc?.chat_id === id) content += attachmentNote(doc);
100
- }
101
- const kept = Array.isArray(images) ? images.filter((img) => img?.mime && img?.data) : [];
102
- store.messages.add(id, { role: 'user', content, ...(kept.length ? { images: kept } : {}) });
103
-
104
- // The run is started but not streamed back here. The client reloads the
105
- // rewound transcript and then attaches, the same path a reload takes,
106
- // rather than reading a stream through a response it also has to
107
- // redraw behind.
108
- runs.start({ chat: found, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role), instructions: instructionsFor(auth.user?.id) });
109
- return json(res, 200, { ok: true, running: true });
110
- } },
111
-
112
- // The user's answer to an approval prompt in the transcript.
113
- { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/approve$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
114
- if (!store.chats.get(chatId, auth.userId)) return noChat(res);
115
- const { id, decision } = await readJson(req);
116
- if (!['allow', 'always', 'deny'].includes(decision)) {
117
- return json(res, 400, { error: 'decision must be allow, always or deny' });
118
- }
119
- const run = runs.get(chatId);
120
- const pending = run?.approvals.get(id);
121
- if (!pending) return json(res, 409, { error: 'that call is no longer waiting' });
122
- run.approvals.delete(id);
123
- // "Always" rewrites tool policy, which is the tools feature's to do;
124
- // without it, the answer counts as allowing this one call.
125
- if (decision === 'always' && auth.features.has(FEATURE.TOOLS)) {
126
- // Approval lists set in code cannot be saved to; allow this call only.
127
- try { saveConfig(setOverride(config(), pending.name, 'auto'), actor(auth)); }
128
- catch (err) { if (!(err instanceof LockedError)) throw err; }
129
- }
130
- pending.resolve(decision);
131
- return json(res, 200, { ok: true });
132
- } },
133
-
134
- // The user's answer to an ask_user question, or `skip` to let the run
135
- // continue without one. Only the run's own open question can be
136
- // answered; anything else (answered, timed out, stopped) is a 409.
137
- { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/answer$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
138
- if (!store.chats.get(chatId, auth.userId)) return noChat(res);
139
- const { id, answer, skip } = await readJson(req, TURN_LIMIT);
140
- const gone = () => json(res, 409, { error: 'that question is no longer waiting' });
141
- const pending = runs.get(chatId)?.question;
142
- if (!pending || pending.id !== id) return gone();
143
- if (skip === true) return pending.settle('skipped') ? json(res, 200, { ok: true }) : gone();
144
- const text = String(answer ?? '').trim();
145
- if (!text) return json(res, 400, { error: 'answer is required' });
146
- if (!pending.allowFreeText && !pending.choices.includes(text)) {
147
- return json(res, 400, { error: 'answer must be one of the offered choices' });
148
- }
149
- return pending.settle('answered', text) ? json(res, 200, { ok: true }) : gone();
150
- } },
151
-
152
- // Input sent while a turn is running. Refused when nothing interactive
153
- // is running (send it normally) and while an automation holds the chat.
154
- { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/queue$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
155
- if (!store.chats.get(chatId, auth.userId)) return noChat(res);
156
- const { id, kind, message } = await readJson(req, TURN_LIMIT);
157
- const text = String(message ?? '').trim();
158
- if (!text) return json(res, 400, { error: 'message is required' });
159
- if (!['steer', 'followup'].includes(kind)) return json(res, 400, { error: 'kind must be steer or followup' });
160
- if (typeof id !== 'string' || !/^[\w.-]{1,64}$/.test(id)) return json(res, 400, { error: 'id is required' });
161
- const run = runs.get(chatId);
162
- if (!run || run.done) return json(res, 409, { error: 'nothing is running; send it as a message' });
163
- if (run.unattended) return json(res, 409, { error: 'an automation is running in this chat; wait for it to finish' });
164
- store.chats.addQueued(chatId, { id, kind, content: text });
165
- runs.announceQueue(chatId);
166
- return json(res, 200, { ok: true, items: store.chats.listQueued(chatId) });
167
- } },
168
-
169
- { method: 'DELETE', path: /^\/api\/chats\/([\w.-]+)\/queue\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [chatId, itemId] }) => {
170
- if (!store.chats.get(chatId, auth.userId)) return noChat(res);
171
- const removed = store.chats.deleteQueued(chatId, itemId);
172
- runs.announceQueue(chatId);
173
- return json(res, removed ? 200 : 409, removed ? { ok: true } : { error: 'already delivered' });
174
- } },
175
-
176
- { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/stop$/, feature: F, handle: ({ res, auth, params: [chatId] }) => {
177
- if (!store.chats.get(chatId, auth.userId)) return noChat(res);
178
- runs.get(chatId)?.ac.abort();
179
- return json(res, 200, { ok: true });
180
- } },
181
-
182
- { method: 'POST', path: /^\/api\/chat$/, feature: F, handle: async ({ req, res, auth }) => {
183
- const body = await readJson(req, TURN_LIMIT);
184
- const { chatId, message, documentIds, images } = body;
185
- const refused = refusedFeature(auth, body);
186
- if (refused) return json(res, 403, { error: 'feature_disabled', feature: refused });
187
- if (!config().apiKey) return json(res, 400, { error: 'No API key. Set TINYWEBUI_API_KEY or apiKey in the config file.' });
188
- if (!message) return json(res, 400, { error: 'message is required' });
189
-
190
- // The client no longer ships the transcript: it sends the new turn and
191
- // the server replays what it already holds. That is what stops a
192
- // page-sized tool result from crossing the wire on every message.
193
- const owned = chatId && store.chats.get(chatId, auth.userId);
194
- // Someone else's id is "no such chat", never a primary-key clash.
195
- if (chatId && !owned && store.chats.byId(chatId)) return noChat(res);
196
- const chat = owned || store.chats.create({ id: chatId, title: String(message).slice(0, 60) }, auth.userId);
197
-
198
- // Attachments are surfaced as plain text inline notes rather than a
199
- // system-prompt change, the same idiom compact.js uses for a compacted
200
- // artifact -- the model sees "[Attached document: ...]" in the message
201
- // it's already reading and knows to call read_document on the id.
202
- let content = String(message);
203
- for (const id of Array.isArray(documentIds) ? documentIds : []) {
204
- const doc = store.documents.get(id, auth.userId);
205
- if (!doc || doc.chat_id !== chat.id) continue;
206
- content += attachmentNote(doc);
207
- }
208
-
209
- // Images ride directly on the message as OpenAI multimodal content
210
- // parts -- unlike a document there is no text to extract, so the bytes
211
- // have to go up with the turn that attached them. Usually already
212
- // normalized client-side (/api/images/normalize); normalizing again is a
213
- // cheap passthrough then, and a safety net for callers that skipped it.
214
- const imgs = [];
215
- for (const img of Array.isArray(images) ? images.slice(0, MAX_IMAGES) : []) {
216
- const normalized = await normalizeUpload(img?.mime, img?.dataBase64);
217
- if (normalized) imgs.push(normalized);
218
- }
219
-
220
- store.messages.add(chat.id, { role: 'user', content, ...(imgs.length ? { images: imgs } : {}) });
221
-
222
- // The turn is started, not awaited. Closing the tab detaches a
223
- // listener; it no longer kills the work, and the answer is in the store
224
- // whether or not anyone was watching when it landed.
225
- const existing = runs.get(chat.id);
226
- const run = existing && !existing.done
227
- ? existing
228
- : runs.start({ chat, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role), instructions: instructionsFor(auth.user?.id) });
229
- return runs.attach(run, res, 0);
230
- } }
231
- ];
21
+ const noChat = (res) => json(res, 404, { error: 'no such chat' });
22
+ return [
23
+ // `running` is what puts the dot in the sidebar: a turn belongs to the
24
+ // server, so a chat can be working while nothing is watching it.
25
+ { method: 'GET', path: /^\/api\/chats$/, feature: F, handle: ({ res, auth }) => json(res, 200, {
26
+ chats: store.chats.list(200, auth.userId).map((c) => ({ ...c, running: runs.isRunning(c.id) }))
27
+ }) },
28
+ { method: 'GET', path: /^\/api\/chats\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
29
+ const found = store.chats.get(id, auth.userId);
30
+ if (!found)
31
+ return noChat(res);
32
+ const run = runs.get(found.id);
33
+ const live = run && !run.done;
34
+ const messages = store.messages.list(found.id).map(toView);
35
+ return json(res, 200, {
36
+ id: found.id,
37
+ title: found.title,
38
+ folder: found.folder || null,
39
+ epoch: found.epoch,
40
+ // A turn in flight has already written some of itself to the store.
41
+ // Cutting the transcript back to where the turn began lets the client
42
+ // replay the settled part and then play the run's events over the top,
43
+ // instead of rendering the same rounds twice.
44
+ messages: live ? messages.slice(0, run.baseCount) : messages,
45
+ running: Boolean(live),
46
+ queued: store.chats.listQueued(found.id),
47
+ tasks: store.chats.visibleTasks(found.id),
48
+ documents: store.documents.list(found.id)
49
+ });
50
+ } },
51
+ { method: 'DELETE', path: /^\/api\/chats\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
52
+ if (!store.chats.get(id, auth.userId))
53
+ return noChat(res);
54
+ store.chats.delete(id, auth.userId);
55
+ return json(res, 200, { ok: true });
56
+ } },
57
+ { method: 'GET', path: /^\/api\/chats\/([\w.-]+)\/stream$/, feature: F, handle: ({ res, auth, url, params: [id] }) => {
58
+ if (!store.chats.get(id, auth.userId))
59
+ return noChat(res);
60
+ const run = runs.get(id);
61
+ if (!run)
62
+ return json(res, 404, { error: 'nothing running' });
63
+ return runs.attach(run, res, Number(url.searchParams.get('from')) || 0);
64
+ } },
65
+ // Rewriting a question and answering it again. Asking the same question
66
+ // again is the same operation with the same text, so there is one route.
67
+ { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/edit$/, feature: F, handle: async ({ req, res, auth, params: [id] }) => {
68
+ const found = store.chats.get(id, auth.userId);
69
+ if (!found)
70
+ return noChat(res);
71
+ // Rewriting history under a turn that is still reading it would leave
72
+ // the run answering a question that no longer exists.
73
+ if (runs.isRunning(id))
74
+ return json(res, 409, { error: 'that chat is still working; stop it first' });
75
+ const body = await readJson(req, TURN_LIMIT);
76
+ const { seq, message, documentIds, removeDocumentIds, images } = body;
77
+ const refused = refusedFeature(auth, body);
78
+ if (refused)
79
+ return json(res, 403, { error: 'feature_disabled', feature: refused });
80
+ const text = String(message ?? '').trim();
81
+ if (!text)
82
+ return json(res, 400, { error: 'message is required' });
83
+ const target = store.messages.list(id).find((m) => m.seq === Number(seq));
84
+ if (!target || target.role !== 'user') {
85
+ return json(res, 400, { error: 'seq must name a question of your own' });
86
+ }
87
+ store.chats.truncateFrom(id, Number(seq));
88
+ // A document is chat-scoped, not part of the message row that just got
89
+ // truncated, so dropping one during edit takes an explicit delete --
90
+ // this is the only place that happens. Nothing survives that would
91
+ // still reference it: truncateFrom already took every later message
92
+ // (the only other place a reference could live) with it.
93
+ for (const docId of Array.isArray(removeDocumentIds) ? removeDocumentIds : []) {
94
+ const doc = store.documents.get(docId, auth.userId);
95
+ if (doc?.chat_id === id)
96
+ store.documents.delete(docId, auth.userId);
97
+ }
98
+ let content = text;
99
+ for (const docId of Array.isArray(documentIds) ? documentIds : []) {
100
+ const doc = store.documents.get(docId, auth.userId);
101
+ if (doc?.chat_id === id)
102
+ content += attachmentNote(doc);
103
+ }
104
+ const kept = Array.isArray(images) ? images.filter((img) => img?.mime && img?.data) : [];
105
+ store.messages.add(id, { role: 'user', content, ...(kept.length ? { images: kept } : {}) });
106
+ // The run is started but not streamed back here. The client reloads the
107
+ // rewound transcript and then attaches, the same path a reload takes,
108
+ // rather than reading a stream through a response it also has to
109
+ // redraw behind.
110
+ runs.start({ chat: found, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role), instructions: instructionsFor(auth.user?.id) });
111
+ return json(res, 200, { ok: true, running: true });
112
+ } },
113
+ // The user's answer to an approval prompt in the transcript.
114
+ { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/approve$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
115
+ if (!store.chats.get(chatId, auth.userId))
116
+ return noChat(res);
117
+ const { id, decision } = await readJson(req);
118
+ if (!oneOf(decision, ['allow', 'always', 'deny'])) {
119
+ return json(res, 400, { error: 'decision must be allow, always or deny' });
120
+ }
121
+ const run = runs.get(chatId);
122
+ const pending = typeof id === 'string' ? run?.approvals.get(id) : undefined;
123
+ if (!pending)
124
+ return json(res, 409, { error: 'that call is no longer waiting' });
125
+ run.approvals.delete(id);
126
+ // "Always" rewrites tool policy, which is the tools feature's to do;
127
+ // without it, the answer counts as allowing this one call.
128
+ if (decision === 'always' && auth.features.has(FEATURE.TOOLS)) {
129
+ // Approval lists set in code cannot be saved to; allow this call only.
130
+ try {
131
+ saveConfig(setOverride(config(), pending.name, 'auto'), actor(auth));
132
+ }
133
+ catch (err) {
134
+ if (!(err instanceof LockedError))
135
+ throw err;
136
+ }
137
+ }
138
+ pending.resolve(decision);
139
+ return json(res, 200, { ok: true });
140
+ } },
141
+ // The user's answer to an ask_user question, or `skip` to let the run
142
+ // continue without one. Only the run's own open question can be
143
+ // answered; anything else (answered, timed out, stopped) is a 409.
144
+ { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/answer$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
145
+ if (!store.chats.get(chatId, auth.userId))
146
+ return noChat(res);
147
+ const { id, answer, skip } = await readJson(req, TURN_LIMIT);
148
+ const gone = () => json(res, 409, { error: 'that question is no longer waiting' });
149
+ const pending = runs.get(chatId)?.question;
150
+ if (!pending || pending.id !== id)
151
+ return gone();
152
+ if (skip === true)
153
+ return pending.settle('skipped') ? json(res, 200, { ok: true }) : gone();
154
+ const text = String(answer ?? '').trim();
155
+ if (!text)
156
+ return json(res, 400, { error: 'answer is required' });
157
+ if (!pending.allowFreeText && !pending.choices.includes(text)) {
158
+ return json(res, 400, { error: 'answer must be one of the offered choices' });
159
+ }
160
+ return pending.settle('answered', text) ? json(res, 200, { ok: true }) : gone();
161
+ } },
162
+ // Input sent while a turn is running. Refused when nothing interactive
163
+ // is running (send it normally) and while an automation holds the chat.
164
+ { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/queue$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
165
+ if (!store.chats.get(chatId, auth.userId))
166
+ return noChat(res);
167
+ const { id, kind, message } = await readJson(req, TURN_LIMIT);
168
+ const text = String(message ?? '').trim();
169
+ if (!text)
170
+ return json(res, 400, { error: 'message is required' });
171
+ if (!oneOf(kind, ['steer', 'followup']))
172
+ return json(res, 400, { error: 'kind must be steer or followup' });
173
+ if (typeof id !== 'string' || !/^[\w.-]{1,64}$/.test(id))
174
+ return json(res, 400, { error: 'id is required' });
175
+ const run = runs.get(chatId);
176
+ if (!run || run.done)
177
+ return json(res, 409, { error: 'nothing is running; send it as a message' });
178
+ if (run.unattended)
179
+ return json(res, 409, { error: 'an automation is running in this chat; wait for it to finish' });
180
+ store.chats.addQueued(chatId, { id, kind, content: text });
181
+ runs.announceQueue(chatId);
182
+ return json(res, 200, { ok: true, items: store.chats.listQueued(chatId) });
183
+ } },
184
+ { method: 'DELETE', path: /^\/api\/chats\/([\w.-]+)\/queue\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [chatId, itemId] }) => {
185
+ if (!store.chats.get(chatId, auth.userId))
186
+ return noChat(res);
187
+ const removed = store.chats.deleteQueued(chatId, itemId);
188
+ runs.announceQueue(chatId);
189
+ return json(res, removed ? 200 : 409, removed ? { ok: true } : { error: 'already delivered' });
190
+ } },
191
+ { method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/stop$/, feature: F, handle: ({ res, auth, params: [chatId] }) => {
192
+ if (!store.chats.get(chatId, auth.userId))
193
+ return noChat(res);
194
+ runs.get(chatId)?.ac.abort();
195
+ return json(res, 200, { ok: true });
196
+ } },
197
+ { method: 'POST', path: /^\/api\/chat$/, feature: F, handle: async ({ req, res, auth }) => {
198
+ const body = await readJson(req, TURN_LIMIT);
199
+ const { chatId, message, documentIds, images } = body;
200
+ const refused = refusedFeature(auth, body);
201
+ if (refused)
202
+ return json(res, 403, { error: 'feature_disabled', feature: refused });
203
+ const model = modelFor(auth.user?.id, auth.role);
204
+ if (!hasEndpoint(config(), model))
205
+ return json(res, 400, { error: 'No model is connected yet: an admin adds a connector and its models in Settings.' });
206
+ if (!message)
207
+ return json(res, 400, { error: 'message is required' });
208
+ // Ids go into SQL and into URLs the page builds, so they must look like one.
209
+ if (chatId != null && (typeof chatId !== 'string' || !/^[\w.-]{1,128}$/.test(chatId)))
210
+ return json(res, 400, { error: 'chatId must be an id' });
211
+ // The client no longer ships the transcript: it sends the new turn and
212
+ // the server replays what it already holds. That is what stops a
213
+ // page-sized tool result from crossing the wire on every message.
214
+ const owned = chatId ? store.chats.get(chatId, auth.userId) : null;
215
+ // Someone else's id is "no such chat", never a primary-key clash.
216
+ if (chatId && !owned && store.chats.byId(chatId))
217
+ return noChat(res);
218
+ const chat = owned || store.chats.create({ id: chatId ?? undefined, title: String(message).slice(0, 60) }, auth.userId);
219
+ // Attachments are surfaced as plain text inline notes rather than a
220
+ // system-prompt change, the same idiom compact.js uses for a compacted
221
+ // artifact -- the model sees "[Attached document: ...]" in the message
222
+ // it's already reading and knows to call read_document on the id.
223
+ let content = String(message);
224
+ for (const id of Array.isArray(documentIds) ? documentIds : []) {
225
+ if (typeof id !== 'string')
226
+ continue;
227
+ const doc = store.documents.get(id, auth.userId);
228
+ if (!doc || doc.chat_id !== chat.id)
229
+ continue;
230
+ content += attachmentNote(doc);
231
+ }
232
+ // Images ride directly on the message as OpenAI multimodal content
233
+ // parts -- unlike a document there is no text to extract, so the bytes
234
+ // have to go up with the turn that attached them. Usually already
235
+ // normalized client-side (/api/images/normalize); normalizing again is a
236
+ // cheap passthrough then, and a safety net for callers that skipped it.
237
+ const imgs = [];
238
+ for (const img of Array.isArray(images) ? images.slice(0, MAX_IMAGES) : []) {
239
+ const normalized = await normalizeUpload(img?.mime, img?.dataBase64);
240
+ if (normalized)
241
+ imgs.push(normalized);
242
+ }
243
+ store.messages.add(chat.id, { role: 'user', content, ...(imgs.length ? { images: imgs } : {}) });
244
+ // The turn is started, not awaited. Closing the tab detaches a
245
+ // listener; it no longer kills the work, and the answer is in the store
246
+ // whether or not anyone was watching when it landed.
247
+ const existing = runs.get(chat.id);
248
+ const run = existing && !existing.done
249
+ ? existing
250
+ : runs.start({ chat, tools: toolsFor(auth.features), model, instructions: instructionsFor(auth.user?.id) });
251
+ return runs.attach(run, res, 0);
252
+ } }
253
+ ];
232
254
  }