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/chat/compact.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { textMap, EXPAND_CONTEXT } from '../tools/context_tool.js';
|
|
2
|
-
|
|
3
2
|
/**
|
|
4
3
|
* Context compaction.
|
|
5
4
|
*
|
|
@@ -25,168 +24,156 @@ import { textMap, EXPAND_CONTEXT } from '../tools/context_tool.js';
|
|
|
25
24
|
* server. Nothing below may look at a tool's name or know its schema; the
|
|
26
25
|
* only structure we infer is generic (JSON, XML-ish, or neither).
|
|
27
26
|
*/
|
|
28
|
-
|
|
29
27
|
export const DIGEST_HEAD = 400;
|
|
30
28
|
export const DIGEST_TAIL = 200;
|
|
31
|
-
|
|
32
29
|
/**
|
|
33
30
|
* A one-line shape hint, derived only from generic structure. Gives the model
|
|
34
31
|
* enough to judge whether re-reading is worth an expand_context call, without
|
|
35
32
|
* the stub pretending to summarise content it has not understood.
|
|
36
33
|
*/
|
|
37
34
|
function outline(text) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// XML-ish covers a lot of MCP output without committing to a parser.
|
|
53
|
-
const tags = trimmed.match(/<([a-zA-Z][\w:.-]*)(?=[\s/>])/g);
|
|
54
|
-
if (tags && tags.length >= 2) {
|
|
55
|
-
const counts = new Map();
|
|
56
|
-
for (const t of tags) {
|
|
57
|
-
const name = t.slice(1);
|
|
58
|
-
counts.set(name, (counts.get(name) || 0) + 1);
|
|
35
|
+
const trimmed = text.trim();
|
|
36
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(trimmed);
|
|
39
|
+
if (Array.isArray(parsed))
|
|
40
|
+
return `json array, ${parsed.length} item(s)`;
|
|
41
|
+
if (parsed && typeof parsed === 'object') {
|
|
42
|
+
const keys = Object.keys(parsed);
|
|
43
|
+
const shown = keys.slice(0, 12).join(', ');
|
|
44
|
+
return `json object, keys: ${shown}${keys.length > 12 ? `, +${keys.length - 12} more` : ''}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch { /* not JSON after all; fall through */ }
|
|
59
48
|
}
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
49
|
+
// XML-ish covers a lot of MCP output without committing to a parser.
|
|
50
|
+
const tags = trimmed.match(/<([a-zA-Z][\w:.-]*)(?=[\s/>])/g);
|
|
51
|
+
if (tags && tags.length >= 2) {
|
|
52
|
+
const counts = new Map();
|
|
53
|
+
for (const t of tags) {
|
|
54
|
+
const name = t.slice(1);
|
|
55
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
// Sorted by count, then name, so the line is stable for identical input.
|
|
58
|
+
const top = [...counts.entries()]
|
|
59
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
60
|
+
.slice(0, 8)
|
|
61
|
+
.map(([name, n]) => `${name}×${n}`);
|
|
62
|
+
return `tags: ${top.join(', ')}`;
|
|
63
|
+
}
|
|
64
|
+
const lines = trimmed ? trimmed.split('\n').length : 0;
|
|
65
|
+
return `plain text, ${lines} line(s)`;
|
|
70
66
|
}
|
|
71
|
-
|
|
72
67
|
/**
|
|
73
68
|
* Builds the stub that replaces a tool result on the wire. Head and tail are
|
|
74
69
|
* kept verbatim because the ends of a tool result are where the useful framing
|
|
75
70
|
* usually is, and because a verbatim excerpt cannot hallucinate.
|
|
76
71
|
*/
|
|
77
72
|
export function digest(artifact, { head = DIGEST_HEAD, tail = DIGEST_TAIL } = {}) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
`[Full output retained. Read it with ${EXPAND_CONTEXT}("${id}", grep=... or offset/limit).]`
|
|
100
|
-
].join('\n');
|
|
73
|
+
const text = artifact.content ?? '';
|
|
74
|
+
const len = text.length;
|
|
75
|
+
const id = artifact.id;
|
|
76
|
+
const name = artifact.tool_name || 'tool';
|
|
77
|
+
if (len <= head + tail) {
|
|
78
|
+
// Nothing to gain; keep it whole rather than emit a stub bigger than its source.
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
const elided = len - head - tail;
|
|
82
|
+
// Only for prose: a JSON or XML outline already names its own structure, and
|
|
83
|
+
// the model greps those by key or tag rather than paging through them.
|
|
84
|
+
const shape = outline(text);
|
|
85
|
+
const map = shape.startsWith('plain text') ? textMap(text) : '';
|
|
86
|
+
return [
|
|
87
|
+
`[compacted: artifact ${id} · ${name} · ${len.toLocaleString('en-US')} chars · ${shape}]`,
|
|
88
|
+
...(map ? [map] : []),
|
|
89
|
+
text.slice(0, head),
|
|
90
|
+
`\n… ${elided.toLocaleString('en-US')} chars elided …\n`,
|
|
91
|
+
text.slice(len - tail),
|
|
92
|
+
`[Full output retained. Read it with ${EXPAND_CONTEXT}("${id}", grep=... or offset/limit).]`
|
|
93
|
+
].join('\n');
|
|
101
94
|
}
|
|
102
|
-
|
|
103
95
|
/** What one image is counted as, in characters of text (about 1.5k tokens). */
|
|
104
96
|
export const IMAGE_CHARS = 6000;
|
|
105
|
-
|
|
106
97
|
/** Rough size of a tool block, so a large MCP toolset isn't mistaken for history. */
|
|
107
98
|
export function estimateToolTokens(tools = []) {
|
|
108
|
-
|
|
99
|
+
return tools.length ? Math.ceil(JSON.stringify(tools).length / 4) : 0;
|
|
109
100
|
}
|
|
110
|
-
|
|
111
101
|
/** Rough size of the wire payload, for when a provider reports no usage at all. */
|
|
112
102
|
export function estimateTokens(wireMessages) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
103
|
+
let chars = 0;
|
|
104
|
+
for (const m of wireMessages) {
|
|
105
|
+
if (typeof m.content === 'string')
|
|
106
|
+
chars += m.content.length;
|
|
107
|
+
else if (Array.isArray(m.content)) {
|
|
108
|
+
for (const p of m.content) {
|
|
109
|
+
if (p.type === 'text')
|
|
110
|
+
chars += p.text.length;
|
|
111
|
+
// Base64 length says nothing about what an image costs; providers bill
|
|
112
|
+
// a tile-based figure in the low thousands of tokens.
|
|
113
|
+
else if (p.type === 'image_url')
|
|
114
|
+
chars += IMAGE_CHARS;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (m.tool_calls)
|
|
118
|
+
chars += JSON.stringify(m.tool_calls).length;
|
|
123
119
|
}
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
return Math.ceil(chars / 4);
|
|
120
|
+
return Math.ceil(chars / 4);
|
|
127
121
|
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Decides whether this turn should open a new epoch, and what to demote.
|
|
131
|
-
*
|
|
132
|
-
* Returns null when nothing should change -- which is the common case, and the
|
|
133
|
-
* case that keeps the prefix cached. Called once at the start of a turn, never
|
|
134
|
-
* mid-turn, so within a turn the prefix only ever grows.
|
|
135
|
-
*/
|
|
136
122
|
export function planEpoch(rows, { threshold, keepTurns, promptTokens }) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return { boundarySeq, targets };
|
|
123
|
+
if (!(promptTokens > threshold))
|
|
124
|
+
return null;
|
|
125
|
+
// Everything from the Nth-from-last user message onward stays verbatim: the
|
|
126
|
+
// model is most likely to still be working from the recent turns.
|
|
127
|
+
const userSeqs = rows.filter((r) => r.role === 'user').map((r) => r.seq);
|
|
128
|
+
if (userSeqs.length <= keepTurns)
|
|
129
|
+
return null;
|
|
130
|
+
const boundarySeq = userSeqs[userSeqs.length - keepTurns];
|
|
131
|
+
// Old images go the same way as old tool output: they are resent in full on
|
|
132
|
+
// every turn, and unlike text nothing ever shrinks them otherwise.
|
|
133
|
+
const targets = rows.filter((r) => r.seq < boundarySeq && ((r.role === 'tool' && r.artifact_id && !r.stub_text)
|
|
134
|
+
|| (r.images_json && !r.images_dropped)));
|
|
135
|
+
// No candidates means bumping the epoch would cost a cache miss for nothing.
|
|
136
|
+
if (!targets.length)
|
|
137
|
+
return null;
|
|
138
|
+
return { boundarySeq, targets };
|
|
155
139
|
}
|
|
156
|
-
|
|
157
140
|
/**
|
|
158
141
|
* Applies the plan: writes each stub back to the store and records the new
|
|
159
142
|
* boundary. After this returns, the prefix up to `boundarySeq` is frozen --
|
|
160
143
|
* stubs are persisted, so every later rebuild produces identical bytes.
|
|
161
144
|
*/
|
|
162
145
|
export function applyEpoch(store, chat, plan, { minSaved = 0 } = {}) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
146
|
+
// Stubs are worked out before anything is written: an epoch costs a full
|
|
147
|
+
// cache miss, so one that would only shave a few hundred characters is not
|
|
148
|
+
// worth opening, and the check has to happen before the store is touched.
|
|
149
|
+
const stubs = [];
|
|
150
|
+
let saved = 0;
|
|
151
|
+
const drops = [];
|
|
152
|
+
for (const row of plan.targets) {
|
|
153
|
+
if (row.role !== 'tool') {
|
|
154
|
+
drops.push(row.id);
|
|
155
|
+
saved += JSON.parse(row.images_json).length * IMAGE_CHARS;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const artifact = store.messages.getArtifact(row.artifact_id, chat.id);
|
|
159
|
+
if (!artifact)
|
|
160
|
+
continue;
|
|
161
|
+
const stub = digest(artifact);
|
|
162
|
+
if (stub.length >= (row.content?.length ?? 0))
|
|
163
|
+
continue;
|
|
164
|
+
stubs.push([row.id, stub]);
|
|
165
|
+
saved += (row.content?.length ?? 0) - stub.length;
|
|
174
166
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const stub
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
for (const id of drops) store.messages.dropImages(id);
|
|
185
|
-
const epoch = chat.epoch + 1;
|
|
186
|
-
store.chats.touch(chat.id, { epoch, boundary_seq: plan.boundarySeq });
|
|
187
|
-
return { epoch, boundarySeq: plan.boundarySeq, saved };
|
|
167
|
+
if (!saved || saved < minSaved)
|
|
168
|
+
return null;
|
|
169
|
+
for (const [id, stub] of stubs)
|
|
170
|
+
store.messages.setStub(id, stub);
|
|
171
|
+
for (const id of drops)
|
|
172
|
+
store.messages.dropImages(id);
|
|
173
|
+
const epoch = chat.epoch + 1;
|
|
174
|
+
store.chats.touch(chat.id, { epoch, boundary_seq: plan.boundarySeq });
|
|
175
|
+
return { epoch, boundarySeq: plan.boundarySeq, saved };
|
|
188
176
|
}
|
|
189
|
-
|
|
190
177
|
/**
|
|
191
178
|
* The hard window: what happens when stubbing is not enough.
|
|
192
179
|
*
|
|
@@ -203,40 +190,46 @@ export function applyEpoch(store, chat, plan, { minSaved = 0 } = {}) {
|
|
|
203
190
|
* `rows` must already be what the model would see (window applied, stubs in).
|
|
204
191
|
*/
|
|
205
192
|
export function planWindow(rows, { maxTokens, targetTokens, keepTurns, toWire }) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
193
|
+
if (!(maxTokens > 0))
|
|
194
|
+
return null;
|
|
195
|
+
const sizes = rows.map((r) => estimateTokens([toWire(r)]));
|
|
196
|
+
let total = sizes.reduce((a, b) => a + b, 0);
|
|
197
|
+
if (total <= maxTokens)
|
|
198
|
+
return null;
|
|
199
|
+
const userIdx = rows.map((r, i) => (r.role === 'user' ? i : -1)).filter((i) => i >= 0);
|
|
200
|
+
if (userIdx.length <= keepTurns)
|
|
201
|
+
return null;
|
|
202
|
+
const lastCut = userIdx[userIdx.length - keepTurns];
|
|
203
|
+
let cut = 0;
|
|
204
|
+
for (const i of userIdx) {
|
|
205
|
+
if (i === 0)
|
|
206
|
+
continue;
|
|
207
|
+
if (i > lastCut)
|
|
208
|
+
break;
|
|
209
|
+
for (let k = cut; k < i; k++)
|
|
210
|
+
total -= sizes[k];
|
|
211
|
+
cut = i;
|
|
212
|
+
if (total <= targetTokens)
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
return cut > 0 ? rows[cut].seq : null;
|
|
224
216
|
}
|
|
225
|
-
|
|
226
217
|
/**
|
|
227
218
|
* Applies a window to stored rows: drops what is before it and marks the
|
|
228
219
|
* first remaining message so the model knows the conversation did not start
|
|
229
220
|
* there. Pure and deterministic, so every rebuild sends the same bytes.
|
|
230
221
|
*/
|
|
231
222
|
export function windowRows(rows, windowSeq, { summarized = false } = {}) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
223
|
+
if (!(windowSeq >= 0))
|
|
224
|
+
return rows;
|
|
225
|
+
const kept = rows.filter((r) => r.seq >= windowSeq);
|
|
226
|
+
const dropped = rows.length - kept.length;
|
|
227
|
+
if (!dropped || !kept.length || kept[0].role !== 'user')
|
|
228
|
+
return kept;
|
|
229
|
+
const note = `[Earlier conversation omitted: ${dropped} message${dropped === 1 ? '' : 's'} before this`
|
|
230
|
+
+ ' point were removed to keep the conversation within the context window. '
|
|
231
|
+
+ (summarized
|
|
232
|
+
? 'They are summarised in the system prompt; if something you need is missing there, ask the user.]'
|
|
233
|
+
: 'If something from before is needed and is not restated here, ask the user.]');
|
|
234
|
+
return [{ ...kept[0], content: `${note}\n\n${kept[0].content ?? ''}` }, ...kept.slice(1)];
|
|
242
235
|
}
|