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/auth.js
CHANGED
|
@@ -1,120 +1,193 @@
|
|
|
1
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
2
1
|
import { json, readJson } from '../http.js';
|
|
3
2
|
import { isClosed, findEntry, labelFor } from '../config/models.js';
|
|
4
|
-
import { modelsFor, fingerprint, resolveAccess } from '../access/policy.js';
|
|
5
|
-
import {
|
|
6
|
-
verifyPassword, createSession, destroySession, sessionToken, sessionCookie, clearCookie, isSecureRequest
|
|
7
|
-
} from '../access/auth.js';
|
|
3
|
+
import { modelsFor, fingerprint, resolveAccess, MIN_PASSWORD_LENGTH } from '../access/policy.js';
|
|
4
|
+
import { hashPassword, hashToken, verifyPassword, createSession, destroySession, sessionToken, sessionCookie, clearCookie, isSecureRequest } from '../access/auth.js';
|
|
8
5
|
import { audit } from '../audit.js';
|
|
9
6
|
import { OWNER_ID } from '../access/auth_gate.js';
|
|
7
|
+
import { normalizeEmail, validEmail } from '../access/email.js';
|
|
10
8
|
import { FEATURE } from '../../public/shared/features.js';
|
|
11
|
-
|
|
12
9
|
// Failed logins per client address: 5 misses locks that address out for 15 minutes.
|
|
13
10
|
const LOGIN_MAX = 5;
|
|
14
11
|
const LOGIN_WINDOW_MS = 15 * 60_000;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
12
|
+
const DUMMY_PASSWORD_HASH = hashPassword('tinywebui timing placeholder');
|
|
13
|
+
function localAccount(body) {
|
|
14
|
+
const email = normalizeEmail(body.email);
|
|
15
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
16
|
+
if (!validEmail(email))
|
|
17
|
+
return { error: 'enter a valid email address' };
|
|
18
|
+
if (password.length < MIN_PASSWORD_LENGTH)
|
|
19
|
+
return { error: `use at least ${MIN_PASSWORD_LENGTH} characters` };
|
|
20
|
+
if (password.length > 1024)
|
|
21
|
+
return { error: 'password is too long' };
|
|
22
|
+
return { email, password };
|
|
23
|
+
}
|
|
24
|
+
export function authRoutes({ config, modelFor, multiUser, source, store, version }) {
|
|
25
|
+
const loginFailures = new Map();
|
|
26
|
+
return [
|
|
27
|
+
{ method: 'GET', path: /^\/api\/auth\/me$/, feature: null, handle: ({ res, auth }) => {
|
|
28
|
+
const cfg = config();
|
|
29
|
+
const signedIn = auth.userId !== undefined;
|
|
30
|
+
const model = signedIn ? modelFor(auth.user?.id, auth.role) : null;
|
|
31
|
+
return json(res, 200, {
|
|
32
|
+
authMode: cfg.authMode,
|
|
33
|
+
needsSetup: cfg.authMode === 'multiuser' && store.users.localCount() === 0,
|
|
34
|
+
logoutUrl: cfg.logoutUrl || '',
|
|
35
|
+
isAdmin: Boolean(auth.isAdmin),
|
|
36
|
+
// What this person may personalise, and their saved instructions (multi-user only).
|
|
37
|
+
customize: multiUser() && auth.user ? resolveAccess(cfg).customize : [],
|
|
38
|
+
instructions: multiUser() && auth.user ? store.users.getPrefs(auth.user.id).instructions || '' : '',
|
|
39
|
+
features: [...auth.features],
|
|
40
|
+
models: signedIn ? modelsFor(cfg, auth.role) : [],
|
|
41
|
+
model,
|
|
42
|
+
modelLabel: signedIn ? labelFor(cfg, model) : null,
|
|
43
|
+
// What the catalog says this model can do; empty when it says nothing.
|
|
44
|
+
modelTags: signedIn ? findEntry(cfg, model)?.tags || [] : [],
|
|
45
|
+
user: auth.user
|
|
46
|
+
? { id: auth.user.id, email: auth.user.email, name: auth.user.name ?? null, role: auth.user.role, status: auth.user.status }
|
|
47
|
+
: null
|
|
48
|
+
});
|
|
49
|
+
} },
|
|
50
|
+
{ method: 'POST', path: /^\/api\/auth\/login$/, feature: null, handle: async ({ req, res }) => {
|
|
51
|
+
const cfg = config();
|
|
52
|
+
if (cfg.authMode !== 'multiuser')
|
|
53
|
+
return json(res, 404, { error: 'not found' });
|
|
54
|
+
const who = req.socket.remoteAddress || '?';
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const f = loginFailures.get(who);
|
|
57
|
+
if (f && now - f.first > LOGIN_WINDOW_MS)
|
|
58
|
+
loginFailures.delete(who);
|
|
59
|
+
const entry = loginFailures.get(who);
|
|
60
|
+
if (entry && entry.count >= LOGIN_MAX) {
|
|
61
|
+
return json(res, 429, { error: 'too many attempts, try again later' });
|
|
62
|
+
}
|
|
63
|
+
const body = await readJson(req);
|
|
64
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
65
|
+
const user = store.users.byEmail(normalizeEmail(body.email));
|
|
66
|
+
const stored = user?.password_hash || DUMMY_PASSWORD_HASH;
|
|
67
|
+
if (!verifyPassword(password, stored) || !user) {
|
|
68
|
+
loginFailures.set(who, { first: entry?.first ?? now, count: (entry?.count ?? 0) + 1 });
|
|
69
|
+
audit('auth.rejected', { reason: 'bad_password' });
|
|
70
|
+
return json(res, 401, { error: 'wrong email or password' });
|
|
71
|
+
}
|
|
72
|
+
if (user.status !== 'approved')
|
|
73
|
+
return json(res, 403, { error: user.status === 'disabled' ? 'account disabled' : 'account pending approval' });
|
|
74
|
+
loginFailures.delete(who);
|
|
75
|
+
store.users.sync(user.id, { last_login_at: now });
|
|
76
|
+
const token = createSession(store, user.id, cfg.sessionTtlDays);
|
|
77
|
+
audit('auth.login', { userId: user.id });
|
|
78
|
+
res.setHeader('set-cookie', sessionCookie(token, cfg.sessionSecret, { secure: isSecureRequest(req, cfg.trustedProxyCidrs) }));
|
|
79
|
+
return json(res, 200, { ok: true });
|
|
80
|
+
} },
|
|
81
|
+
{ method: 'POST', path: /^\/api\/auth\/setup$/, feature: null, handle: async ({ req, res }) => {
|
|
82
|
+
const cfg = config();
|
|
83
|
+
if (cfg.authMode !== 'multiuser')
|
|
84
|
+
return json(res, 404, { error: 'not found' });
|
|
85
|
+
if (store.users.localCount() !== 0)
|
|
86
|
+
return json(res, 409, { error: 'this installation already has an administrator' });
|
|
87
|
+
const account = localAccount(await readJson(req));
|
|
88
|
+
if (account.error !== undefined)
|
|
89
|
+
return json(res, 400, { error: account.error });
|
|
90
|
+
let user;
|
|
91
|
+
try {
|
|
92
|
+
user = store.users.createFirstLocal({ ...account, passwordHash: hashPassword(account.password) });
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
return json(res, 409, { error: 'that email address is already in use' });
|
|
96
|
+
}
|
|
97
|
+
if (!user)
|
|
98
|
+
return json(res, 409, { error: 'this installation already has an administrator' });
|
|
99
|
+
store.claimUnowned(user.id, { removeUserId: OWNER_ID });
|
|
100
|
+
const token = createSession(store, user.id, cfg.sessionTtlDays);
|
|
101
|
+
audit('user.provisioned', { userId: user.id, role: 'admin', status: 'approved', source: 'first-account' });
|
|
102
|
+
audit('auth.login', { userId: user.id });
|
|
103
|
+
res.setHeader('set-cookie', sessionCookie(token, cfg.sessionSecret, { secure: isSecureRequest(req, cfg.trustedProxyCidrs) }));
|
|
104
|
+
return json(res, 201, { ok: true });
|
|
105
|
+
} },
|
|
106
|
+
{ method: 'POST', path: /^\/api\/auth\/accept-invite$/, feature: null, handle: async ({ req, res }) => {
|
|
107
|
+
const cfg = config();
|
|
108
|
+
if (cfg.authMode !== 'multiuser')
|
|
109
|
+
return json(res, 404, { error: 'not found' });
|
|
110
|
+
const body = await readJson(req);
|
|
111
|
+
const account = localAccount(body);
|
|
112
|
+
if (account.error !== undefined)
|
|
113
|
+
return json(res, 400, { error: account.error });
|
|
114
|
+
const inviteToken = typeof body.token === 'string' ? body.token.trim() : '';
|
|
115
|
+
if (!/^[0-9a-f]{64}$/i.test(inviteToken))
|
|
116
|
+
return json(res, 400, { error: 'invalid or expired invitation' });
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
result = store.users.acceptInvite({
|
|
120
|
+
tokenHash: hashToken(inviteToken), email: account.email,
|
|
121
|
+
passwordHash: hashPassword(account.password)
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
return json(res, 409, { error: 'that email address is already in use' });
|
|
126
|
+
}
|
|
127
|
+
if (result.error === 'email_mismatch')
|
|
128
|
+
return json(res, 400, { error: 'this invitation is for a different email address' });
|
|
129
|
+
if (result.error === 'email_taken')
|
|
130
|
+
return json(res, 409, { error: 'that email address is already in use' });
|
|
131
|
+
if (result.error)
|
|
132
|
+
return json(res, 400, { error: 'invalid or expired invitation' });
|
|
133
|
+
const token = createSession(store, result.user.id, cfg.sessionTtlDays);
|
|
134
|
+
audit('user.provisioned', { userId: result.user.id, role: 'user', status: 'approved', source: 'invitation' });
|
|
135
|
+
audit('auth.login', { userId: result.user.id });
|
|
136
|
+
res.setHeader('set-cookie', sessionCookie(token, cfg.sessionSecret, { secure: isSecureRequest(req, cfg.trustedProxyCidrs) }));
|
|
137
|
+
return json(res, 201, { ok: true });
|
|
138
|
+
} },
|
|
139
|
+
{ method: 'POST', path: /^\/api\/auth\/logout$/, feature: null, handle: ({ req, res }) => {
|
|
140
|
+
const cfg = config();
|
|
141
|
+
// The gateway owns the session; the client is sent to its logout page.
|
|
142
|
+
if (cfg.authMode === 'trusted-header')
|
|
143
|
+
return json(res, 200, { redirect: cfg.logoutUrl || null });
|
|
144
|
+
if (cfg.authMode !== 'multiuser')
|
|
145
|
+
return json(res, 404, { error: 'not found' });
|
|
146
|
+
const token = sessionToken(req, cfg);
|
|
147
|
+
if (token)
|
|
148
|
+
destroySession(store, token);
|
|
149
|
+
res.setHeader('set-cookie', clearCookie({ secure: isSecureRequest(req, cfg.trustedProxyCidrs) }));
|
|
150
|
+
return json(res, 200, { ok: true });
|
|
151
|
+
} },
|
|
152
|
+
// Tier 3's model pill: a personal choice among the role's models. In
|
|
153
|
+
// tiers 1-2 the pill changes the configured model instead (/api/config).
|
|
154
|
+
{ method: 'POST', path: /^\/api\/me\/prefs$/, feature: null, handle: async ({ req, res, auth }) => {
|
|
155
|
+
const cfg = config();
|
|
156
|
+
if (!multiUser())
|
|
157
|
+
return json(res, 400, { error: 'preferences are per-user; set the model in settings' });
|
|
158
|
+
// With accounts on, the auth gate answers 401 before this for anyone not signed in.
|
|
159
|
+
const user = auth.user;
|
|
160
|
+
const { model, instructions } = await readJson(req);
|
|
161
|
+
if (instructions !== undefined) {
|
|
162
|
+
if (!resolveAccess(cfg).customize.includes('instructions'))
|
|
163
|
+
return json(res, 403, { error: 'your administrator has not enabled personal instructions' });
|
|
164
|
+
if (instructions !== null && (typeof instructions !== 'string' || instructions.length > 4000))
|
|
165
|
+
return json(res, 400, { error: 'instructions must be text of at most 4000 characters, or null' });
|
|
166
|
+
store.users.setPref(user.id, 'instructions', instructions && instructions.trim() ? instructions.trim() : null);
|
|
167
|
+
if (model === undefined)
|
|
168
|
+
return json(res, 200, { instructions: instructions && instructions.trim() ? instructions.trim() : '' });
|
|
169
|
+
}
|
|
170
|
+
if (!auth.features.has(FEATURE.MODEL_PICKER))
|
|
171
|
+
return json(res, 403, { error: 'feature not available to your role', feature: FEATURE.MODEL_PICKER });
|
|
172
|
+
if (model !== null && (typeof model !== 'string' || !model.trim()))
|
|
173
|
+
return json(res, 400, { error: 'model must be a model id or null' });
|
|
174
|
+
const allowed = modelsFor(cfg, auth.role);
|
|
175
|
+
if (model && isClosed(cfg) && !findEntry(cfg, model))
|
|
176
|
+
return json(res, 403, { error: 'that model is not available to you' });
|
|
177
|
+
if (model && allowed !== '*' && !allowed.includes(model))
|
|
178
|
+
return json(res, 403, { error: 'that model is not available to you' });
|
|
179
|
+
store.users.setPref(user.id, 'model', model ? model.trim() : null);
|
|
180
|
+
return json(res, 200, { model: modelFor(user.id, auth.role) });
|
|
181
|
+
} },
|
|
182
|
+
// Non-secret facts about this deployment, for scripts verifying what is
|
|
183
|
+
// running: build, config hash, database schema, auth, MCP server names.
|
|
184
|
+
{ method: 'GET', path: /^\/api\/meta$/, feature: null, handle: ({ res }) => json(res, 200, {
|
|
185
|
+
version: version,
|
|
186
|
+
schemaVersion: store.schemaVersion(),
|
|
187
|
+
fingerprint: fingerprint(config(), source.loadMcpServers()),
|
|
188
|
+
configMode: source.isFrozen() ? 'frozen' : 'editable',
|
|
189
|
+
authMode: config().authMode,
|
|
190
|
+
mcpServers: Object.keys(source.loadMcpServers()).sort()
|
|
191
|
+
}) }
|
|
192
|
+
];
|
|
120
193
|
}
|
|
@@ -1,92 +1,109 @@
|
|
|
1
1
|
import { json, readJson } from '../http.js';
|
|
2
2
|
import { validateSchedule } from '../automations/automation.js';
|
|
3
3
|
import { FEATURE } from '../../public/shared/features.js';
|
|
4
|
-
|
|
5
4
|
/** `newChatTitle` on an automation request: undefined when absent, false when
|
|
6
5
|
* present but unusable, otherwise the trimmed title. */
|
|
7
6
|
function newChatTitleOf(body) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
if (body.newChatTitle === undefined || body.newChatTitle === null)
|
|
8
|
+
return undefined;
|
|
9
|
+
const title = String(body.newChatTitle).trim();
|
|
10
|
+
return title && title.length <= 120 ? title : false;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
12
|
const BAD_TITLE = { error: 'the new chat needs a name (at most 120 characters)' };
|
|
14
|
-
|
|
15
13
|
export function automationRoutes({ scheduler, store }) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
14
|
+
const F = FEATURE.AUTOMATIONS;
|
|
15
|
+
return [
|
|
16
|
+
{ method: 'GET', path: /^\/api\/automations$/, feature: F, handle: ({ res, auth }) => json(res, 200, { automations: store.automations.list(auth.userId), chats: store.chats.list(200, auth.userId) }) },
|
|
17
|
+
{ method: 'POST', path: /^\/api\/automations$/, feature: F, handle: async ({ req, res, auth }) => {
|
|
18
|
+
const body = await readJson(req);
|
|
19
|
+
const newChatTitle = newChatTitleOf(body);
|
|
20
|
+
if (newChatTitle === false)
|
|
21
|
+
return json(res, 400, BAD_TITLE);
|
|
22
|
+
let chat = newChatTitle ? null : store.chats.get(String(body.chatId || ''), auth.userId);
|
|
23
|
+
if (!newChatTitle && !chat)
|
|
24
|
+
return json(res, 400, { error: 'a chat you own is required' });
|
|
25
|
+
const name = String(body.name || '').trim();
|
|
26
|
+
const prompt = String(body.prompt || '').trim();
|
|
27
|
+
if (!name || !prompt)
|
|
28
|
+
return json(res, 400, { error: 'name and prompt are required' });
|
|
29
|
+
if (name.length > 120 || prompt.length > 12000)
|
|
30
|
+
return json(res, 400, { error: 'name or prompt is too long' });
|
|
31
|
+
let schedule;
|
|
32
|
+
try {
|
|
33
|
+
schedule = validateSchedule(body.cron, body.timezone);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return json(res, 400, { error: err.message });
|
|
37
|
+
}
|
|
38
|
+
if (newChatTitle)
|
|
39
|
+
chat = store.chats.create({ title: newChatTitle }, auth.userId);
|
|
40
|
+
const automation = store.automations.create({ ...schedule, chatId: chat.id, name, prompt, enabled: body.enabled !== false, source: 'user' }, auth.userId);
|
|
41
|
+
scheduler.arm();
|
|
42
|
+
return json(res, 201, { automation });
|
|
43
|
+
} },
|
|
44
|
+
{ method: 'GET', path: /^\/api\/automations\/([\w.-]+)\/runs$/, feature: F, handle: ({ res, auth, params: [id] }) => {
|
|
45
|
+
const runs = store.automations.listRuns(id, auth.userId);
|
|
46
|
+
return runs ? json(res, 200, { runs }) : json(res, 404, { error: 'no such automation' });
|
|
47
|
+
} },
|
|
48
|
+
{ method: 'POST', path: /^\/api\/automations\/([\w.-]+)\/trigger$/, feature: F, handle: async ({ res, auth, params: [id] }) => {
|
|
49
|
+
const automation = store.automations.get(id, auth.userId);
|
|
50
|
+
if (!automation)
|
|
51
|
+
return json(res, 404, { error: 'no such automation' });
|
|
52
|
+
const run = await scheduler.trigger(automation, auth.userId);
|
|
53
|
+
return json(res, 202, { run });
|
|
54
|
+
} },
|
|
55
|
+
{ method: 'PATCH', path: /^\/api\/automations\/([\w.-]+)$/, feature: F, handle: async ({ req, res, auth, params: [id] }) => {
|
|
56
|
+
const existing = store.automations.get(id, auth.userId);
|
|
57
|
+
if (!existing)
|
|
58
|
+
return json(res, 404, { error: 'no such automation' });
|
|
59
|
+
const body = await readJson(req);
|
|
60
|
+
// Raw from the body until each field is checked below.
|
|
61
|
+
const patch = {};
|
|
62
|
+
for (const key of ['name', 'prompt', 'cron', 'timezone', 'enabled', 'chatId'])
|
|
63
|
+
if (body[key] !== undefined)
|
|
64
|
+
patch[key] = body[key];
|
|
65
|
+
const newChatTitle = newChatTitleOf(body);
|
|
66
|
+
if (newChatTitle === false)
|
|
67
|
+
return json(res, 400, BAD_TITLE);
|
|
68
|
+
if (newChatTitle)
|
|
69
|
+
delete patch.chatId;
|
|
70
|
+
if (patch.chatId !== undefined) {
|
|
71
|
+
const chat = store.chats.get(String(patch.chatId), auth.userId);
|
|
72
|
+
if (!chat)
|
|
73
|
+
return json(res, 400, { error: 'a chat you own is required' });
|
|
74
|
+
patch.chatId = chat.id;
|
|
75
|
+
}
|
|
76
|
+
if (patch.name !== undefined) {
|
|
77
|
+
patch.name = String(patch.name).trim();
|
|
78
|
+
if (!patch.name || patch.name.length > 120)
|
|
79
|
+
return json(res, 400, { error: 'name is required and must be at most 120 characters' });
|
|
80
|
+
}
|
|
81
|
+
if (patch.prompt !== undefined) {
|
|
82
|
+
patch.prompt = String(patch.prompt).trim();
|
|
83
|
+
if (!patch.prompt || patch.prompt.length > 12000)
|
|
84
|
+
return json(res, 400, { error: 'prompt is required and must be at most 12000 characters' });
|
|
85
|
+
}
|
|
86
|
+
if (patch.cron !== undefined || patch.timezone !== undefined) {
|
|
87
|
+
let schedule;
|
|
88
|
+
try {
|
|
89
|
+
schedule = validateSchedule(patch.cron ?? existing.cron, patch.timezone ?? existing.timezone);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return json(res, 400, { error: err.message });
|
|
93
|
+
}
|
|
94
|
+
Object.assign(patch, schedule);
|
|
95
|
+
}
|
|
96
|
+
if (newChatTitle)
|
|
97
|
+
patch.chatId = store.chats.create({ title: newChatTitle }, auth.userId).id;
|
|
98
|
+
const automation = store.automations.update(id, patch, auth.userId);
|
|
99
|
+
scheduler.arm();
|
|
100
|
+
return json(res, 200, { automation });
|
|
101
|
+
} },
|
|
102
|
+
{ method: 'DELETE', path: /^\/api\/automations\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
|
|
103
|
+
if (!store.automations.delete(id, auth.userId))
|
|
104
|
+
return json(res, 404, { error: 'no such automation' });
|
|
105
|
+
scheduler.arm();
|
|
106
|
+
return json(res, 200, { ok: true });
|
|
107
|
+
} }
|
|
108
|
+
];
|
|
92
109
|
}
|