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.
- package/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- package/src/tools/task_tool.js +63 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { json, readJson } from '../http.js';
|
|
2
|
+
import { isClosed, findEntry, labelFor } from '../config/models.js';
|
|
3
|
+
import { modelsFor, fingerprint } from '../access/policy.js';
|
|
4
|
+
import {
|
|
5
|
+
verifyPassword, createSession, destroySession, sessionToken, sessionCookie, clearCookie, isSecureRequest
|
|
6
|
+
} from '../access/auth.js';
|
|
7
|
+
import { audit } from '../audit.js';
|
|
8
|
+
import { OWNER_ID } from '../access/auth_gate.js';
|
|
9
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
10
|
+
|
|
11
|
+
// Failed logins per client address: 5 misses locks that address out for 15 minutes.
|
|
12
|
+
const LOGIN_MAX = 5;
|
|
13
|
+
const LOGIN_WINDOW_MS = 15 * 60_000;
|
|
14
|
+
|
|
15
|
+
export function authRoutes({ config, modelFor, multiUser, passwordHash, source, store, version }) {
|
|
16
|
+
const loginFailures = new Map();
|
|
17
|
+
|
|
18
|
+
return [
|
|
19
|
+
{ method: 'GET', path: /^\/api\/auth\/me$/, feature: null, handle: ({ res, auth }) => {
|
|
20
|
+
const cfg = config();
|
|
21
|
+
const signedIn = auth.userId !== undefined;
|
|
22
|
+
const model = signedIn ? modelFor(auth.user?.id, auth.role) : null;
|
|
23
|
+
return json(res, 200, {
|
|
24
|
+
authMode: cfg.authMode,
|
|
25
|
+
logoutUrl: cfg.logoutUrl || '',
|
|
26
|
+
isAdmin: Boolean(auth.isAdmin),
|
|
27
|
+
features: [...auth.features],
|
|
28
|
+
models: signedIn ? modelsFor(cfg, auth.role) : [],
|
|
29
|
+
model,
|
|
30
|
+
modelLabel: signedIn ? labelFor(cfg, model) : null,
|
|
31
|
+
user: auth.user
|
|
32
|
+
? { id: auth.user.id, email: auth.user.email, name: auth.user.name ?? null, role: auth.user.role, status: auth.user.status }
|
|
33
|
+
: null
|
|
34
|
+
});
|
|
35
|
+
} },
|
|
36
|
+
|
|
37
|
+
{ method: 'POST', path: /^\/api\/auth\/login$/, feature: null, handle: async ({ req, res }) => {
|
|
38
|
+
const cfg = config();
|
|
39
|
+
if (cfg.authMode !== 'single') return json(res, 404, { error: 'not found' });
|
|
40
|
+
const who = req.socket.remoteAddress || '?';
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const f = loginFailures.get(who);
|
|
43
|
+
if (f && now - f.first > LOGIN_WINDOW_MS) loginFailures.delete(who);
|
|
44
|
+
const entry = loginFailures.get(who);
|
|
45
|
+
if (entry && entry.count >= LOGIN_MAX) {
|
|
46
|
+
return json(res, 429, { error: 'too many attempts, try again later' });
|
|
47
|
+
}
|
|
48
|
+
const { password } = await readJson(req);
|
|
49
|
+
if (!verifyPassword(password, passwordHash())) {
|
|
50
|
+
loginFailures.set(who, { first: entry?.first ?? now, count: (entry?.count ?? 0) + 1 });
|
|
51
|
+
audit('auth.rejected', { reason: 'bad_password' });
|
|
52
|
+
return json(res, 401, { error: 'wrong password' });
|
|
53
|
+
}
|
|
54
|
+
loginFailures.delete(who);
|
|
55
|
+
const token = createSession(store, OWNER_ID, cfg.sessionTtlDays);
|
|
56
|
+
audit('auth.login', { userId: OWNER_ID });
|
|
57
|
+
res.setHeader('set-cookie', sessionCookie(token, cfg.sessionSecret, { secure: isSecureRequest(req, cfg.trustedProxyCidrs) }));
|
|
58
|
+
return json(res, 200, { ok: true });
|
|
59
|
+
} },
|
|
60
|
+
|
|
61
|
+
{ method: 'POST', path: /^\/api\/auth\/logout$/, feature: null, handle: ({ req, res }) => {
|
|
62
|
+
const cfg = config();
|
|
63
|
+
// The gateway owns the session; the client is sent to its logout page.
|
|
64
|
+
if (cfg.authMode === 'trusted-header') return json(res, 200, { redirect: cfg.logoutUrl || null });
|
|
65
|
+
if (cfg.authMode !== 'single') return json(res, 404, { error: 'not found' });
|
|
66
|
+
const token = sessionToken(req, cfg);
|
|
67
|
+
if (token) destroySession(store, token);
|
|
68
|
+
res.setHeader('set-cookie', clearCookie({ secure: isSecureRequest(req, cfg.trustedProxyCidrs) }));
|
|
69
|
+
return json(res, 200, { ok: true });
|
|
70
|
+
} },
|
|
71
|
+
|
|
72
|
+
// Tier 3's model pill: a personal choice among the role's models. In
|
|
73
|
+
// tiers 1-2 the pill changes the configured model instead (/api/config).
|
|
74
|
+
{ method: 'POST', path: /^\/api\/me\/prefs$/, feature: FEATURE.MODEL_PICKER, handle: async ({ req, res, auth }) => {
|
|
75
|
+
const cfg = config();
|
|
76
|
+
if (!multiUser()) return json(res, 400, { error: 'preferences are per-user; set the model in settings' });
|
|
77
|
+
const { model } = await readJson(req);
|
|
78
|
+
if (model !== null && (typeof model !== 'string' || !model.trim())) return json(res, 400, { error: 'model must be a model id or null' });
|
|
79
|
+
const allowed = modelsFor(cfg, auth.role);
|
|
80
|
+
if (model && isClosed(cfg) && !findEntry(cfg, model)) return json(res, 403, { error: 'that model is not available to you' });
|
|
81
|
+
if (model && allowed !== '*' && !allowed.includes(model)) return json(res, 403, { error: 'that model is not available to you' });
|
|
82
|
+
store.users.setPref(auth.user.id, 'model', model ? model.trim() : null);
|
|
83
|
+
return json(res, 200, { model: modelFor(auth.user.id, auth.role) });
|
|
84
|
+
} },
|
|
85
|
+
|
|
86
|
+
// Non-secret facts about this deployment, for scripts verifying what is
|
|
87
|
+
// running: build, config hash, database schema, auth, MCP server names.
|
|
88
|
+
{ method: 'GET', path: /^\/api\/meta$/, feature: null, handle: ({ res }) => json(res, 200, {
|
|
89
|
+
version: version,
|
|
90
|
+
schemaVersion: store.schemaVersion(),
|
|
91
|
+
fingerprint: fingerprint(config(), source.loadMcpServers()),
|
|
92
|
+
configMode: source.isFrozen() ? 'frozen' : 'editable',
|
|
93
|
+
authMode: config().authMode,
|
|
94
|
+
mcpServers: Object.keys(source.loadMcpServers()).sort()
|
|
95
|
+
}) }
|
|
96
|
+
];
|
|
97
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { json, readJson } from '../http.js';
|
|
2
|
+
import { validateSchedule } from '../automations/automation.js';
|
|
3
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
4
|
+
|
|
5
|
+
/** `newChatTitle` on an automation request: undefined when absent, false when
|
|
6
|
+
* present but unusable, otherwise the trimmed title. */
|
|
7
|
+
function newChatTitleOf(body) {
|
|
8
|
+
if (body.newChatTitle === undefined || body.newChatTitle === null) return undefined;
|
|
9
|
+
const title = String(body.newChatTitle).trim();
|
|
10
|
+
return title && title.length <= 120 ? title : false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BAD_TITLE = { error: 'the new chat needs a name (at most 120 characters)' };
|
|
14
|
+
|
|
15
|
+
export function automationRoutes({ scheduler, store }) {
|
|
16
|
+
const F = FEATURE.AUTOMATIONS;
|
|
17
|
+
return [
|
|
18
|
+
{ method: 'GET', path: /^\/api\/automations$/, feature: F, handle: ({ res, auth }) =>
|
|
19
|
+
json(res, 200, { automations: store.automations.list(auth.userId), chats: store.chats.list(200, auth.userId) }) },
|
|
20
|
+
|
|
21
|
+
{ method: 'POST', path: /^\/api\/automations$/, feature: F, handle: async ({ req, res, auth }) => {
|
|
22
|
+
const body = await readJson(req);
|
|
23
|
+
const newChatTitle = newChatTitleOf(body);
|
|
24
|
+
if (newChatTitle === false) return json(res, 400, BAD_TITLE);
|
|
25
|
+
let chat = newChatTitle ? null : store.chats.get(String(body.chatId || ''), auth.userId);
|
|
26
|
+
if (!newChatTitle && !chat) return json(res, 400, { error: 'a chat you own is required' });
|
|
27
|
+
const name = String(body.name || '').trim();
|
|
28
|
+
const prompt = String(body.prompt || '').trim();
|
|
29
|
+
if (!name || !prompt) return json(res, 400, { error: 'name and prompt are required' });
|
|
30
|
+
if (name.length > 120 || prompt.length > 12000) return json(res, 400, { error: 'name or prompt is too long' });
|
|
31
|
+
let schedule;
|
|
32
|
+
try { schedule = validateSchedule(body.cron, body.timezone); }
|
|
33
|
+
catch (err) { return json(res, 400, { error: err.message }); }
|
|
34
|
+
if (newChatTitle) chat = store.chats.create({ title: newChatTitle }, auth.userId);
|
|
35
|
+
const automation = store.automations.create({ ...schedule, chatId: chat.id, name, prompt, enabled: body.enabled !== false, source: 'user' }, auth.userId);
|
|
36
|
+
scheduler.arm();
|
|
37
|
+
return json(res, 201, { automation });
|
|
38
|
+
} },
|
|
39
|
+
|
|
40
|
+
{ method: 'GET', path: /^\/api\/automations\/([\w.-]+)\/runs$/, feature: F, handle: ({ res, auth, params: [id] }) => {
|
|
41
|
+
const runs = store.automations.listRuns(id, auth.userId);
|
|
42
|
+
return runs ? json(res, 200, { runs }) : json(res, 404, { error: 'no such automation' });
|
|
43
|
+
} },
|
|
44
|
+
|
|
45
|
+
{ method: 'POST', path: /^\/api\/automations\/([\w.-]+)\/trigger$/, feature: F, handle: async ({ res, auth, params: [id] }) => {
|
|
46
|
+
const automation = store.automations.get(id, auth.userId);
|
|
47
|
+
if (!automation) return json(res, 404, { error: 'no such automation' });
|
|
48
|
+
const run = await scheduler.trigger(automation, auth.userId);
|
|
49
|
+
return json(res, 202, { run });
|
|
50
|
+
} },
|
|
51
|
+
|
|
52
|
+
{ method: 'PATCH', path: /^\/api\/automations\/([\w.-]+)$/, feature: F, handle: async ({ req, res, auth, params: [id] }) => {
|
|
53
|
+
const existing = store.automations.get(id, auth.userId);
|
|
54
|
+
if (!existing) return json(res, 404, { error: 'no such automation' });
|
|
55
|
+
const body = await readJson(req);
|
|
56
|
+
const patch = {};
|
|
57
|
+
for (const key of ['name', 'prompt', 'cron', 'timezone', 'enabled', 'chatId']) if (body[key] !== undefined) patch[key] = body[key];
|
|
58
|
+
const newChatTitle = newChatTitleOf(body);
|
|
59
|
+
if (newChatTitle === false) return json(res, 400, BAD_TITLE);
|
|
60
|
+
if (newChatTitle) delete patch.chatId;
|
|
61
|
+
if (patch.chatId !== undefined) {
|
|
62
|
+
const chat = store.chats.get(String(patch.chatId), auth.userId);
|
|
63
|
+
if (!chat) return json(res, 400, { error: 'a chat you own is required' });
|
|
64
|
+
patch.chatId = chat.id;
|
|
65
|
+
}
|
|
66
|
+
if (patch.name !== undefined) {
|
|
67
|
+
patch.name = String(patch.name).trim();
|
|
68
|
+
if (!patch.name || patch.name.length > 120) return json(res, 400, { error: 'name is required and must be at most 120 characters' });
|
|
69
|
+
}
|
|
70
|
+
if (patch.prompt !== undefined) {
|
|
71
|
+
patch.prompt = String(patch.prompt).trim();
|
|
72
|
+
if (!patch.prompt || patch.prompt.length > 12000) return json(res, 400, { error: 'prompt is required and must be at most 12000 characters' });
|
|
73
|
+
}
|
|
74
|
+
if (patch.cron !== undefined || patch.timezone !== undefined) {
|
|
75
|
+
let schedule;
|
|
76
|
+
try { schedule = validateSchedule(patch.cron ?? existing.cron, patch.timezone ?? existing.timezone); }
|
|
77
|
+
catch (err) { return json(res, 400, { error: err.message }); }
|
|
78
|
+
Object.assign(patch, schedule);
|
|
79
|
+
}
|
|
80
|
+
if (newChatTitle) patch.chatId = store.chats.create({ title: newChatTitle }, auth.userId).id;
|
|
81
|
+
const automation = store.automations.update(id, patch, auth.userId);
|
|
82
|
+
scheduler.arm();
|
|
83
|
+
return json(res, 200, { automation });
|
|
84
|
+
} },
|
|
85
|
+
|
|
86
|
+
{ method: 'DELETE', path: /^\/api\/automations\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
|
|
87
|
+
if (!store.automations.delete(id, auth.userId)) return json(res, 404, { error: 'no such automation' });
|
|
88
|
+
scheduler.arm();
|
|
89
|
+
return json(res, 200, { ok: true });
|
|
90
|
+
} }
|
|
91
|
+
];
|
|
92
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { json, readJson, TURN_LIMIT } from '../http.js';
|
|
2
|
+
import { LockedError } from '../config/config.js';
|
|
3
|
+
import { toView } from '../store/index.js';
|
|
4
|
+
import { setOverride } from '../config/approval.js';
|
|
5
|
+
import { actor } from '../access/auth_gate.js';
|
|
6
|
+
import { normalizeUpload } from './documents.js';
|
|
7
|
+
import { attachmentNote } from '../../public/shared/attachment_note.js';
|
|
8
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
9
|
+
|
|
10
|
+
const MAX_IMAGES = 8;
|
|
11
|
+
const F = FEATURE.CHAT;
|
|
12
|
+
|
|
13
|
+
/** Images and documents on a turn need their own features, whatever the route. */
|
|
14
|
+
function refusedFeature(auth, { images, documentIds }) {
|
|
15
|
+
if (Array.isArray(images) && images.length && !auth.features.has(FEATURE.IMAGES)) return FEATURE.IMAGES;
|
|
16
|
+
if (Array.isArray(documentIds) && documentIds.length && !auth.features.has(FEATURE.ATTACHMENTS)) return FEATURE.ATTACHMENTS;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function chatRoutes({ config, modelFor, runs, saveConfig, store, toolsFor }) {
|
|
21
|
+
const noChat = (res) => json(res, 404, { error: 'no such chat' });
|
|
22
|
+
|
|
23
|
+
return [
|
|
24
|
+
// `running` is what puts the dot in the sidebar: a turn belongs to the
|
|
25
|
+
// server, so a chat can be working while nothing is watching it.
|
|
26
|
+
{ method: 'GET', path: /^\/api\/chats$/, feature: F, handle: ({ res, auth }) => json(res, 200, {
|
|
27
|
+
chats: store.chats.list(200, auth.userId).map((c) => ({ ...c, running: runs.isRunning(c.id) }))
|
|
28
|
+
}) },
|
|
29
|
+
|
|
30
|
+
{ method: 'GET', path: /^\/api\/chats\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
|
|
31
|
+
const found = store.chats.get(id, auth.userId);
|
|
32
|
+
if (!found) return noChat(res);
|
|
33
|
+
const run = runs.get(found.id);
|
|
34
|
+
const live = run && !run.done;
|
|
35
|
+
const messages = store.messages.list(found.id).map(toView);
|
|
36
|
+
return json(res, 200, {
|
|
37
|
+
id: found.id,
|
|
38
|
+
title: found.title,
|
|
39
|
+
folder: found.folder || null,
|
|
40
|
+
epoch: found.epoch,
|
|
41
|
+
// A turn in flight has already written some of itself to the store.
|
|
42
|
+
// Cutting the transcript back to where the turn began lets the client
|
|
43
|
+
// replay the settled part and then play the run's events over the top,
|
|
44
|
+
// instead of rendering the same rounds twice.
|
|
45
|
+
messages: live ? messages.slice(0, run.baseCount) : messages,
|
|
46
|
+
running: Boolean(live),
|
|
47
|
+
queued: store.chats.listQueued(found.id),
|
|
48
|
+
tasks: store.chats.visibleTasks(found.id),
|
|
49
|
+
documents: store.documents.list(found.id)
|
|
50
|
+
});
|
|
51
|
+
} },
|
|
52
|
+
|
|
53
|
+
{ method: 'DELETE', path: /^\/api\/chats\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [id] }) => {
|
|
54
|
+
if (!store.chats.get(id, auth.userId)) return noChat(res);
|
|
55
|
+
store.chats.delete(id, auth.userId);
|
|
56
|
+
return json(res, 200, { ok: true });
|
|
57
|
+
} },
|
|
58
|
+
|
|
59
|
+
{ method: 'GET', path: /^\/api\/chats\/([\w.-]+)\/stream$/, feature: F, handle: ({ res, auth, url, params: [id] }) => {
|
|
60
|
+
if (!store.chats.get(id, auth.userId)) return noChat(res);
|
|
61
|
+
const run = runs.get(id);
|
|
62
|
+
if (!run) return json(res, 404, { error: 'nothing running' });
|
|
63
|
+
return runs.attach(run, res, Number(url.searchParams.get('from')) || 0);
|
|
64
|
+
} },
|
|
65
|
+
|
|
66
|
+
// Rewriting a question and answering it again. Asking the same question
|
|
67
|
+
// again is the same operation with the same text, so there is one route.
|
|
68
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/edit$/, feature: F, handle: async ({ req, res, auth, params: [id] }) => {
|
|
69
|
+
const found = store.chats.get(id, auth.userId);
|
|
70
|
+
if (!found) return noChat(res);
|
|
71
|
+
// Rewriting history under a turn that is still reading it would leave
|
|
72
|
+
// the run answering a question that no longer exists.
|
|
73
|
+
if (runs.isRunning(id)) return json(res, 409, { error: 'that chat is still working; stop it first' });
|
|
74
|
+
|
|
75
|
+
const body = await readJson(req, TURN_LIMIT);
|
|
76
|
+
const { seq, message, documentIds, removeDocumentIds, images } = body;
|
|
77
|
+
const refused = refusedFeature(auth, body);
|
|
78
|
+
if (refused) return json(res, 403, { error: 'feature_disabled', feature: refused });
|
|
79
|
+
const text = String(message ?? '').trim();
|
|
80
|
+
if (!text) return json(res, 400, { error: 'message is required' });
|
|
81
|
+
const target = store.messages.list(id).find((m) => m.seq === Number(seq));
|
|
82
|
+
if (!target || target.role !== 'user') {
|
|
83
|
+
return json(res, 400, { error: 'seq must name a question of your own' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
store.chats.truncateFrom(id, Number(seq));
|
|
87
|
+
// A document is chat-scoped, not part of the message row that just got
|
|
88
|
+
// truncated, so dropping one during edit takes an explicit delete --
|
|
89
|
+
// this is the only place that happens. Nothing survives that would
|
|
90
|
+
// still reference it: truncateFrom already took every later message
|
|
91
|
+
// (the only other place a reference could live) with it.
|
|
92
|
+
for (const docId of Array.isArray(removeDocumentIds) ? removeDocumentIds : []) {
|
|
93
|
+
const doc = store.documents.get(docId, auth.userId);
|
|
94
|
+
if (doc?.chat_id === id) store.documents.delete(docId, auth.userId);
|
|
95
|
+
}
|
|
96
|
+
let content = text;
|
|
97
|
+
for (const docId of Array.isArray(documentIds) ? documentIds : []) {
|
|
98
|
+
const doc = store.documents.get(docId, auth.userId);
|
|
99
|
+
if (doc?.chat_id === id) content += attachmentNote(doc);
|
|
100
|
+
}
|
|
101
|
+
const kept = Array.isArray(images) ? images.filter((img) => img?.mime && img?.data) : [];
|
|
102
|
+
store.messages.add(id, { role: 'user', content, ...(kept.length ? { images: kept } : {}) });
|
|
103
|
+
|
|
104
|
+
// The run is started but not streamed back here. The client reloads the
|
|
105
|
+
// rewound transcript and then attaches, the same path a reload takes,
|
|
106
|
+
// rather than reading a stream through a response it also has to
|
|
107
|
+
// redraw behind.
|
|
108
|
+
runs.start({ chat: found, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role) });
|
|
109
|
+
return json(res, 200, { ok: true, running: true });
|
|
110
|
+
} },
|
|
111
|
+
|
|
112
|
+
// The user's answer to an approval prompt in the transcript.
|
|
113
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/approve$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
114
|
+
if (!store.chats.get(chatId, auth.userId)) return noChat(res);
|
|
115
|
+
const { id, decision } = await readJson(req);
|
|
116
|
+
if (!['allow', 'always', 'deny'].includes(decision)) {
|
|
117
|
+
return json(res, 400, { error: 'decision must be allow, always or deny' });
|
|
118
|
+
}
|
|
119
|
+
const run = runs.get(chatId);
|
|
120
|
+
const pending = run?.approvals.get(id);
|
|
121
|
+
if (!pending) return json(res, 409, { error: 'that call is no longer waiting' });
|
|
122
|
+
run.approvals.delete(id);
|
|
123
|
+
// "Always" rewrites tool policy, which is the tools feature's to do;
|
|
124
|
+
// without it, the answer counts as allowing this one call.
|
|
125
|
+
if (decision === 'always' && auth.features.has(FEATURE.TOOLS)) {
|
|
126
|
+
// Approval lists set in code cannot be saved to; allow this call only.
|
|
127
|
+
try { saveConfig(setOverride(config(), pending.name, 'auto'), actor(auth)); }
|
|
128
|
+
catch (err) { if (!(err instanceof LockedError)) throw err; }
|
|
129
|
+
}
|
|
130
|
+
pending.resolve(decision);
|
|
131
|
+
return json(res, 200, { ok: true });
|
|
132
|
+
} },
|
|
133
|
+
|
|
134
|
+
// The user's answer to an ask_user question, or `skip` to let the run
|
|
135
|
+
// continue without one. Only the run's own open question can be
|
|
136
|
+
// answered; anything else (answered, timed out, stopped) is a 409.
|
|
137
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/answer$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
138
|
+
if (!store.chats.get(chatId, auth.userId)) return noChat(res);
|
|
139
|
+
const { id, answer, skip } = await readJson(req, TURN_LIMIT);
|
|
140
|
+
const gone = () => json(res, 409, { error: 'that question is no longer waiting' });
|
|
141
|
+
const pending = runs.get(chatId)?.question;
|
|
142
|
+
if (!pending || pending.id !== id) return gone();
|
|
143
|
+
if (skip === true) return pending.settle('skipped') ? json(res, 200, { ok: true }) : gone();
|
|
144
|
+
const text = String(answer ?? '').trim();
|
|
145
|
+
if (!text) return json(res, 400, { error: 'answer is required' });
|
|
146
|
+
if (!pending.allowFreeText && !pending.choices.includes(text)) {
|
|
147
|
+
return json(res, 400, { error: 'answer must be one of the offered choices' });
|
|
148
|
+
}
|
|
149
|
+
return pending.settle('answered', text) ? json(res, 200, { ok: true }) : gone();
|
|
150
|
+
} },
|
|
151
|
+
|
|
152
|
+
// Input sent while a turn is running. Refused when nothing interactive
|
|
153
|
+
// is running (send it normally) and while an automation holds the chat.
|
|
154
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/queue$/, feature: F, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
155
|
+
if (!store.chats.get(chatId, auth.userId)) return noChat(res);
|
|
156
|
+
const { id, kind, message } = await readJson(req, TURN_LIMIT);
|
|
157
|
+
const text = String(message ?? '').trim();
|
|
158
|
+
if (!text) return json(res, 400, { error: 'message is required' });
|
|
159
|
+
if (!['steer', 'followup'].includes(kind)) return json(res, 400, { error: 'kind must be steer or followup' });
|
|
160
|
+
if (typeof id !== 'string' || !/^[\w.-]{1,64}$/.test(id)) return json(res, 400, { error: 'id is required' });
|
|
161
|
+
const run = runs.get(chatId);
|
|
162
|
+
if (!run || run.done) return json(res, 409, { error: 'nothing is running; send it as a message' });
|
|
163
|
+
if (run.unattended) return json(res, 409, { error: 'an automation is running in this chat; wait for it to finish' });
|
|
164
|
+
store.chats.addQueued(chatId, { id, kind, content: text });
|
|
165
|
+
runs.announceQueue(chatId);
|
|
166
|
+
return json(res, 200, { ok: true, items: store.chats.listQueued(chatId) });
|
|
167
|
+
} },
|
|
168
|
+
|
|
169
|
+
{ method: 'DELETE', path: /^\/api\/chats\/([\w.-]+)\/queue\/([\w.-]+)$/, feature: F, handle: ({ res, auth, params: [chatId, itemId] }) => {
|
|
170
|
+
if (!store.chats.get(chatId, auth.userId)) return noChat(res);
|
|
171
|
+
const removed = store.chats.deleteQueued(chatId, itemId);
|
|
172
|
+
runs.announceQueue(chatId);
|
|
173
|
+
return json(res, removed ? 200 : 409, removed ? { ok: true } : { error: 'already delivered' });
|
|
174
|
+
} },
|
|
175
|
+
|
|
176
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/stop$/, feature: F, handle: ({ res, auth, params: [chatId] }) => {
|
|
177
|
+
if (!store.chats.get(chatId, auth.userId)) return noChat(res);
|
|
178
|
+
runs.get(chatId)?.ac.abort();
|
|
179
|
+
return json(res, 200, { ok: true });
|
|
180
|
+
} },
|
|
181
|
+
|
|
182
|
+
{ method: 'POST', path: /^\/api\/chat$/, feature: F, handle: async ({ req, res, auth }) => {
|
|
183
|
+
const body = await readJson(req, TURN_LIMIT);
|
|
184
|
+
const { chatId, message, documentIds, images } = body;
|
|
185
|
+
const refused = refusedFeature(auth, body);
|
|
186
|
+
if (refused) return json(res, 403, { error: 'feature_disabled', feature: refused });
|
|
187
|
+
if (!config().apiKey) return json(res, 400, { error: 'No API key. Set TINYWEBUI_API_KEY or apiKey in the config file.' });
|
|
188
|
+
if (!message) return json(res, 400, { error: 'message is required' });
|
|
189
|
+
|
|
190
|
+
// The client no longer ships the transcript: it sends the new turn and
|
|
191
|
+
// the server replays what it already holds. That is what stops a
|
|
192
|
+
// page-sized tool result from crossing the wire on every message.
|
|
193
|
+
const owned = chatId && store.chats.get(chatId, auth.userId);
|
|
194
|
+
// Someone else's id is "no such chat", never a primary-key clash.
|
|
195
|
+
if (chatId && !owned && store.chats.byId(chatId)) return noChat(res);
|
|
196
|
+
const chat = owned || store.chats.create({ id: chatId, title: String(message).slice(0, 60) }, auth.userId);
|
|
197
|
+
|
|
198
|
+
// Attachments are surfaced as plain text inline notes rather than a
|
|
199
|
+
// system-prompt change, the same idiom compact.js uses for a compacted
|
|
200
|
+
// artifact -- the model sees "[Attached document: ...]" in the message
|
|
201
|
+
// it's already reading and knows to call read_document on the id.
|
|
202
|
+
let content = String(message);
|
|
203
|
+
for (const id of Array.isArray(documentIds) ? documentIds : []) {
|
|
204
|
+
const doc = store.documents.get(id, auth.userId);
|
|
205
|
+
if (!doc || doc.chat_id !== chat.id) continue;
|
|
206
|
+
content += attachmentNote(doc);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Images ride directly on the message as OpenAI multimodal content
|
|
210
|
+
// parts -- unlike a document there is no text to extract, so the bytes
|
|
211
|
+
// have to go up with the turn that attached them. Usually already
|
|
212
|
+
// normalized client-side (/api/images/normalize); normalizing again is a
|
|
213
|
+
// cheap passthrough then, and a safety net for callers that skipped it.
|
|
214
|
+
const imgs = [];
|
|
215
|
+
for (const img of Array.isArray(images) ? images.slice(0, MAX_IMAGES) : []) {
|
|
216
|
+
const normalized = await normalizeUpload(img?.mime, img?.dataBase64);
|
|
217
|
+
if (normalized) imgs.push(normalized);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
store.messages.add(chat.id, { role: 'user', content, ...(imgs.length ? { images: imgs } : {}) });
|
|
221
|
+
|
|
222
|
+
// The turn is started, not awaited. Closing the tab detaches a
|
|
223
|
+
// listener; it no longer kills the work, and the answer is in the store
|
|
224
|
+
// whether or not anyone was watching when it landed.
|
|
225
|
+
const existing = runs.get(chat.id);
|
|
226
|
+
const run = existing && !existing.done
|
|
227
|
+
? existing
|
|
228
|
+
: runs.start({ chat, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role) });
|
|
229
|
+
return runs.attach(run, res, 0);
|
|
230
|
+
} }
|
|
231
|
+
];
|
|
232
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { json, readJson, UPLOAD_LIMIT } from '../http.js';
|
|
2
|
+
import { extractText, servingFor } from '../files/documents.js';
|
|
3
|
+
import { normalizeImage } from '../files/images.js';
|
|
4
|
+
import { logger } from '../log.js';
|
|
5
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
6
|
+
import { CORPUS } from '../store/index.js';
|
|
7
|
+
|
|
8
|
+
const log = logger('retrieval');
|
|
9
|
+
|
|
10
|
+
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/** Decodes and normalizes one uploaded image; null on anything unusable. */
|
|
13
|
+
export async function normalizeUpload(mimeRaw, dataBase64) {
|
|
14
|
+
const mime = String(mimeRaw || '');
|
|
15
|
+
if (!mime.startsWith('image/') && mime !== '') return null;
|
|
16
|
+
let buf;
|
|
17
|
+
try { buf = Buffer.from(String(dataBase64 || ''), 'base64'); } catch { return null; }
|
|
18
|
+
if (!buf.length || buf.length > MAX_FILE_BYTES) return null;
|
|
19
|
+
try {
|
|
20
|
+
const normalized = await normalizeImage(buf, mime);
|
|
21
|
+
return normalized && { mime: normalized.mime, data: normalized.data.toString('base64') };
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function documentRoutes({ retrieval, store }) {
|
|
28
|
+
// Uploads and attaches land before the first message exists, so the chat is
|
|
29
|
+
// created lazily, the same way /api/chat does for a brand-new conversation.
|
|
30
|
+
// Null when the id is someone else's: "no such chat", never a key clash.
|
|
31
|
+
const chatFor = (chatId, auth, title) => {
|
|
32
|
+
const own = store.chats.get(chatId, auth.userId);
|
|
33
|
+
if (own) return own;
|
|
34
|
+
if (store.chats.byId(chatId)) return null;
|
|
35
|
+
return store.chats.create({ id: chatId, title: String(title).slice(0, 60) }, auth.userId);
|
|
36
|
+
};
|
|
37
|
+
// Embedded once, in the background; a question that arrives first waits for it.
|
|
38
|
+
const ingest = (doc) => retrieval.ingest(CORPUS.DOCUMENTS, doc.id)
|
|
39
|
+
.catch((err) => log.error(`embedding ${doc.id} failed: ${err.message}`));
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
// Uploads, including the paste-as-file path.
|
|
43
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/documents$/, feature: FEATURE.ATTACHMENTS, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
44
|
+
const { filename, mime, dataBase64 } = await readJson(req, UPLOAD_LIMIT);
|
|
45
|
+
if (!filename || typeof dataBase64 !== 'string') {
|
|
46
|
+
return json(res, 400, { error: 'filename and dataBase64 are required' });
|
|
47
|
+
}
|
|
48
|
+
let buf;
|
|
49
|
+
try {
|
|
50
|
+
buf = Buffer.from(dataBase64, 'base64');
|
|
51
|
+
} catch {
|
|
52
|
+
return json(res, 400, { error: 'dataBase64 is not valid base64' });
|
|
53
|
+
}
|
|
54
|
+
if (buf.length > MAX_FILE_BYTES) {
|
|
55
|
+
return json(res, 400, { error: `file exceeds ${MAX_FILE_BYTES.toLocaleString('en-US')} byte limit` });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = await extractText(buf, String(filename));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return json(res, 400, { error: err.message });
|
|
63
|
+
}
|
|
64
|
+
if (!text.trim()) return json(res, 400, { error: 'no extractable text in that file' });
|
|
65
|
+
|
|
66
|
+
const chat = chatFor(chatId, auth, filename);
|
|
67
|
+
if (!chat) return json(res, 404, { error: 'no such chat' });
|
|
68
|
+
// The same file dropped in twice is one document in the chat.
|
|
69
|
+
const already = store.documents.findInChat(chat.id, buf);
|
|
70
|
+
if (already) return json(res, 200, { chatId: chat.id, document: already });
|
|
71
|
+
const doc = store.documents.add(chat.id, { filename: String(filename), mime: mime || null, content: text, original: buf }, retrieval.passageSettings());
|
|
72
|
+
ingest(doc);
|
|
73
|
+
return json(res, 200, { chatId: chat.id, document: doc });
|
|
74
|
+
} },
|
|
75
|
+
|
|
76
|
+
// What the user has attached in earlier chats, one line per distinct file,
|
|
77
|
+
// for the "attach from earlier chats" picker.
|
|
78
|
+
{ method: 'GET', path: /^\/api\/documents$/, feature: FEATURE.ATTACHMENTS, handle: ({ res, auth }) => {
|
|
79
|
+
return json(res, 200, { documents: store.documents.library(auth.userId) });
|
|
80
|
+
} },
|
|
81
|
+
|
|
82
|
+
// Attaches a file the user already uploaded elsewhere, without uploading
|
|
83
|
+
// it again: the chat gets its own copy of the text (same stored file).
|
|
84
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/documents\/attach$/, feature: FEATURE.ATTACHMENTS, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
85
|
+
const { documentId } = await readJson(req);
|
|
86
|
+
if (typeof documentId !== 'string' || !documentId) return json(res, 400, { error: 'documentId is required' });
|
|
87
|
+
// Checked before the chat is created, so a bad id leaves no empty chat behind.
|
|
88
|
+
const source = store.documents.get(documentId, auth.userId);
|
|
89
|
+
if (!source) return json(res, 404, { error: 'no such document' });
|
|
90
|
+
const chat = chatFor(chatId, auth, source.filename);
|
|
91
|
+
if (!chat) return json(res, 404, { error: 'no such chat' });
|
|
92
|
+
const doc = store.documents.copyToChat(documentId, chat.id, auth.userId, retrieval.passageSettings());
|
|
93
|
+
if (!doc.existing) ingest(doc);
|
|
94
|
+
return json(res, 200, { chatId: chat.id, document: doc });
|
|
95
|
+
} },
|
|
96
|
+
|
|
97
|
+
// Lets the "files" rail open a document's full extracted text -- the
|
|
98
|
+
// same content the model reads via read_document, in a plain new tab.
|
|
99
|
+
// Documents have no delete route of their own: the only way one goes
|
|
100
|
+
// away is editing the message that attached it (see /edit in chats.js).
|
|
101
|
+
{ method: 'GET', path: /^\/api\/documents\/([\w.-]+)$/, feature: FEATURE.ATTACHMENTS, handle: ({ res, auth, params: [id] }) => {
|
|
102
|
+
const doc = store.documents.get(id, auth.userId);
|
|
103
|
+
if (!doc) return json(res, 404, { error: 'no such document' });
|
|
104
|
+
return json(res, 200, { filename: doc.filename, mime: doc.mime, content: doc.content });
|
|
105
|
+
} },
|
|
106
|
+
|
|
107
|
+
// What the "artifacts" rail opens: the file as uploaded. Documents stored
|
|
108
|
+
// before originals were kept fall back to their extracted text.
|
|
109
|
+
{ method: 'GET', path: /^\/api\/documents\/([\w.-]+)\/original$/, feature: FEATURE.ATTACHMENTS, handle: ({ res, auth, params: [id] }) => {
|
|
110
|
+
const doc = store.documents.get(id, auth.userId);
|
|
111
|
+
if (!doc) return json(res, 404, { error: 'no such document' });
|
|
112
|
+
const original = store.documents.original(id, auth.userId);
|
|
113
|
+
const { type, inline } = original ? servingFor(doc.filename) : servingFor('.txt');
|
|
114
|
+
const body = original || Buffer.from(doc.content, 'utf8');
|
|
115
|
+
// The name is percent-encoded (RFC 5987) so it cannot break out of the header.
|
|
116
|
+
const disposition = `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(doc.filename)}`;
|
|
117
|
+
res.writeHead(200, { 'content-type': type, 'content-length': body.length, 'content-disposition': disposition });
|
|
118
|
+
res.end(body);
|
|
119
|
+
} },
|
|
120
|
+
|
|
121
|
+
// Converts one staged image to a wire-safe format before it's ever sent,
|
|
122
|
+
// so the composer's own preview and the optimistic thumbnail in the
|
|
123
|
+
// transcript show the same bytes the model (and the store) end up with,
|
|
124
|
+
// instead of a HEIC/AVIF the browser can't decode until the turn ends
|
|
125
|
+
// and the chat reloads with what the server stored.
|
|
126
|
+
{ method: 'POST', path: /^\/api\/images\/normalize$/, feature: FEATURE.IMAGES, handle: async ({ req, res }) => {
|
|
127
|
+
const { mime, dataBase64 } = await readJson(req, UPLOAD_LIMIT);
|
|
128
|
+
const normalized = await normalizeUpload(mime, dataBase64);
|
|
129
|
+
if (!normalized) return json(res, 400, { error: 'unrecognized or oversized image' });
|
|
130
|
+
return json(res, 200, normalized);
|
|
131
|
+
} }
|
|
132
|
+
];
|
|
133
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { json, readJson } from '../http.js';
|
|
2
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
3
|
+
|
|
4
|
+
/** The sidebar's search, usage statistics, and folders. */
|
|
5
|
+
export function libraryRoutes({ runs, store }) {
|
|
6
|
+
return [
|
|
7
|
+
// Full-text search across every stored message, for the sidebar's search
|
|
8
|
+
// box. GET with a query string, so it is bookmarkable and cacheable like
|
|
9
|
+
// any other read.
|
|
10
|
+
{ method: 'GET', path: /^\/api\/search$/, feature: FEATURE.SEARCH, handle: ({ res, auth, url }) => {
|
|
11
|
+
const q = url.searchParams.get('q') || '';
|
|
12
|
+
const limit = 30;
|
|
13
|
+
// A chat mid-turn is still being written -- store.search would be
|
|
14
|
+
// matching against text that has not settled, and openChat/rejoin is
|
|
15
|
+
// not built to land a click in the middle of a live stream. Overfetch
|
|
16
|
+
// and filter rather than ask the store to know about runs, which is a
|
|
17
|
+
// server-only concept it has no business importing.
|
|
18
|
+
const results = store.search.chats(q, limit * 2, auth.userId)
|
|
19
|
+
.filter((r) => !runs.isRunning(r.chatId))
|
|
20
|
+
.slice(0, limit);
|
|
21
|
+
return json(res, 200, { results });
|
|
22
|
+
} },
|
|
23
|
+
|
|
24
|
+
// ?model= narrows every figure to one model; adding &provider= to one
|
|
25
|
+
// upstream serving it.
|
|
26
|
+
{ method: 'GET', path: /^\/api\/usage$/, feature: FEATURE.STATISTICS, handle: ({ res, auth, url }) => {
|
|
27
|
+
const q = url.searchParams;
|
|
28
|
+
const filter = { model: q.get('model') || undefined, provider: q.has('provider') ? q.get('provider') : undefined };
|
|
29
|
+
return json(res, 200, { days: store.usage.rollup(auth.userId, filter), statistics: store.usage.statistics(auth.userId, filter) });
|
|
30
|
+
} },
|
|
31
|
+
|
|
32
|
+
{ method: 'GET', path: /^\/api\/folders$/, feature: FEATURE.FOLDERS, handle: ({ res, auth }) =>
|
|
33
|
+
json(res, 200, { folders: store.chats.listFolders(auth.userId) }) },
|
|
34
|
+
|
|
35
|
+
{ method: 'POST', path: /^\/api\/folders$/, feature: FEATURE.FOLDERS, handle: async ({ req, res, auth }) => {
|
|
36
|
+
const { name } = await readJson(req);
|
|
37
|
+
const created = store.chats.createFolder(name, auth.userId);
|
|
38
|
+
if (!created) return json(res, 400, { error: 'folder name required' });
|
|
39
|
+
return json(res, 200, { folder: created });
|
|
40
|
+
} },
|
|
41
|
+
|
|
42
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/organize$/, feature: FEATURE.FOLDERS, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
43
|
+
const { folder, tags } = await readJson(req);
|
|
44
|
+
const found = store.chats.get(chatId, auth.userId);
|
|
45
|
+
if (!found) return json(res, 404, { error: 'no such chat' });
|
|
46
|
+
if (folder) store.chats.createFolder(folder, auth.userId);
|
|
47
|
+
const updated = store.chats.organize(chatId, { folder, tags }, auth.userId);
|
|
48
|
+
return json(res, 200, { folder: updated.folder });
|
|
49
|
+
} }
|
|
50
|
+
];
|
|
51
|
+
}
|