tinywebui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,430 @@
1
+ /* The provider side of a turn: request shape, prompt caching, retries and the SSE stream. */
2
+ import { EVENT } from '../../public/shared/events.js';
3
+
4
+ /**
5
+ * Caching has two layers, and only the first one is universal.
6
+ *
7
+ * Layer 1 -- the stable prefix -- is what `canonical`, append-only history and
8
+ * frozen compaction below give every backend for free. llama.cpp, Ollama,
9
+ * vLLM, LM Studio, DeepSeek, Gemini and OpenAI all cache on an exact prefix
10
+ * match with no request field involved, so keeping the front of the request
11
+ * byte-identical is where most of the saving comes from on most setups.
12
+ *
13
+ * Layer 2 -- explicit markers -- exists because a handful of gateways bill
14
+ * cache writes separately and want to be told where to write. That is a
15
+ * per-gateway wire dialect, not a fact about the model, so it lives in one
16
+ * table here and is off unless we have a reason to believe it is understood.
17
+ * An endpoint we do not recognise (which is every local runtime) gets layer 1
18
+ * only: markers there are pure downside -- array content is the part strict
19
+ * OpenAI-compatible servers reject, and they buy nothing on a backend that
20
+ * already caches the prefix automatically.
21
+ *
22
+ * `cacheMode` in the config overrides the guess, so a new model or a gateway
23
+ * we have never heard of is a config line, not a patch to this file.
24
+ *
25
+ * 'auto' pick per the table below (default)
26
+ * 'implicit' layer 1 only -- stable prefix, no marker fields
27
+ * 'explicit' Anthropic-style cache_control breakpoints in message content
28
+ * 'rolling' OpenRouter top-level automatic cache_control
29
+ * 'off' no cache shaping at all
30
+ */
31
+ export function isOpenRouter(cfg) {
32
+ return /^https?:\/\/(?:[^/]+\.)?openrouter\.ai(?::\d+)?(?:\/|$)/i.test(cfg.baseUrl || '');
33
+ }
34
+
35
+ /** Loopback and LAN endpoints: a local runtime, whatever model it is serving. */
36
+ function isLocal(cfg) {
37
+ let host = '';
38
+ try { host = new URL(cfg.baseUrl || '').hostname.toLowerCase(); } catch { return false; }
39
+ return host === 'localhost' || host === '::1' || host === 'host.docker.internal'
40
+ || host.endsWith('.local') || /^127\./.test(host)
41
+ || /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host);
42
+ }
43
+
44
+ function isClaude(model) {
45
+ return /claude|anthropic/i.test(model || '');
46
+ }
47
+
48
+ /**
49
+ * Model families that bill cache writes and therefore want an explicit marker.
50
+ * Matched at the family level on purpose: pinning exact model ids means every
51
+ * new release needs a code change, and a marker on a sibling that does not
52
+ * need one is ignored rather than fatal.
53
+ *
54
+ * Deliberately Anthropic-only. A marker is not free on everyone else: it turns
55
+ * a message's content from a string into an array, and the rolling breakpoint
56
+ * moves off that message next round, turning it back. Anthropic tolerates that
57
+ * because cache_control only picks where to WRITE and its prefix matching
58
+ * ignores the field -- but a gateway that prefix-matches the serialised request
59
+ * sees a byte change in the middle of the prefix and misses everything after
60
+ * it. DeepSeek, Qwen and Gemini all cache the prefix automatically, so layer 1
61
+ * already gets the saving and the marker is pure downside.
62
+ */
63
+ const NATIVE_ANTHROPIC = /claude|anthropic|amazon\.nova|nova-(?:lite|micro|pro|premier)/i;
64
+
65
+ /** Resolves config + endpoint + model to exactly one of the modes above. */
66
+ export function cacheMode(cfg) {
67
+ const asked = cfg.cacheMode || 'auto';
68
+ if (cfg.cache === false) return 'off';
69
+ if (asked !== 'auto') return asked;
70
+
71
+ // A pinned provider route means the caller has taken routing into their own
72
+ // hands; automatic mode would quietly narrow it, so fall back to markers.
73
+ // Only routing counts: policy fields like zdr or data_collection narrow the
74
+ // pool without choosing an upstream, so they leave automatic mode alone.
75
+ const route = cfg.extraBody?.provider || {};
76
+ const pinned = Boolean(route.order?.length || route.only?.length);
77
+
78
+ if (isOpenRouter(cfg)) {
79
+ if (isClaude(cfg.model) && !pinned) return 'rolling';
80
+ return NATIVE_ANTHROPIC.test(cfg.model || '') ? 'explicit' : 'implicit';
81
+ }
82
+ // Local runtimes cache the prefix themselves and are the strictest about
83
+ // request shape. Never shape their wire.
84
+ if (isLocal(cfg)) return 'implicit';
85
+ // A direct Anthropic-compatible endpoint is the one remaining case worth
86
+ // marking; anything else we have not identified gets the safe path.
87
+ return NATIVE_ANTHROPIC.test(cfg.model || '') ? 'explicit' : 'implicit';
88
+ }
89
+
90
+ function cacheDirective(cfg) {
91
+ const directive = { type: 'ephemeral' };
92
+ if (isClaude(cfg.model) && cfg.cacheTtl === '1h') directive.ttl = '1h';
93
+ return directive;
94
+ }
95
+
96
+ const REPLAY = { OMIT: 'omit', CONTENT: 'reasoning_content', DETAILS: 'reasoning_details' };
97
+
98
+ /**
99
+ * Which reasoning a past assistant turn sends back. Structured reasoning goes
100
+ * back wherever the gateway normalises it, not only where cache markers are on:
101
+ * without it a model on a multi-round tool turn loses its plan between rounds,
102
+ * and some (Gemini's thought signatures, Anthropic) reject the follow-up
103
+ * outright. OpenRouter passes it to the upstream that produced it. Direct
104
+ * DeepSeek wants its own plain `reasoning_content` on tool-call turns instead,
105
+ * and rejects `reasoning_details`. Anything else is billed for reasoning it
106
+ * ignores, so it gets none. `reasoningReplay` overrides this for a gateway the
107
+ * table does not know.
108
+ */
109
+ export function reasoningReplay(cfg) {
110
+ if (cfg.reasoningReplay && cfg.reasoningReplay !== 'auto') return cfg.reasoningReplay;
111
+ if (isOpenRouter(cfg) || cacheMode(cfg) === 'explicit') return REPLAY.DETAILS;
112
+ let host = '';
113
+ try { host = new URL(cfg.baseUrl || '').hostname.toLowerCase(); } catch { /* no endpoint set */ }
114
+ return host === 'api.deepseek.com' ? REPLAY.CONTENT : REPLAY.OMIT;
115
+ }
116
+
117
+ /**
118
+ * Prompt-cache shaping: one fixed key order for every message on the wire.
119
+ *
120
+ * Every provider caches on an exact prefix match, so the real work is keeping
121
+ * the front of the request byte-identical between turns -- one system message
122
+ * built the same way, tools in a stable order, history only ever appended to.
123
+ * That alone is what buys the saving on the majority of models.
124
+ *
125
+ * Which is why this function exists rather than passing messages through: a
126
+ * message must serialise the same whether it was appended a moment ago or
127
+ * rebuilt from the database next turn, and JSON.stringify follows insertion
128
+ * order. Without one canonical shape the two paths emit different bytes for
129
+ * identical content and the whole prefix misses. Which reasoning goes back
130
+ * is the provider's contract, see reasoningReplay.
131
+ */
132
+ function canonical(m, replay) {
133
+ const out = { role: m.role };
134
+ // A tool-only assistant turn stores content as null, but some gateways (seen
135
+ // on DeepInfra via OpenRouter) reject a null text part outright and want an
136
+ // empty string instead. Only assistant+tool_calls hits this; every other
137
+ // role still sends null through unchanged.
138
+ out.content = (m.content == null && m.role === 'assistant' && m.tool_calls) ? '' : (m.content ?? null);
139
+ if (m.tool_calls) out.tool_calls = m.tool_calls;
140
+ if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
141
+ if (replay === REPLAY.DETAILS && m.reasoning_details) out.reasoning_details = m.reasoning_details;
142
+ // Only a tool-call turn: DeepSeek needs its reasoning to continue the turn
143
+ // it belongs to and has no use for it on a finished answer.
144
+ if (replay === REPLAY.CONTENT && m.role === 'assistant' && m.tool_calls && m.reasoning) {
145
+ out.reasoning_content = m.reasoning;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * Marks a cache breakpoint on the newest text-bearing turn at or before `from`.
152
+ * Only user/assistant text can carry it: an assistant message that is purely
153
+ * tool_calls has null content, and tool results are left alone because not
154
+ * every OpenAI-compatible gateway accepts array content there.
155
+ */
156
+ function markBreakpoint(messages, from, directive) {
157
+ for (let i = Math.min(from, messages.length - 1); i > 0; i--) {
158
+ const m = messages[i];
159
+ if (typeof m.content !== 'string' || !m.content) continue;
160
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
161
+ messages[i] = {
162
+ ...m,
163
+ content: [{ type: 'text', text: m.content, cache_control: { ...directive } }]
164
+ };
165
+ return i;
166
+ }
167
+ return -1;
168
+ }
169
+
170
+ export function buildMessages(cfg, history, epochIndex = -1) {
171
+ // Only 'explicit' shapes message content. 'rolling' is the strategy by
172
+ // itself and must not be stacked with markers; 'implicit' and 'off' send
173
+ // plain strings, which is what every strict OpenAI-compatible server wants.
174
+ const bp = cacheMode(cfg) === 'explicit';
175
+ const directive = cacheDirective(cfg);
176
+ const system = {
177
+ role: 'system',
178
+ content: bp
179
+ ? [{ type: 'text', text: cfg.systemPrompt, cache_control: { ...directive } }]
180
+ : cfg.systemPrompt
181
+ };
182
+
183
+ // Kept for the whole history, not just the current turn: stripping it once a
184
+ // turn is over would rewrite those bytes and miss the cache on the next turn.
185
+ const replay = reasoningReplay(cfg);
186
+ const messages = [system, ...history.map((m) => canonical(m, replay))];
187
+ if (bp) {
188
+ // Rolling breakpoint at the END of what we are sending, not behind it. Each
189
+ // tool round appends an assistant turn and its results, and marking the
190
+ // newest text message writes all of that to the cache so the next round
191
+ // reads it back instead of re-paying for it. Anthropic uses cache_control
192
+ // only to decide where to WRITE -- prefix matching ignores it -- so moving
193
+ // the marker forward each round does not invalidate what is already cached.
194
+ const rolling = markBreakpoint(messages, messages.length - 1, directive);
195
+ // Second, pinned breakpoint on the frozen compacted prefix. The rolling one
196
+ // moves every round and its entry expires; this one sits on a span that can
197
+ // no longer change, so the expensive part of the conversation stays cached
198
+ // for as long as the epoch lasts. Skipped when it would land on the same
199
+ // message the rolling marker already claimed.
200
+ if (epochIndex >= 0) {
201
+ const at = epochIndex + 1; // +1 for the system message at index 0
202
+ if (at < rolling || rolling === -1) markBreakpoint(messages, at, directive);
203
+ }
204
+ }
205
+ return messages;
206
+ }
207
+
208
+ // Optional knobs, in the order we give them up. Gateways vary in what they
209
+ // accept, and none of these is worth failing a whole turn over: caching is an
210
+ // optimisation, tool_choice has a prompt-level fallback, and usage is cosmetic.
211
+ const OPTIONAL = [
212
+ { probe: /cache_control|ephemeral/i, drop: 'cache' },
213
+ { probe: /tool_choice/i, drop: 'tool_choice' },
214
+ { probe: /stream_options|include_usage/i, drop: 'stream_options' }
215
+ ];
216
+
217
+ /** Builds one request body, leaving out whatever this endpoint has refused. */
218
+ export function buildBody(cfg, { turn, tools, lastCall, epochIndex, chatId, relax }, disabled) {
219
+ const body = {
220
+ // Gateway-specific routing/knobs first, so nothing below can be clobbered.
221
+ ...(cfg.extraBody || {}),
222
+ model: cfg.model,
223
+ messages: buildMessages(
224
+ disabled.has('cache') ? { ...cfg, cache: false } : cfg, turn, epochIndex
225
+ ),
226
+ stream: true
227
+ };
228
+
229
+ // Last resort after the pinned provider has refused us repeatedly. Pinning
230
+ // exists to keep the prompt cache on one upstream, but a warm cache is worth
231
+ // less than a turn that finishes, so the pin yields before the turn does.
232
+ if (relax && body.provider && body.provider.allow_fallbacks === false) {
233
+ body.provider = { ...body.provider, allow_fallbacks: true };
234
+ }
235
+
236
+ // session_id is an OpenRouter field. Sending it anywhere else risks a strict
237
+ // server rejecting an unknown key over something that would do nothing.
238
+ if (isOpenRouter(cfg) && chatId && body.session_id == null) {
239
+ body.session_id = String(chatId).slice(0, 256);
240
+ }
241
+
242
+ // Rolling mode: the gateway advances the breakpoint to the last cacheable
243
+ // block itself, which is the only shape that keeps caching across client-side
244
+ // tool results in an agentic loop.
245
+ if (!disabled.has('cache') && cacheMode(cfg) === 'rolling') {
246
+ body.cache_control = cacheDirective(cfg);
247
+ }
248
+
249
+ if (!disabled.has('stream_options')) body.stream_options = { include_usage: true };
250
+ // Only send the sampling knobs that were actually set, so an unset one gets
251
+ // the provider's default instead of ours.
252
+ if (cfg.temperature != null) body.temperature = cfg.temperature;
253
+ if (cfg.maxTokens != null) body.max_tokens = cfg.maxTokens;
254
+ if (tools.length) {
255
+ // The tool block stays in the request even on the final pass: dropping it
256
+ // would change the cached prefix. `tool_choice: none` is what blocks the call.
257
+ body.tools = tools;
258
+ if (lastCall && !disabled.has('tool_choice')) body.tool_choice = 'none';
259
+ }
260
+ return body;
261
+ }
262
+
263
+ /**
264
+ * Failures worth trying again: a busy or briefly broken upstream, not a bad
265
+ * request. A rate limit is the common one, and it is the reason this exists --
266
+ * pinning to a single provider for cache stability means its rate limit is now
267
+ * the whole budget, and a 429 used to end the turn outright.
268
+ */
269
+ const TRANSIENT = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
270
+ const MAX_RETRIES = 4;
271
+ // Rounds re-run after the stream itself fails, on top of post()'s retries.
272
+ export const STREAM_RETRIES = 2;
273
+
274
+ /** A failure reported inside an otherwise successful stream. */
275
+ export class StreamError extends Error {}
276
+ // How many refusals to take from a pinned provider before trying another one.
277
+ // Waiting out the full backoff first is the wrong trade: a provider that is
278
+ // rate-limited on its shared pool is usually limited for longer than any
279
+ // backoff we are willing to sit through, and a cold cache costs far less than
280
+ // half a minute of nothing.
281
+ const RELEASE_PIN_AFTER = 2;
282
+
283
+ /** How long to wait: what the provider asked for, else backing off with jitter. */
284
+ export function retryDelay(res, attempt) {
285
+ const header = res?.headers?.get?.('retry-after');
286
+ if (header) {
287
+ const secs = Number(header);
288
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, 30000);
289
+ const when = Date.parse(header);
290
+ if (!Number.isNaN(when)) return Math.min(Math.max(when - Date.now(), 0), 30000);
291
+ }
292
+ // Jittered, so two tabs retrying the same upstream do not march in step.
293
+ return Math.min(1000 * 2 ** attempt, 8000) * (0.5 + Math.random());
294
+ }
295
+
296
+ /** A sleep that a stop actually interrupts, rather than one you wait out. */
297
+ export function sleep(ms, signal) {
298
+ return new Promise((resolve, reject) => {
299
+ if (signal?.aborted) return reject(new Error('Stopped.'));
300
+ const timer = setTimeout(resolve, ms);
301
+ signal?.addEventListener('abort', () => {
302
+ clearTimeout(timer);
303
+ reject(new Error('Stopped.'));
304
+ }, { once: true });
305
+ return undefined;
306
+ });
307
+ }
308
+
309
+ /**
310
+ * POSTs the completion, and if the gateway rejects it over one of the optional
311
+ * knobs, drops that knob and retries. `disabled` is shared across the whole turn,
312
+ * so we learn an endpoint's limits once rather than re-probing every round.
313
+ *
314
+ * A transient failure is retried on the same provider first -- that is the only
315
+ * outcome that keeps both the turn and the prompt cache. Once those are spent,
316
+ * a pinned route is released for one final attempt: a cold cache somewhere else
317
+ * still beats losing the turn and everything already spent on it.
318
+ */
319
+ export async function post(cfg, plan, signal, disabled, emit) {
320
+ let retries = 0;
321
+ let relaxed = false;
322
+
323
+ const pinned = cfg.extraBody?.provider?.allow_fallbacks === false;
324
+
325
+ const backOff = async (res, why) => {
326
+ // A pin exists to keep the prompt cache on one upstream. Once that upstream
327
+ // has refused us twice, the cache is not the binding constraint any more --
328
+ // finishing the turn is. Retry elsewhere immediately rather than waiting.
329
+ if (pinned && !relaxed && retries >= RELEASE_PIN_AFTER) {
330
+ relaxed = true;
331
+ emit({ type: EVENT.NOTICE, text: `${why} — releasing the provider pin (cache will be cold).` });
332
+ return true;
333
+ }
334
+ if (retries < MAX_RETRIES) {
335
+ retries++;
336
+ const wait = retryDelay(res, retries);
337
+ emit({
338
+ type: EVENT.NOTICE,
339
+ text: `${why} — retrying in ${(wait / 1000).toFixed(1)}s (${retries}/${MAX_RETRIES}).`
340
+ });
341
+ await sleep(wait, signal);
342
+ return true;
343
+ }
344
+ return false;
345
+ };
346
+
347
+ for (;;) {
348
+ let res;
349
+ const body = buildBody(cfg, { ...plan, relax: relaxed }, disabled);
350
+ plan.onRequest?.(body);
351
+ try {
352
+ res = await fetch(`${cfg.baseUrl}/chat/completions`, {
353
+ method: 'POST',
354
+ signal,
355
+ // Local runtimes are usually keyless, and the ranking headers are
356
+ // OpenRouter's own -- neither belongs on a request to someone else.
357
+ headers: {
358
+ 'content-type': 'application/json',
359
+ ...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}),
360
+ ...(isOpenRouter(cfg)
361
+ ? {
362
+ 'http-referer': 'https://github.com/TinySuiteHQ/tinywebui',
363
+ 'x-title': 'TinyWebUI'
364
+ }
365
+ : {})
366
+ },
367
+ body: JSON.stringify(body)
368
+ });
369
+ } catch (err) {
370
+ // A dropped connection looks like nothing at all, so it is judged here
371
+ // rather than by a status code. A stop is not a failure to retry.
372
+ if (signal?.aborted) throw err;
373
+ if (await backOff(null, `Network error (${err.message})`)) continue;
374
+ throw err;
375
+ }
376
+
377
+ if (res.ok && res.body) return res;
378
+
379
+ const text = await res.text();
380
+
381
+ // A knob this endpoint refuses is not transient; dropping it is the fix.
382
+ const culprit = res.status >= 400 && res.status < 500
383
+ ? OPTIONAL.find((o) => !disabled.has(o.drop) && o.probe.test(text))
384
+ : null;
385
+ if (culprit) {
386
+ disabled.add(culprit.drop);
387
+ emit({ type: EVENT.NOTICE, text: `Provider rejected ${culprit.drop}; retrying without it.` });
388
+ continue;
389
+ }
390
+
391
+ if (TRANSIENT.has(res.status)) {
392
+ if (await backOff(res, `Provider returned ${res.status}`)) continue;
393
+ }
394
+
395
+ // A model with no vision support rejects image content with a 4xx rather
396
+ // than any dedicated status code, and the raw provider text is often just
397
+ // an opaque "invalid content" -- naming the actual cause here is the
398
+ // difference between that and a clear, actionable error in the transcript.
399
+ const hasImage = plan.turn.some((m) => Array.isArray(m.content)
400
+ && m.content.some((p) => p?.type === 'image_url'));
401
+ if (hasImage && res.status >= 400 && res.status < 500) {
402
+ throw new Error(
403
+ `This model doesn't appear to support image input. `
404
+ + `Provider said: ${res.status} ${res.statusText}: ${text.slice(0, 300)}`
405
+ );
406
+ }
407
+
408
+ throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 500)}`);
409
+ }
410
+ }
411
+
412
+ export async function* streamChunks(res) {
413
+ const reader = res.body.getReader();
414
+ const decoder = new TextDecoder();
415
+ let buf = '';
416
+ while (true) {
417
+ const { done, value } = await reader.read();
418
+ if (done) break;
419
+ buf += decoder.decode(value, { stream: true });
420
+ let idx;
421
+ while ((idx = buf.indexOf('\n')) !== -1) {
422
+ const line = buf.slice(0, idx).trim();
423
+ buf = buf.slice(idx + 1);
424
+ if (!line.startsWith('data:')) continue;
425
+ const data = line.slice(5).trim();
426
+ if (data === '[DONE]') return;
427
+ try { yield JSON.parse(data); } catch { /* keepalive / partial */ }
428
+ }
429
+ }
430
+ }
package/src/http.js ADDED
@@ -0,0 +1,95 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join, normalize } from 'node:path';
4
+
5
+ const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
6
+ const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' };
7
+
8
+ export function json(res, code, payload) {
9
+ const body = JSON.stringify(payload);
10
+ res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
11
+ res.end(body);
12
+ }
13
+
14
+ /** An error that carries its own HTTP status to the handler's catch. */
15
+ export class HttpError extends Error {
16
+ constructor(status, message) { super(message); this.status = status; }
17
+ }
18
+
19
+ // Body caps. Most requests are a few KB; uploads and turns with images carry
20
+ // base64 (4/3 of the file), so they get room for their own per-file limits.
21
+ export const BODY_LIMIT = 1024 * 1024;
22
+ export const UPLOAD_LIMIT = 8 * 1024 * 1024; // one 5 MB file, base64
23
+ export const TURN_LIMIT = 60 * 1024 * 1024; // up to 8 images of 5 MB, base64
24
+
25
+ /** Reads a JSON body, refusing more than `limit` bytes (413) or bad JSON (400). */
26
+ export async function readJson(req, limit = BODY_LIMIT) {
27
+ const declared = Number(req.headers['content-length']);
28
+ if (declared > limit) throw new HttpError(413, `request body exceeds ${limit} bytes`);
29
+ const chunks = [];
30
+ let size = 0;
31
+ for await (const c of req) {
32
+ size += c.length;
33
+ if (size > limit) throw new HttpError(413, `request body exceeds ${limit} bytes`);
34
+ chunks.push(c);
35
+ }
36
+ try {
37
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
38
+ } catch {
39
+ throw new HttpError(400, 'request body is not valid JSON');
40
+ }
41
+ }
42
+
43
+ // Sent on every response. The page loads only its own scripts; the one
44
+ // outside origin is the Fall Fairy theme's Google Fonts.
45
+ export const SECURITY_HEADERS = {
46
+ 'content-security-policy': [
47
+ "default-src 'self'", "script-src 'self'", "connect-src 'self'",
48
+ "style-src 'self' https://fonts.googleapis.com",
49
+ "font-src 'self' data: https://fonts.gstatic.com",
50
+ "img-src 'self' data: blob:", "object-src 'none'", "base-uri 'none'",
51
+ "frame-ancestors 'none'", "form-action 'self'"
52
+ ].join('; '),
53
+ 'x-content-type-options': 'nosniff',
54
+ 'x-frame-options': 'DENY',
55
+ 'referrer-policy': 'no-referrer',
56
+ 'cross-origin-opener-policy': 'same-origin'
57
+ };
58
+
59
+ /**
60
+ * Cross-site request forgery guard for anything that changes state. Browsers
61
+ * send Origin on cross-origin POSTs (and Sec-Fetch-Site on all modern ones),
62
+ * so a page elsewhere cannot drive this one with the user's cookies, the
63
+ * gateway's included -- or poke a tier-1 instance on localhost.
64
+ */
65
+ export function crossSite(req, cfg) {
66
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return false;
67
+ const origin = req.headers.origin;
68
+ if (origin && origin !== 'null') {
69
+ let host;
70
+ try { host = new URL(origin).host; } catch { return true; }
71
+ if (host === req.headers.host) return false;
72
+ return !(cfg.allowedOrigins || []).includes(origin);
73
+ }
74
+ if (origin === 'null') return true;
75
+ return req.headers['sec-fetch-site'] === 'cross-site';
76
+ }
77
+
78
+ export async function serveStatic(req, res) {
79
+ // Resolve the index BEFORE normalising: on Windows normalize('/') returns a
80
+ // lone backslash, so a check for '/' after it never matches and the root
81
+ // request lands on the directory itself.
82
+ const pathname = req.url.split('?')[0];
83
+ const rel = normalize(pathname === '/' ? 'index.html' : pathname)
84
+ .replace(/^(\.\.[/\\])+/, '');
85
+ const file = join(PUBLIC_DIR, rel);
86
+ if (!file.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
87
+ try {
88
+ const data = await readFile(file);
89
+ const ext = file.slice(file.lastIndexOf('.'));
90
+ res.writeHead(200, { 'content-type': TYPES[ext] || 'application/octet-stream' });
91
+ res.end(data);
92
+ } catch {
93
+ json(res, 404, { error: 'not found' });
94
+ }
95
+ }
package/src/log.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Operational log lines: `[tinywebui:<area>] message`, so one area's lines are
3
+ * one search away. The level comes from TINYWEBUI_LOG_LEVEL (debug, info,
4
+ * warn, error, silent; default info). Warnings and errors go to stderr.
5
+ *
6
+ * Audit events are not logging: they go through audit.js and are never
7
+ * filtered by level.
8
+ */
9
+ const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: Infinity };
10
+
11
+ const threshold = () => LEVELS[String(process.env.TINYWEBUI_LOG_LEVEL || '').toLowerCase()] ?? LEVELS.info;
12
+
13
+ export function logger(area) {
14
+ const line = (level, write) => (message) => {
15
+ if (LEVELS[level] < threshold()) return;
16
+ write(`[tinywebui:${area}] ${level === 'info' ? '' : `${level}: `}${message}`);
17
+ };
18
+ return {
19
+ debug: line('debug', console.log),
20
+ info: line('info', console.log),
21
+ warn: line('warn', console.error),
22
+ error: line('error', console.error)
23
+ };
24
+ }