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.
- package/README.md +20 -13
- package/bin/tinywebui.js +69 -53
- package/package.json +25 -8
- package/public/app.js +40 -34
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -53
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +84 -21
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -136
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -454
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +90 -72
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -111
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +426 -415
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
package/src/routes/chats.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
}
|