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/admin.js
CHANGED
|
@@ -1,149 +1,189 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { json, readJson, oneOf } from '../http.js';
|
|
2
3
|
import { ALL_USERS, toView } from '../store/index.js';
|
|
3
|
-
import { validateConfig, fingerprint, resolveAccess, FEATURES, CUSTOMIZABLE } from '../access/policy.js';
|
|
4
|
+
import { validateConfig, fingerprint, resolveAccess, FEATURES, CUSTOMIZABLE, ROLES, STATUSES } from '../access/policy.js';
|
|
4
5
|
import { catalog, modelProblems } from '../config/models.js';
|
|
5
|
-
import { destroyUserSessions } from '../access/auth.js';
|
|
6
|
+
import { destroyUserSessions, hashToken } from '../access/auth.js';
|
|
7
|
+
import { normalizeEmail, validEmail } from '../access/email.js';
|
|
6
8
|
import { audit } from '../audit.js';
|
|
7
9
|
import { FEATURE } from '../../public/shared/features.js';
|
|
8
|
-
|
|
9
10
|
function adminUserView(u) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
return {
|
|
12
|
+
id: u.id, email: u.email, name: u.name ?? null, role: u.role, status: u.status,
|
|
13
|
+
createdAt: u.created_at, lastLoginAt: u.last_login_at, chatCount: u.chat_count ?? null
|
|
14
|
+
};
|
|
14
15
|
}
|
|
15
|
-
|
|
16
16
|
/**
|
|
17
17
|
* User management and oversight. They only mean something when there is more
|
|
18
|
-
* than one person; '
|
|
18
|
+
* than one person; 'single' is just you, and gets admin_only.
|
|
19
19
|
*/
|
|
20
20
|
export function adminRoutes({ config, multiUser, refreshConfig, runs, source, store }) {
|
|
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
|
-
users
|
|
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
|
-
|
|
21
|
+
// Wraps a handler so it refuses outside multi-user deployments.
|
|
22
|
+
const multi = (handle) => (ctx) => (multiUser() ? handle(ctx) : json(ctx.res, 403, { error: 'admin_only' }));
|
|
23
|
+
return [
|
|
24
|
+
{ method: 'GET', path: /^\/api\/admin\/invites$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
|
|
25
|
+
return json(res, 200, { invites: store.users.listInvites().map((i) => ({
|
|
26
|
+
id: i.id, email: i.email, createdAt: i.created_at, expiresAt: i.expires_at
|
|
27
|
+
})) });
|
|
28
|
+
}) },
|
|
29
|
+
{ method: 'POST', path: /^\/api\/admin\/invites$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth }) => {
|
|
30
|
+
if (config().authMode !== 'multiuser')
|
|
31
|
+
return json(res, 400, { error: 'local invitations are only available in multiuser mode' });
|
|
32
|
+
const body = await readJson(req);
|
|
33
|
+
const email = normalizeEmail(body.email) || null;
|
|
34
|
+
if (email && !validEmail(email))
|
|
35
|
+
return json(res, 400, { error: 'enter a valid email address' });
|
|
36
|
+
if (email && store.users.emailTaken(email, ''))
|
|
37
|
+
return json(res, 409, { error: 'that email address already belongs to a user' });
|
|
38
|
+
const hours = body.expiresHours === undefined ? 168 : Number(body.expiresHours);
|
|
39
|
+
if (!(Number.isFinite(hours) && hours >= 1 && hours <= 720))
|
|
40
|
+
return json(res, 400, { error: 'expiry must be between 1 and 720 hours' });
|
|
41
|
+
const token = randomBytes(32).toString('hex');
|
|
42
|
+
const invite = store.users.createInvite({
|
|
43
|
+
id: randomUUID(), tokenHash: hashToken(token), email, createdBy: auth.user.id,
|
|
44
|
+
expiresAt: Date.now() + Math.round(hours * 3600_000)
|
|
45
|
+
});
|
|
46
|
+
audit('invite.created', { inviteId: invite.id, email, by: auth.user.id, expiresAt: invite.expires_at });
|
|
47
|
+
return json(res, 201, {
|
|
48
|
+
invite: { id: invite.id, email, createdAt: invite.created_at, expiresAt: invite.expires_at },
|
|
49
|
+
token, path: `/?invite=${token}`
|
|
50
|
+
});
|
|
51
|
+
}) },
|
|
52
|
+
{ method: 'DELETE', path: /^\/api\/admin\/invites\/([\w-]+)$/, feature: FEATURE.ADMIN, handle: multi(({ res, auth, params: [id] }) => {
|
|
53
|
+
if (!store.users.revokeInvite(id))
|
|
54
|
+
return json(res, 404, { error: 'no active invitation with that id' });
|
|
55
|
+
audit('invite.revoked', { inviteId: id, by: auth.user.id });
|
|
56
|
+
return json(res, 200, { ok: true });
|
|
57
|
+
}) },
|
|
58
|
+
{ method: 'GET', path: /^\/api\/admin\/users$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
|
|
59
|
+
const pins = resolveAccess(config());
|
|
60
|
+
const code = source.codeAccessUsers();
|
|
61
|
+
const pinnedBy = (u) => (pins.bootstrapAdmins.includes(u.external_id) ? 'bootstrapAdmins'
|
|
62
|
+
: code[u.external_id] ? 'access.users' : null);
|
|
63
|
+
return json(res, 200, {
|
|
64
|
+
fingerprint: fingerprint(config(), source.loadMcpServers()),
|
|
65
|
+
users: store.users.list().map((u) => ({ ...adminUserView(u), pinnedInCode: pinnedBy(u) }))
|
|
66
|
+
});
|
|
67
|
+
}) },
|
|
68
|
+
{ method: 'PATCH', path: /^\/api\/admin\/users\/([\w-]+)$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth, params: [id] }) => {
|
|
69
|
+
const target = store.users.get(id);
|
|
70
|
+
if (!target)
|
|
71
|
+
return json(res, 404, { error: 'no such user' });
|
|
72
|
+
const { role, status } = await readJson(req);
|
|
73
|
+
if (role !== undefined && !oneOf(role, ROLES))
|
|
74
|
+
return json(res, 400, { error: 'role must be admin or user' });
|
|
75
|
+
if (status !== undefined && !oneOf(status, STATUSES)) {
|
|
76
|
+
return json(res, 400, { error: 'status must be pending, approved or disabled' });
|
|
77
|
+
}
|
|
78
|
+
if (target.id === auth.userId && ((role && role !== 'admin') || (status && status !== 'approved'))) {
|
|
79
|
+
return json(res, 400, { error: 'you cannot demote or disable your own account' });
|
|
80
|
+
}
|
|
81
|
+
const pins = resolveAccess(config());
|
|
82
|
+
if (target.external_id && pins.bootstrapAdmins.includes(target.external_id)) {
|
|
83
|
+
return json(res, 400, { error: 'this admin is declared in code (access.bootstrapAdmins)' });
|
|
84
|
+
}
|
|
85
|
+
// The file is the record: the decision goes into config.json's
|
|
86
|
+
// access.users first, and only then into the database.
|
|
87
|
+
if (target.external_id) {
|
|
88
|
+
const file = source.readFile();
|
|
89
|
+
const access = file.access && typeof file.access === 'object' ? file.access : {};
|
|
90
|
+
const users = { ...(access.users || {}) };
|
|
91
|
+
users[target.external_id] = {
|
|
92
|
+
...(users[target.external_id] || {}),
|
|
93
|
+
...(role !== undefined ? { role } : {}),
|
|
94
|
+
...(status !== undefined ? { status } : {})
|
|
95
|
+
};
|
|
96
|
+
const merged = { ...file, access: { ...access, users } };
|
|
97
|
+
const problems = validateConfig({ ...config(), access: merged.access });
|
|
98
|
+
if (problems.length)
|
|
99
|
+
return json(res, 400, { error: problems.join('; ') });
|
|
100
|
+
if (source.codeAccessUsers()[target.external_id])
|
|
101
|
+
return json(res, 409, { error: 'this user is pinned in code (access.users)' });
|
|
102
|
+
source.writeFile(merged);
|
|
103
|
+
refreshConfig();
|
|
104
|
+
}
|
|
105
|
+
const updated = store.users.update(target.id, { role, status });
|
|
106
|
+
if (role !== undefined && role !== target.role) {
|
|
107
|
+
audit('user.role_changed', { userId: target.id, from: target.role, to: role, by: auth.userId });
|
|
108
|
+
}
|
|
109
|
+
if (status !== undefined && status !== target.status) {
|
|
110
|
+
audit('user.status_changed', { userId: target.id, from: target.status, to: status, by: auth.userId });
|
|
111
|
+
if (status !== 'approved')
|
|
112
|
+
destroyUserSessions(store, target.id);
|
|
113
|
+
}
|
|
114
|
+
return json(res, 200, { user: adminUserView(updated) });
|
|
115
|
+
}) },
|
|
116
|
+
// What the `user` role may do, what new users start as, and what users may
|
|
117
|
+
// personalise. The admin role is not editable here: it cannot be locked
|
|
118
|
+
// out of the panel that edits it. Written to the file like every decision.
|
|
119
|
+
{ method: 'GET', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
|
|
120
|
+
const a = resolveAccess(config());
|
|
121
|
+
return json(res, 200, {
|
|
122
|
+
features: FEATURES, customizable: CUSTOMIZABLE, models: catalog(config()).map((m) => m.id),
|
|
123
|
+
user: a.roles.user, newUsers: a.newUsers, customize: a.customize,
|
|
124
|
+
locked: source.lockedKeys().has('access')
|
|
125
|
+
});
|
|
126
|
+
}) },
|
|
127
|
+
{ method: 'POST', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth }) => {
|
|
128
|
+
if (source.isFrozen() || source.lockedKeys().has('access'))
|
|
129
|
+
return json(res, 409, { error: 'access is set in code or frozen: change the files instead' });
|
|
130
|
+
const { features, models, newUsers, customize } = await readJson(req);
|
|
131
|
+
const file = source.readFile();
|
|
132
|
+
const access = file.access && typeof file.access === 'object' ? file.access : {};
|
|
133
|
+
const user = { ...(access.roles?.user || {}) };
|
|
134
|
+
if (features !== undefined)
|
|
135
|
+
user.features = features;
|
|
136
|
+
if (models !== undefined)
|
|
137
|
+
user.models = models;
|
|
138
|
+
const merged = {
|
|
139
|
+
...access,
|
|
140
|
+
roles: { ...(access.roles || {}), user },
|
|
141
|
+
...(newUsers !== undefined ? { newUsers } : {}),
|
|
142
|
+
...(customize !== undefined ? { customize } : {})
|
|
143
|
+
};
|
|
144
|
+
const next = { ...file, access: merged };
|
|
145
|
+
const problems = [...validateConfig({ ...config(), access: merged }), ...modelProblems({ ...config(), access: merged })];
|
|
146
|
+
if (problems.length)
|
|
147
|
+
return json(res, 400, { error: problems.join('; ') });
|
|
148
|
+
source.writeFile(next);
|
|
149
|
+
refreshConfig();
|
|
150
|
+
audit('admin.policy_changed', { by: auth.userId, user, newUsers: merged.newUsers ?? null, customize: merged.customize ?? null });
|
|
151
|
+
const a = resolveAccess(config());
|
|
152
|
+
return json(res, 200, { user: a.roles.user, newUsers: a.newUsers, customize: a.customize });
|
|
153
|
+
}) },
|
|
154
|
+
// Oversight: read-only, every view audited. Reached through ALL_USERS on
|
|
155
|
+
// purpose -- this is the one place a user's scope is crossed, and it only
|
|
156
|
+
// exists when there are users to cross between.
|
|
157
|
+
{ method: 'GET', path: /^\/api\/admin\/users\/([\w-]+)\/chats$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
|
|
158
|
+
const target = store.users.get(id);
|
|
159
|
+
if (!target)
|
|
160
|
+
return json(res, 404, { error: 'no such user' });
|
|
161
|
+
audit('admin.view_user_chats', { by: auth.userId, userId: target.id });
|
|
162
|
+
const chats = store.chats.list(500, target.id).map((c) => ({ ...c, running: runs.isRunning(c.id) }));
|
|
163
|
+
// The figures the oversight header shows.
|
|
164
|
+
const { rounds, pricedRounds, reportedCost } = store.usage.statistics(target.id);
|
|
165
|
+
return json(res, 200, { user: adminUserView(target), chats, summary: { rounds, pricedRounds, reportedCost } });
|
|
166
|
+
}) },
|
|
167
|
+
{ method: 'GET', path: /^\/api\/admin\/chats\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
|
|
168
|
+
const found = store.chats.get(id, ALL_USERS);
|
|
169
|
+
if (!found)
|
|
170
|
+
return json(res, 404, { error: 'no such chat' });
|
|
171
|
+
audit('admin.view_chat', { by: auth.userId, chatId: found.id, owner: found.user_id });
|
|
172
|
+
const owner = found.user_id ? store.users.get(found.user_id) : null;
|
|
173
|
+
return json(res, 200, {
|
|
174
|
+
id: found.id, title: found.title, updatedAt: found.updated_at,
|
|
175
|
+
owner: owner ? adminUserView(owner) : null,
|
|
176
|
+
running: runs.isRunning(found.id),
|
|
177
|
+
messages: store.messages.list(found.id).map(toView),
|
|
178
|
+
documents: store.documents.list(found.id)
|
|
179
|
+
});
|
|
180
|
+
}) },
|
|
181
|
+
{ method: 'GET', path: /^\/api\/admin\/documents\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
|
|
182
|
+
const doc = store.documents.get(id, ALL_USERS);
|
|
183
|
+
if (!doc)
|
|
184
|
+
return json(res, 404, { error: 'no such document' });
|
|
185
|
+
audit('admin.view_document', { by: auth.userId, documentId: doc.id, chatId: doc.chat_id });
|
|
186
|
+
return json(res, 200, { filename: doc.filename, mime: doc.mime, content: doc.content });
|
|
187
|
+
}) }
|
|
188
|
+
];
|
|
149
189
|
}
|