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/access/policy.js
CHANGED
|
@@ -1,198 +1,185 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { FEATURE } from '../../public/shared/features.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Access policy: who can use which parts of TinyWebUI, declared in files.
|
|
6
|
-
*
|
|
7
|
-
* The `access` block lives in tinywebui.config.js and/or config.json:
|
|
8
|
-
*
|
|
9
|
-
* access: {
|
|
10
|
-
* bootstrapAdmins: ['<gateway user id>'], // always admin, always approved
|
|
11
|
-
* newUsers: 'approved' | 'pending',
|
|
12
|
-
* roles: { user: { features: [...], models: [...] | '*' }, admin: { features: '*' } },
|
|
13
|
-
* users: { '<gateway user id>': { role?, status? } } // admin decisions, written back
|
|
14
|
-
* }
|
|
15
|
-
*
|
|
16
|
-
* Everything here is pure: no I/O, so the server, the reload path and the CLI
|
|
17
|
-
* all validate and resolve the same way.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/** Every feature a role can be granted. Each maps to UI and API routes. */
|
|
21
3
|
export const FEATURES = Object.values(FEATURE);
|
|
22
|
-
|
|
23
4
|
/** What a signed-in user may personalise (access.customize). Model choice is the model-picker feature's. */
|
|
24
5
|
export const CUSTOMIZABLE = ['theme', 'instructions'];
|
|
25
|
-
|
|
26
6
|
export const ROLES = ['admin', 'user'];
|
|
27
7
|
export const STATUSES = ['pending', 'approved', 'disabled'];
|
|
28
|
-
|
|
29
8
|
// Tier 3's out-of-the-box split: users work, admins run the place.
|
|
30
9
|
const DEFAULT_ROLES = {
|
|
31
|
-
|
|
32
|
-
|
|
10
|
+
user: { features: [FEATURE.CHAT, FEATURE.ATTACHMENTS, FEATURE.IMAGES, FEATURE.SEARCH, FEATURE.FOLDERS, FEATURE.AUTOMATIONS, FEATURE.STATISTICS, FEATURE.MODEL_PICKER], models: '*' },
|
|
11
|
+
admin: { features: '*', models: '*' }
|
|
33
12
|
};
|
|
34
|
-
|
|
35
13
|
/**
|
|
36
14
|
* Keys that can never be written through the UI or API, whatever the lock
|
|
37
15
|
* state: the trust boundary, credentials, and the policy itself. They can
|
|
38
|
-
* still be set in either file
|
|
16
|
+
* still be set in either file.
|
|
39
17
|
*/
|
|
40
18
|
export const FILE_ONLY = new Set([
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
19
|
+
'authMode', 'authPassword', 'sessionSecret', 'sessionTtlDays', 'trustedProxyCidrs', 'trustedUserIdHeader',
|
|
20
|
+
'trustedEmailHeader', 'trustedNameHeader', 'trustedRoleHeader',
|
|
21
|
+
'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'allowedHosts', 'frozen', 'autoMigrate', 'retrieval',
|
|
22
|
+
'googleClientId', 'googleClientSecret', 'googleRedirectUri', 'adminEmails'
|
|
45
23
|
]);
|
|
46
|
-
|
|
47
24
|
/**
|
|
48
25
|
* Writable, but where credentials and routing live: in a multi-user
|
|
49
26
|
* deployment only an admin may send these, whatever features a role has.
|
|
50
27
|
*/
|
|
51
28
|
export const SECRET_PATCH_KEYS = new Set(['baseUrl', 'apiKey', 'models', 'connectors']);
|
|
52
|
-
|
|
53
29
|
// Never logged, never fingerprinted as values, never sent to a browser.
|
|
54
30
|
export const SECRET_KEYS = new Set(['apiKey', 'authPassword', 'sessionSecret', 'googleClientSecret']);
|
|
55
|
-
|
|
56
31
|
/**
|
|
57
32
|
* Merges the file's and the code's `access` blocks. Code wins per field;
|
|
58
33
|
* `users` merges per user, code winning, so admin write-back to the file can
|
|
59
34
|
* never override a user pinned in code.
|
|
60
35
|
*/
|
|
61
36
|
export function mergeAccess(fileAccess = {}, codeAccess = {}) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
37
|
+
const f = fileAccess && typeof fileAccess === 'object' ? fileAccess : {};
|
|
38
|
+
const c = codeAccess && typeof codeAccess === 'object' ? codeAccess : {};
|
|
39
|
+
return {
|
|
40
|
+
...f, ...c,
|
|
41
|
+
users: { ...(f.users || {}), ...(c.users || {}) }
|
|
42
|
+
};
|
|
68
43
|
}
|
|
69
|
-
|
|
70
44
|
/** The effective policy, with defaults filled in, for a loaded config. */
|
|
71
45
|
export function resolveAccess(cfg) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
46
|
+
const a = cfg.access || {};
|
|
47
|
+
const roles = {};
|
|
48
|
+
for (const r of ROLES)
|
|
49
|
+
roles[r] = { ...DEFAULT_ROLES[r], ...(a.roles?.[r] || {}) };
|
|
50
|
+
return {
|
|
51
|
+
bootstrapAdmins: Array.isArray(a.bootstrapAdmins) ? a.bootstrapAdmins.map(String) : [],
|
|
52
|
+
newUsers: a.newUsers || 'approved',
|
|
53
|
+
customize: Array.isArray(a.customize) ? a.customize : ['theme'],
|
|
54
|
+
roles,
|
|
55
|
+
users: a.users && typeof a.users === 'object' ? a.users : {}
|
|
56
|
+
};
|
|
82
57
|
}
|
|
83
|
-
|
|
84
58
|
const expand = (list) => new Set(list === '*' ? FEATURES : list || []);
|
|
85
|
-
|
|
86
59
|
/**
|
|
87
60
|
* What a signed-in person may use. Tiers 1 and 2 are one person, who gets
|
|
88
61
|
* everything that makes sense for one person: no user management.
|
|
89
62
|
*/
|
|
90
63
|
export function featuresFor(cfg, role) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
64
|
+
if (!['multiuser', 'trusted-header'].includes(cfg.authMode)) {
|
|
65
|
+
const all = new Set(FEATURES);
|
|
66
|
+
all.delete('admin');
|
|
67
|
+
all.delete('oversight');
|
|
68
|
+
return all;
|
|
69
|
+
}
|
|
70
|
+
return expand(role ? resolveAccess(cfg).roles[role]?.features : undefined);
|
|
97
71
|
}
|
|
98
|
-
|
|
99
72
|
/** '*' or the list of model ids this role may pick. */
|
|
100
73
|
export function modelsFor(cfg, role) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
74
|
+
if (!['multiuser', 'trusted-header'].includes(cfg.authMode))
|
|
75
|
+
return '*';
|
|
76
|
+
const m = role ? resolveAccess(cfg).roles[role]?.models : undefined;
|
|
77
|
+
return m === '*' || m === undefined ? '*' : m.map(String);
|
|
104
78
|
}
|
|
105
|
-
|
|
106
|
-
export const MIN_PASSWORD_LENGTH = 15;
|
|
107
|
-
|
|
79
|
+
export const MIN_PASSWORD_LENGTH = 10;
|
|
108
80
|
/** Every problem with a config, as readable strings. Empty means valid. */
|
|
81
|
+
// Takes the config as read, before anything about it is known to hold.
|
|
109
82
|
export function validateConfig(cfg, { env = process.env } = {}) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
if (mode === 'trusted-header' && !(Array.isArray(cfg.trustedProxyCidrs) && cfg.trustedProxyCidrs.length)) {
|
|
116
|
-
errors.push("authMode 'trusted-header' requires trustedProxyCidrs: identity headers are only believed from those peers");
|
|
117
|
-
}
|
|
118
|
-
if (mode === 'single') {
|
|
119
|
-
if (cfg.authPassword && !/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{64}$/i.test(String(cfg.authPassword))) {
|
|
120
|
-
errors.push('authPassword must be a hash: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD');
|
|
121
|
-
} else if (!cfg.authPassword && env.TINYWEBUI_PASSWORD && env.TINYWEBUI_PASSWORD.length < MIN_PASSWORD_LENGTH) {
|
|
122
|
-
errors.push(`TINYWEBUI_PASSWORD needs at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const a = cfg.access;
|
|
127
|
-
if (a !== undefined) {
|
|
128
|
-
if (!a || typeof a !== 'object' || Array.isArray(a)) return [...errors, 'access must be an object'];
|
|
129
|
-
const known = new Set(['bootstrapAdmins', 'newUsers', 'roles', 'users', 'customize']);
|
|
130
|
-
for (const k of Object.keys(a)) if (!known.has(k)) errors.push(`access.${k} is not a known setting`);
|
|
131
|
-
if (a.bootstrapAdmins !== undefined && !(Array.isArray(a.bootstrapAdmins) && a.bootstrapAdmins.every((x) => typeof x === 'string' && x))) {
|
|
132
|
-
errors.push('access.bootstrapAdmins must be a list of gateway user ids');
|
|
83
|
+
const errors = [];
|
|
84
|
+
const mode = cfg.authMode;
|
|
85
|
+
if (mode === 'trusted-header' && !(Array.isArray(cfg.trustedProxyCidrs) && cfg.trustedProxyCidrs.length)) {
|
|
86
|
+
errors.push("authMode 'trusted-header' requires trustedProxyCidrs: identity headers are only believed from those peers");
|
|
133
87
|
}
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
88
|
+
if (mode === 'single')
|
|
89
|
+
errors.push("legacy authMode 'single' is no longer supported; choose multiuser for accounts or explicitly choose local without login");
|
|
90
|
+
if (mode === 'local' && cfg.authPassword)
|
|
91
|
+
errors.push("authMode 'local' cannot use authPassword; remove it only if you want access without login");
|
|
92
|
+
if (env.TINYWEBUI_PASSWORD)
|
|
93
|
+
errors.push('TINYWEBUI_PASSWORD no longer selects password login; unset it and use multiuser setup');
|
|
94
|
+
const a = cfg.access;
|
|
95
|
+
if (a !== undefined) {
|
|
96
|
+
if (!a || typeof a !== 'object' || Array.isArray(a))
|
|
97
|
+
return [...errors, 'access must be an object'];
|
|
98
|
+
const known = new Set(['bootstrapAdmins', 'newUsers', 'roles', 'users', 'customize']);
|
|
99
|
+
for (const k of Object.keys(a))
|
|
100
|
+
if (!known.has(k))
|
|
101
|
+
errors.push(`access.${k} is not a known setting`);
|
|
102
|
+
if (a.bootstrapAdmins !== undefined && !(Array.isArray(a.bootstrapAdmins) && a.bootstrapAdmins.every((x) => typeof x === 'string' && x))) {
|
|
103
|
+
errors.push('access.bootstrapAdmins must be a list of gateway user ids');
|
|
104
|
+
}
|
|
105
|
+
if (a.newUsers !== undefined && !['approved', 'pending'].includes(a.newUsers)) {
|
|
106
|
+
errors.push("access.newUsers must be 'approved' or 'pending'");
|
|
107
|
+
}
|
|
108
|
+
if (a.customize !== undefined && !(Array.isArray(a.customize) && a.customize.every((c) => CUSTOMIZABLE.includes(c)))) {
|
|
109
|
+
errors.push(`access.customize must be a list from ${CUSTOMIZABLE.join(', ')}`);
|
|
110
|
+
}
|
|
111
|
+
for (const [role, spec] of Object.entries(a.roles || {})) {
|
|
112
|
+
if (!ROLES.includes(role)) {
|
|
113
|
+
errors.push(`access.roles.${role}: unknown role (known: ${ROLES.join(', ')})`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!spec || typeof spec !== 'object') {
|
|
117
|
+
errors.push(`access.roles.${role} must be an object`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const k of Object.keys(spec)) {
|
|
121
|
+
if (!['features', 'models'].includes(k))
|
|
122
|
+
errors.push(`access.roles.${role}.${k} is not a known setting`);
|
|
123
|
+
}
|
|
124
|
+
if (spec.features !== undefined && spec.features !== '*') {
|
|
125
|
+
if (!Array.isArray(spec.features))
|
|
126
|
+
errors.push(`access.roles.${role}.features must be '*' or a list`);
|
|
127
|
+
else
|
|
128
|
+
for (const f of spec.features) {
|
|
129
|
+
if (!FEATURES.includes(f))
|
|
130
|
+
errors.push(`access.roles.${role}.features: unknown feature "${f}" (known: ${FEATURES.join(', ')})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (spec.models !== undefined && spec.models !== '*' && !(Array.isArray(spec.models) && spec.models.every((m) => typeof m === 'string' && m))) {
|
|
134
|
+
errors.push(`access.roles.${role}.models must be '*' or a list of model ids (catalog ids when models is set)`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [id, u] of Object.entries(a.users || {})) {
|
|
138
|
+
if (!u || typeof u !== 'object') {
|
|
139
|
+
errors.push(`access.users["${id}"] must be an object`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (u.role !== undefined && !ROLES.includes(u.role))
|
|
143
|
+
errors.push(`access.users["${id}"].role must be one of ${ROLES.join(', ')}`);
|
|
144
|
+
if (u.status !== undefined && !STATUSES.includes(u.status))
|
|
145
|
+
errors.push(`access.users["${id}"].status must be one of ${STATUSES.join(', ')}`);
|
|
150
146
|
}
|
|
151
|
-
}
|
|
152
|
-
if (spec.models !== undefined && spec.models !== '*' && !(Array.isArray(spec.models) && spec.models.every((m) => typeof m === 'string' && m))) {
|
|
153
|
-
errors.push(`access.roles.${role}.models must be '*' or a list of model ids (catalog ids when models is set)`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
for (const [id, u] of Object.entries(a.users || {})) {
|
|
157
|
-
if (!u || typeof u !== 'object') { errors.push(`access.users["${id}"] must be an object`); continue; }
|
|
158
|
-
if (u.role !== undefined && !ROLES.includes(u.role)) errors.push(`access.users["${id}"].role must be one of ${ROLES.join(', ')}`);
|
|
159
|
-
if (u.status !== undefined && !STATUSES.includes(u.status)) errors.push(`access.users["${id}"].status must be one of ${STATUSES.join(', ')}`);
|
|
160
147
|
}
|
|
161
|
-
|
|
162
|
-
return errors;
|
|
148
|
+
return errors;
|
|
163
149
|
}
|
|
164
|
-
|
|
165
150
|
/** Stable JSON: object keys sorted, so key order never changes a hash. */
|
|
166
151
|
function canonical(value) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
152
|
+
if (Array.isArray(value))
|
|
153
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
154
|
+
if (value && typeof value === 'object') {
|
|
155
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
156
|
+
}
|
|
157
|
+
return JSON.stringify(value ?? null);
|
|
172
158
|
}
|
|
173
|
-
|
|
174
159
|
/**
|
|
175
160
|
* A short hash of the effective config and MCP servers. Secrets count only
|
|
176
161
|
* as present/absent (their values never enter the hash), so a fingerprint can
|
|
177
162
|
* be logged and compared against a checked-in config without leaking anything.
|
|
178
163
|
*/
|
|
179
164
|
export function fingerprint(cfg, mcpServers = {}) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
165
|
+
const redacted = {};
|
|
166
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
167
|
+
// The session secret is operational (it is generated on first start),
|
|
168
|
+
// not policy; leaving it out keeps the CLI and the server in agreement.
|
|
169
|
+
if (k === 'sessionSecret')
|
|
170
|
+
continue;
|
|
171
|
+
redacted[k] = SECRET_KEYS.has(k) ? Boolean(v) : v;
|
|
172
|
+
}
|
|
173
|
+
if (Array.isArray(redacted.connectors)) {
|
|
174
|
+
redacted.connectors = redacted.connectors.map((c) => (c && typeof c === 'object' ? { ...c, apiKey: Boolean(c.apiKey) } : c));
|
|
175
|
+
}
|
|
176
|
+
return createHash('sha256').update(canonical({ config: redacted, mcpServers })).digest('hex').slice(0, 16);
|
|
191
177
|
}
|
|
192
|
-
|
|
193
178
|
/** 'file-only' | 'frozen' | 'editable' -- how a key may be changed. */
|
|
194
179
|
export function keyClass(key, lockedKeys) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
180
|
+
if (FILE_ONLY.has(key))
|
|
181
|
+
return 'file-only';
|
|
182
|
+
if (lockedKeys.has(key))
|
|
183
|
+
return 'frozen';
|
|
184
|
+
return 'editable';
|
|
198
185
|
}
|
package/src/audit.js
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* log collector. Never pass content, prompts, tokens or raw header values.
|
|
4
4
|
*/
|
|
5
5
|
export function audit(event, fields = {}) {
|
|
6
|
-
|
|
6
|
+
console.log(`[tinywebui:audit] ${JSON.stringify({ ts: new Date().toISOString(), event, ...fields })}`);
|
|
7
7
|
}
|
|
@@ -1,136 +1,148 @@
|
|
|
1
1
|
import { CronExpressionParser } from 'cron-parser';
|
|
2
|
-
|
|
3
2
|
export function validateSchedule(cron, timezone, now = Date.now()) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
const tz = String(timezone || '').trim();
|
|
4
|
+
if (!tz)
|
|
5
|
+
throw new Error('timezone is required');
|
|
6
|
+
try {
|
|
7
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error(`invalid IANA timezone: ${tz}`);
|
|
11
|
+
}
|
|
12
|
+
let nextRunAt;
|
|
13
|
+
try {
|
|
14
|
+
nextRunAt = CronExpressionParser.parse(String(cron || ''), {
|
|
15
|
+
currentDate: new Date(now), tz
|
|
16
|
+
}).next().getTime();
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
throw new Error(`invalid cron schedule: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
return { cron: String(cron).trim(), timezone: tz, nextRunAt };
|
|
17
22
|
}
|
|
18
|
-
|
|
19
23
|
function cleanFields(input, { requireAll = false } = {}) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
const fields = {};
|
|
25
|
+
for (const key of ['name', 'prompt']) {
|
|
26
|
+
if (input[key] === undefined && !requireAll)
|
|
27
|
+
continue;
|
|
28
|
+
const val = String(input[key] ?? '').trim();
|
|
29
|
+
if (!val)
|
|
30
|
+
throw new Error(`${key} is required`);
|
|
31
|
+
if (val.length > (key === 'prompt' ? 12000 : 120))
|
|
32
|
+
throw new Error(`${key} is too long`);
|
|
33
|
+
fields[key] = val;
|
|
34
|
+
}
|
|
35
|
+
if (requireAll || (input.cron !== undefined && input.timezone !== undefined)) {
|
|
36
|
+
const cron = input.cron ?? '';
|
|
37
|
+
const timezone = input.timezone ?? '';
|
|
38
|
+
Object.assign(fields, validateSchedule(cron, timezone));
|
|
39
|
+
}
|
|
40
|
+
if (input.enabled !== undefined)
|
|
41
|
+
fields.enabled = Boolean(input.enabled);
|
|
42
|
+
return fields;
|
|
35
43
|
}
|
|
36
|
-
|
|
37
44
|
export function automationToolDef() {
|
|
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
|
-
|
|
45
|
+
return {
|
|
46
|
+
type: 'function',
|
|
47
|
+
function: {
|
|
48
|
+
name: 'manage_automation',
|
|
49
|
+
description: [
|
|
50
|
+
'Manage scheduled automations: a prompt that runs on its own, on a cron schedule, in this chat.',
|
|
51
|
+
'Use it when the user wants something done repeatedly or later without them ("every morning',
|
|
52
|
+
'summarise...", "check X each Friday"). For steps of the current request use manage_tasks instead.',
|
|
53
|
+
'',
|
|
54
|
+
'Each run starts fresh: it sees only its own prompt and the previous run\'s result, not this',
|
|
55
|
+
'conversation, and nobody is there to answer questions. Write `prompt` so it stands alone:',
|
|
56
|
+
'what to do, where, and what to report.',
|
|
57
|
+
'',
|
|
58
|
+
'Actions and what each needs:',
|
|
59
|
+
'- list: nothing. Returns every automation with its id, schedule and last result.',
|
|
60
|
+
'- create: name, prompt, cron, timezone. Starts running on schedule immediately.',
|
|
61
|
+
'- update: id, plus only the fields to change. cron and timezone are checked together.',
|
|
62
|
+
'- delete: id. Permanent.',
|
|
63
|
+
'- trigger: id. Runs it once now; queued if the chat is busy.',
|
|
64
|
+
'',
|
|
65
|
+
'An automation keeps running until someone stops it, so before create or delete, make sure',
|
|
66
|
+
'the user asked for exactly that (schedule, timezone, what it does). If any of those is unclear,',
|
|
67
|
+
'ask first. Pausing is update with enabled=false.'
|
|
68
|
+
].join('\n'),
|
|
69
|
+
parameters: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
action: { type: 'string', enum: ['list', 'create', 'update', 'delete', 'trigger'] },
|
|
73
|
+
id: { type: 'string', description: 'The automation\'s id, from list or create. For update, delete and trigger.' },
|
|
74
|
+
name: { type: 'string', description: 'Short label shown to the user, e.g. "Morning inbox summary". Up to 120 characters.' },
|
|
75
|
+
prompt: { type: 'string', description: 'The instruction each run carries out, written to stand alone (see above). Up to 12000 characters.' },
|
|
76
|
+
cron: { type: 'string', description: 'Five fields: minute hour day-of-month month day-of-week. "0 9 * * 1-5" is 09:00 on weekdays; "*/30 * * * *" is every 30 minutes.' },
|
|
77
|
+
timezone: { type: 'string', description: 'IANA timezone the cron is read in, e.g. "Europe/Vienna". Required with cron; use the user\'s timezone, and ask if you do not know it.' },
|
|
78
|
+
enabled: { type: 'boolean', description: 'false pauses the automation without deleting it; true resumes it. New automations are enabled.' }
|
|
79
|
+
},
|
|
80
|
+
required: ['action']
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
77
84
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (action === 'delete') return store.automations.delete(args.id, userId)
|
|
99
|
-
? `Deleted automation ${args.id}.` : 'Error: no such automation';
|
|
100
|
-
if (action === 'trigger') {
|
|
101
|
-
const automation = store.automations.get(args.id, userId);
|
|
102
|
-
if (!automation) throw new Error('no such automation');
|
|
103
|
-
if (!triggerAutomation) throw new Error('manual triggering is unavailable');
|
|
104
|
-
return JSON.stringify(await triggerAutomation(automation, userId));
|
|
105
|
-
}
|
|
106
|
-
if (action === 'update') {
|
|
107
|
-
const current = store.automations.get(args.id, userId);
|
|
108
|
-
if (!current) throw new Error('no such automation');
|
|
109
|
-
const fields = cleanFields(args);
|
|
110
|
-
if (args.cron !== undefined || args.timezone !== undefined) {
|
|
111
|
-
Object.assign(fields, validateSchedule(fields.cron ?? current.cron, fields.timezone ?? current.timezone));
|
|
85
|
+
export async function manageAutomation(args, { store, chatId, triggerAutomation, unattended = false }) {
|
|
86
|
+
const action = String(args?.action || '');
|
|
87
|
+
// A scheduled run has nobody watching it. Letting it start more runs is how
|
|
88
|
+
// one trigger becomes an endless self-requeueing loop, and how text injected
|
|
89
|
+
// through a tool result turns into a standing schedule. Adjusting or removing
|
|
90
|
+
// an existing automation stays allowed -- "stop once X is done" is legitimate.
|
|
91
|
+
if (unattended && (action === 'create' || action === 'trigger')) {
|
|
92
|
+
return `Error: "${action}" is not available inside a scheduled run. Tell the user in your result if a new automation or run is needed.`;
|
|
93
|
+
}
|
|
94
|
+
const chat = chatId ? store.chats.byId(chatId) : null;
|
|
95
|
+
const userId = chat?.user_id ?? null;
|
|
96
|
+
if (action === 'list')
|
|
97
|
+
return JSON.stringify(store.automations.list(userId));
|
|
98
|
+
if (action === 'create') {
|
|
99
|
+
if (!chat)
|
|
100
|
+
throw new Error('automation creation requires a current chat');
|
|
101
|
+
const fields = cleanFields(args, { requireAll: true });
|
|
102
|
+
// requireAll: cleanFields has filled every field create needs, or thrown.
|
|
103
|
+
const row = store.automations.create({ ...fields, chatId, source: 'model' }, userId);
|
|
104
|
+
return JSON.stringify(row);
|
|
112
105
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
106
|
+
if (!args?.id)
|
|
107
|
+
throw new Error('id is required');
|
|
108
|
+
if (action === 'delete')
|
|
109
|
+
return store.automations.delete(args.id, userId)
|
|
110
|
+
? `Deleted automation ${args.id}.` : 'Error: no such automation';
|
|
111
|
+
if (action === 'trigger') {
|
|
112
|
+
const automation = store.automations.get(args.id, userId);
|
|
113
|
+
if (!automation)
|
|
114
|
+
throw new Error('no such automation');
|
|
115
|
+
if (!triggerAutomation)
|
|
116
|
+
throw new Error('manual triggering is unavailable');
|
|
117
|
+
return JSON.stringify(await triggerAutomation(automation, userId));
|
|
118
|
+
}
|
|
119
|
+
if (action === 'update') {
|
|
120
|
+
const current = store.automations.get(args.id, userId);
|
|
121
|
+
if (!current)
|
|
122
|
+
throw new Error('no such automation');
|
|
123
|
+
const fields = cleanFields(args);
|
|
124
|
+
if (args.cron !== undefined || args.timezone !== undefined) {
|
|
125
|
+
Object.assign(fields, validateSchedule(fields.cron ?? current.cron, fields.timezone ?? current.timezone));
|
|
126
|
+
}
|
|
127
|
+
const row = store.automations.update(args.id, fields, userId);
|
|
128
|
+
return row ? JSON.stringify(row) : 'Error: no such automation';
|
|
129
|
+
}
|
|
130
|
+
throw new Error('action must be list, create, update, delete, or trigger');
|
|
117
131
|
}
|
|
118
|
-
|
|
119
132
|
export function nextSchedule(cron, timezone, now = Date.now()) {
|
|
120
|
-
|
|
133
|
+
return validateSchedule(cron, timezone, now).nextRunAt;
|
|
121
134
|
}
|
|
122
|
-
|
|
123
135
|
export function runMessage(automation) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
const previous = automation.lastResult
|
|
137
|
+
? `\n\nPrevious run result (truncated):\n${automation.lastResult.slice(0, 2000)}` : '';
|
|
138
|
+
return [
|
|
139
|
+
`[Scheduled automation: ${automation.name}]`,
|
|
140
|
+
`Schedule: ${automation.cron} (${automation.timezone})`,
|
|
141
|
+
// Without this the model reads an ordinary user turn and may stop to ask a
|
|
142
|
+
// clarifying question that nobody will ever answer.
|
|
143
|
+
'This is an unattended run: no one is present to answer questions. Complete the task'
|
|
144
|
+
+ ' with sensible assumptions and state them, or report exactly what blocked it.',
|
|
145
|
+
'',
|
|
146
|
+
automation.prompt
|
|
147
|
+
].join('\n') + previous;
|
|
136
148
|
}
|