libre-webui 0.14.2 → 0.15.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/README.md +92 -24
- package/backend/dist/db.d.ts.map +1 -1
- package/backend/dist/db.js +84 -0
- package/backend/dist/db.js.map +1 -1
- package/backend/dist/index.d.ts.map +1 -1
- package/backend/dist/index.js +99 -35
- package/backend/dist/index.js.map +1 -1
- package/backend/dist/middleware/auth.d.ts.map +1 -1
- package/backend/dist/middleware/auth.js +27 -2
- package/backend/dist/middleware/auth.js.map +1 -1
- package/backend/dist/routes/plugins.d.ts.map +1 -1
- package/backend/dist/routes/plugins.js +1 -1
- package/backend/dist/routes/plugins.js.map +1 -1
- package/backend/dist/routes/users.d.ts.map +1 -1
- package/backend/dist/routes/users.js +40 -6
- package/backend/dist/routes/users.js.map +1 -1
- package/backend/dist/routes/work.d.ts +3 -0
- package/backend/dist/routes/work.d.ts.map +1 -0
- package/backend/dist/routes/work.js +604 -0
- package/backend/dist/routes/work.js.map +1 -0
- package/backend/dist/services/ollamaService.d.ts +7 -2
- package/backend/dist/services/ollamaService.d.ts.map +1 -1
- package/backend/dist/services/ollamaService.js +8 -8
- package/backend/dist/services/ollamaService.js.map +1 -1
- package/backend/dist/services/pluginService.d.ts.map +1 -1
- package/backend/dist/services/pluginService.js +8 -9
- package/backend/dist/services/pluginService.js.map +1 -1
- package/backend/dist/services/preferencesService.d.ts.map +1 -1
- package/backend/dist/services/preferencesService.js +1 -0
- package/backend/dist/services/preferencesService.js.map +1 -1
- package/backend/dist/services/workAgentGuidance.d.ts +23 -0
- package/backend/dist/services/workAgentGuidance.d.ts.map +1 -0
- package/backend/dist/services/workAgentGuidance.js +129 -0
- package/backend/dist/services/workAgentGuidance.js.map +1 -0
- package/backend/dist/services/workAgentService.d.ts +33 -0
- package/backend/dist/services/workAgentService.d.ts.map +1 -0
- package/backend/dist/services/workAgentService.js +880 -0
- package/backend/dist/services/workAgentService.js.map +1 -0
- package/backend/dist/services/workEventService.d.ts +40 -0
- package/backend/dist/services/workEventService.d.ts.map +1 -0
- package/backend/dist/services/workEventService.js +324 -0
- package/backend/dist/services/workEventService.js.map +1 -0
- package/backend/dist/services/workModelProviderService.d.ts +49 -0
- package/backend/dist/services/workModelProviderService.d.ts.map +1 -0
- package/backend/dist/services/workModelProviderService.js +916 -0
- package/backend/dist/services/workModelProviderService.js.map +1 -0
- package/backend/dist/services/workRuntimeService.d.ts +143 -0
- package/backend/dist/services/workRuntimeService.d.ts.map +1 -0
- package/backend/dist/services/workRuntimeService.js +1860 -0
- package/backend/dist/services/workRuntimeService.js.map +1 -0
- package/backend/dist/services/workTaskService.d.ts +96 -0
- package/backend/dist/services/workTaskService.d.ts.map +1 -0
- package/backend/dist/services/workTaskService.js +718 -0
- package/backend/dist/services/workTaskService.js.map +1 -0
- package/backend/dist/types/index.d.ts +3 -0
- package/backend/dist/types/index.d.ts.map +1 -1
- package/backend/dist/types/index.js.map +1 -1
- package/backend/dist/types/work.d.ts +209 -0
- package/backend/dist/types/work.d.ts.map +1 -0
- package/backend/dist/types/work.js +18 -0
- package/backend/dist/types/work.js.map +1 -0
- package/backend/dist/utils/pluginChatAdapter.d.ts +9 -0
- package/backend/dist/utils/pluginChatAdapter.d.ts.map +1 -1
- package/backend/dist/utils/pluginChatAdapter.js +32 -6
- package/backend/dist/utils/pluginChatAdapter.js.map +1 -1
- package/backend/dist/utils/pluginStreamAdapter.d.ts +12 -0
- package/backend/dist/utils/pluginStreamAdapter.d.ts.map +1 -1
- package/backend/dist/utils/pluginStreamAdapter.js +168 -19
- package/backend/dist/utils/pluginStreamAdapter.js.map +1 -1
- package/backend/dist/utils/pluginStreaming.d.ts +10 -0
- package/backend/dist/utils/pluginStreaming.d.ts.map +1 -1
- package/backend/dist/utils/pluginStreaming.js.map +1 -1
- package/backend/package.json +1 -1
- package/electron/main.js +39 -16
- package/frontend/dist/assets/index-Bv-twCuY.css +1 -0
- package/frontend/dist/assets/index-uCHBH_PM.js +5 -0
- package/frontend/dist/index.html +12 -11
- package/frontend/dist/js/{ArtifactContainer-RPQq1DRo.js → ArtifactContainer-CXfmVx8T.js} +1 -1
- package/frontend/dist/js/{ArtifactDemoPage-Dd0Rbl3N.js → ArtifactDemoPage-B1_AuMd2.js} +1 -1
- package/frontend/dist/js/ArtifactSlideOutPanel-C0eTguNL.js +1 -0
- package/frontend/dist/js/{Button-BZBnLKRn.js → Button-DBk-AREB.js} +1 -1
- package/frontend/dist/js/ChatPage-1bK0gDBy.js +16 -0
- package/frontend/dist/js/FirstTimeSetup-DOoZUaRs.js +1 -0
- package/frontend/dist/js/{GalleryPage-BTA5LAmZ.js → GalleryPage-6WwyH3kt.js} +1 -1
- package/frontend/dist/js/LibreClawPage-ChHVfPLb.js +1 -0
- package/frontend/dist/js/{ModelManagerModals-D1_k_AKo.js → ModelManagerModals-DneqFQJC.js} +1 -1
- package/frontend/dist/js/ModelsPage-Dw6wdiMt.js +2 -0
- package/frontend/dist/js/{OptimizedSyntaxHighlighter-BR97Z7hi.js → OptimizedSyntaxHighlighter-Ci9sRc78.js} +1 -1
- package/frontend/dist/js/PersonasPage-BxdI2e1-.js +13 -0
- package/frontend/dist/js/RichMessageContent-WQLdJ5jq.js +3 -0
- package/frontend/dist/js/SettingsModal-C-cK6TNb.js +1 -0
- package/frontend/dist/js/UserManagementPage-DqjNXPkf.js +1 -0
- package/frontend/dist/js/WorkPage-MGR3odo6.js +2 -0
- package/frontend/dist/js/api-DD5qhhLf.js +19 -0
- package/frontend/dist/js/{ar-UnnZB1Qy.js → ar-CCVY0-7X.js} +2 -2
- package/frontend/dist/js/babel-BnLuavNv.js +16 -0
- package/frontend/dist/js/{bn-uZXaDyeV.js → bn-CoU5CSph.js} +2 -2
- package/frontend/dist/js/{cs-CCXlnUEB.js → cs-CR6pjIbG.js} +2 -2
- package/frontend/dist/js/{da--BpVVGPp.js → da-CkyYITRz.js} +2 -2
- package/frontend/dist/js/{de-DZd9lsBc.js → de-DwNANzB-.js} +2 -2
- package/frontend/dist/js/{es-Bm968oUX.js → es-TxygiaVQ.js} +2 -2
- package/frontend/dist/js/estree-DPzMpjFm.js +44 -0
- package/frontend/dist/js/{fr-RlC0rD8I.js → fr-DvEqVpZr.js} +2 -2
- package/frontend/dist/js/{hi-C6kwkL7d.js → hi-CyrWkyh7.js} +2 -2
- package/frontend/dist/js/html-R50jmBYq.js +25 -0
- package/frontend/dist/js/{id-CDfqnnXS.js → id-PJjIGHGM.js} +2 -2
- package/frontend/dist/js/{is-Dnd_7Jqv.js → is-ChXcN1tI.js} +2 -2
- package/frontend/dist/js/{it-BM0j6_Sz.js → it-DsgM8-MJ.js} +2 -2
- package/frontend/dist/js/{ja-xGblKDDf.js → ja-ivtt80N2.js} +2 -2
- package/frontend/dist/js/{ko-CQ2d_Oj0.js → ko-B8l_Ice1.js} +2 -2
- package/frontend/dist/js/logger-DbNs3-DK.js +1 -0
- package/frontend/dist/js/markdown-C08x2H-K.js +70 -0
- package/frontend/dist/js/{markdown-core-fkbHPd-g.js → markdown-core-GI9BPwrB.js} +1 -1
- package/frontend/dist/js/{markdown-math-Ci4QOsf-.js → markdown-math-E4O1gcxj.js} +1 -1
- package/frontend/dist/js/{ms-CFpRuv_a.js → ms-DoUBI6Qi.js} +2 -2
- package/frontend/dist/js/{nl-D6oTztmP.js → nl-DsX5Hj8m.js} +2 -2
- package/frontend/dist/js/{pl-ByuvABul.js → pl-Db_NKL4x.js} +2 -2
- package/frontend/dist/js/postcss-DSDkwgFE.js +62 -0
- package/frontend/dist/js/{pt-CNFV21T7.js → pt-CpzBhb5J.js} +2 -2
- package/frontend/dist/js/react-vendor-DyG_ODzn.js +9 -0
- package/frontend/dist/js/router-vendor-BjDI9mRe.js +1 -0
- package/frontend/dist/js/{ru-CRsSOntz.js → ru-DCXvJyXJ.js} +2 -2
- package/frontend/dist/js/standalone-DpMZUvjd.js +29 -0
- package/frontend/dist/js/{sv-BRkwjyS5.js → sv-DcVoybc7.js} +2 -2
- package/frontend/dist/js/{syntax-highlight-B8OiKBhG.js → syntax-highlight-pM1xnNpY.js} +1 -1
- package/frontend/dist/js/{th-DigqsQEB.js → th-D0G7HyQn.js} +2 -2
- package/frontend/dist/js/{tr-B5aMUMJE.js → tr-CMDOh4Aq.js} +2 -2
- package/frontend/dist/js/typescript-B6y_hxBm.js +22 -0
- package/frontend/dist/js/ui-vendor-DUqvZsfs.js +177 -0
- package/frontend/dist/js/{uk-BUYi3llT.js → uk-D2MMa6LP.js} +2 -2
- package/frontend/dist/js/useBaseQuery-Duml3DYp.js +1 -0
- package/frontend/dist/js/useQuery-C0lZwNoc.js +1 -0
- package/frontend/dist/js/{useTranslation-CP6Cg-EI.js → useTranslation-Cs6iTP4m.js} +1 -1
- package/frontend/dist/js/{utils-D0l9J3P-.js → utils-pTFqSV8c.js} +1 -1
- package/frontend/dist/js/{utils-vendor-BX3ZwoIK.js → utils-vendor-Cbaj0BC5.js} +1 -1
- package/frontend/dist/js/{vi-hQIcXi3m.js → vi-B8uHT8yh.js} +2 -2
- package/frontend/dist/js/yaml-mLy9chOK.js +154 -0
- package/frontend/dist/js/{zh-CUDZ3JDv.js → zh-CNqa_15K.js} +2 -2
- package/package.json +14 -7
- package/plugins/kimi-code.json +6 -37
- package/frontend/dist/assets/index-BPvF-fpc.css +0 -1
- package/frontend/dist/assets/index-CDvTfv-t.js +0 -5
- package/frontend/dist/js/ArtifactSlideOutPanel-oA5Iy8Nc.js +0 -1
- package/frontend/dist/js/ChatPage-DdLe5r1n.js +0 -16
- package/frontend/dist/js/FirstTimeSetup-DL_WZABT.js +0 -1
- package/frontend/dist/js/LibreClawPage-CuQ8tJUW.js +0 -1
- package/frontend/dist/js/ModelsPage-BVfl9FU_.js +0 -2
- package/frontend/dist/js/PersonasPage-wP36Pd7I.js +0 -13
- package/frontend/dist/js/RichMessageContent-CdgQHk6Q.js +0 -3
- package/frontend/dist/js/SettingsModal-CLdxDdQV.js +0 -1
- package/frontend/dist/js/UserManagementPage-BwnBtNj1.js +0 -1
- package/frontend/dist/js/api-BRz6cQiD.js +0 -15
- package/frontend/dist/js/logger-BmpKNkls.js +0 -1
- package/frontend/dist/js/react-vendor-Co1u1rWb.js +0 -8
- package/frontend/dist/js/ui-vendor-njo77ljx.js +0 -177
- package/frontend/dist/js/useBaseQuery-C8AWX-Cq.js +0 -1
- package/frontend/dist/js/useQuery-CbYbdhch.js +0 -1
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Libre WebUI
|
|
3
|
+
* Copyright (C) 2025 Kroonen AI, Inc.
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at:
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
18
|
+
import { getDatabase } from '../db.js';
|
|
19
|
+
import { createLogger } from '../utils/logger.js';
|
|
20
|
+
const logger = createLogger('services:work-task');
|
|
21
|
+
const ACTIVE_RUN_STATUSES = ['queued', 'preparing', 'running'];
|
|
22
|
+
const ACTIVE_PREVIEW_STATUSES = ['starting', 'running'];
|
|
23
|
+
export const WORK_MESSAGE_PAGE_SIZE = 200;
|
|
24
|
+
export const WORK_MESSAGE_MAX_BYTES = 100000;
|
|
25
|
+
const WORK_MESSAGE_PAGE_MAX_BYTES = 1000000;
|
|
26
|
+
const WORK_CONTEXT_MAX_BYTES = 256000;
|
|
27
|
+
export const WORK_ADMISSION_DEFAULTS = {
|
|
28
|
+
maxActiveRuntimesGlobal: 2,
|
|
29
|
+
maxActiveRuntimesPerUser: 1,
|
|
30
|
+
maxTasksGlobal: 500,
|
|
31
|
+
maxTasksPerUser: 100,
|
|
32
|
+
};
|
|
33
|
+
const workAdmissionLimits = {
|
|
34
|
+
maxActiveRuntimesGlobal: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_GLOBAL, WORK_ADMISSION_DEFAULTS.maxActiveRuntimesGlobal),
|
|
35
|
+
maxActiveRuntimesPerUser: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_PER_USER, WORK_ADMISSION_DEFAULTS.maxActiveRuntimesPerUser),
|
|
36
|
+
maxTasksGlobal: positiveInteger(process.env.WORK_MAX_TASKS_GLOBAL, WORK_ADMISSION_DEFAULTS.maxTasksGlobal),
|
|
37
|
+
maxTasksPerUser: positiveInteger(process.env.WORK_MAX_TASKS_PER_USER, WORK_ADMISSION_DEFAULTS.maxTasksPerUser),
|
|
38
|
+
};
|
|
39
|
+
export class WorkTaskService {
|
|
40
|
+
constructor() {
|
|
41
|
+
this.retiringUsers = new Set();
|
|
42
|
+
this.retiringTasks = new Set();
|
|
43
|
+
this.networkPolicyChanges = new Set();
|
|
44
|
+
}
|
|
45
|
+
createTaskWithRun(userId, message, model, networkEnabled, provider = { providerType: 'ollama' }) {
|
|
46
|
+
this.assertUserIsActive(userId);
|
|
47
|
+
const selectedProvider = normalizeProvider(provider);
|
|
48
|
+
const db = getDatabase();
|
|
49
|
+
const taskId = uuidv4();
|
|
50
|
+
const runId = uuidv4();
|
|
51
|
+
const messageId = uuidv4();
|
|
52
|
+
const compactId = taskId.replace(/-/g, '');
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const title = deriveTitle(message);
|
|
55
|
+
const transaction = db.transaction(() => {
|
|
56
|
+
this.assertTaskAdmission(userId);
|
|
57
|
+
this.assertRuntimeAdmission(userId);
|
|
58
|
+
db.prepare(`INSERT INTO work_tasks (
|
|
59
|
+
id, user_id, title, model, provider_type, provider_id, status,
|
|
60
|
+
network_enabled, volume_name, container_name, preview_status,
|
|
61
|
+
created_at, updated_at
|
|
62
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'preparing', ?, ?, ?, 'stopped', ?, ?)`).run(taskId, userId, title, model, selectedProvider.providerType, selectedProvider.providerId || null, networkEnabled ? 1 : 0, `libre-work-${compactId}`, `libre-work-${compactId}`, now, now);
|
|
63
|
+
db.prepare(`INSERT INTO work_runs (
|
|
64
|
+
id, task_id, model, provider_type, provider_id, status, created_at
|
|
65
|
+
) VALUES (?, ?, ?, ?, ?, 'queued', ?)`).run(runId, taskId, model, selectedProvider.providerType, selectedProvider.providerId || null, now);
|
|
66
|
+
db.prepare(`INSERT INTO work_messages (
|
|
67
|
+
id, task_id, run_id, role, kind, content, message_index, created_at
|
|
68
|
+
) VALUES (?, ?, ?, 'user', 'message', ?, 0, ?)`).run(messageId, taskId, runId, message, now);
|
|
69
|
+
});
|
|
70
|
+
transaction();
|
|
71
|
+
return this.requireTaskDetail(taskId, userId);
|
|
72
|
+
}
|
|
73
|
+
createRun(taskId, userId, message, model, provider) {
|
|
74
|
+
const task = this.requireMutableTaskRecord(taskId, userId);
|
|
75
|
+
if (this.getActiveRun(taskId)) {
|
|
76
|
+
throw new WorkConflictError('This Work task already has an active run.');
|
|
77
|
+
}
|
|
78
|
+
const db = getDatabase();
|
|
79
|
+
const runId = uuidv4();
|
|
80
|
+
const messageId = uuidv4();
|
|
81
|
+
const selectedModel = model?.trim() || task.model;
|
|
82
|
+
const selectedProvider = normalizeProvider(provider || {
|
|
83
|
+
providerType: task.providerType,
|
|
84
|
+
providerId: task.providerId,
|
|
85
|
+
});
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const transaction = db.transaction(() => {
|
|
88
|
+
if (this.getActiveRun(taskId)) {
|
|
89
|
+
throw new WorkConflictError('This Work task already has an active run.');
|
|
90
|
+
}
|
|
91
|
+
if (ACTIVE_PREVIEW_STATUSES.includes(task.previewStatus)) {
|
|
92
|
+
throw new WorkConflictError('Stop this Work task preview before starting another run.');
|
|
93
|
+
}
|
|
94
|
+
this.assertRuntimeAdmission(userId, taskId);
|
|
95
|
+
const nextIndex = this.nextMessageIndex(taskId);
|
|
96
|
+
db.prepare(`INSERT INTO work_runs (
|
|
97
|
+
id, task_id, model, provider_type, provider_id, status, created_at
|
|
98
|
+
) VALUES (?, ?, ?, ?, ?, 'queued', ?)`).run(runId, taskId, selectedModel, selectedProvider.providerType, selectedProvider.providerId || null, now);
|
|
99
|
+
db.prepare(`INSERT INTO work_messages (
|
|
100
|
+
id, task_id, run_id, role, kind, content, message_index, created_at
|
|
101
|
+
) VALUES (?, ?, ?, 'user', 'message', ?, ?, ?)`).run(messageId, taskId, runId, message, nextIndex, now);
|
|
102
|
+
db.prepare(`UPDATE work_tasks
|
|
103
|
+
SET model = ?, provider_type = ?, provider_id = ?,
|
|
104
|
+
status = 'preparing', updated_at = ?
|
|
105
|
+
WHERE id = ?`).run(selectedModel, selectedProvider.providerType, selectedProvider.providerId || null, now, taskId);
|
|
106
|
+
});
|
|
107
|
+
transaction();
|
|
108
|
+
return this.requireTaskDetail(taskId, userId);
|
|
109
|
+
}
|
|
110
|
+
listTasks(userId) {
|
|
111
|
+
const rows = getDatabase()
|
|
112
|
+
.prepare(`SELECT * FROM work_tasks
|
|
113
|
+
WHERE user_id = ?
|
|
114
|
+
ORDER BY updated_at DESC`)
|
|
115
|
+
.all(userId);
|
|
116
|
+
return rows.map(row => this.summaryFromRow(row));
|
|
117
|
+
}
|
|
118
|
+
listTaskRecords(userId) {
|
|
119
|
+
const rows = getDatabase()
|
|
120
|
+
.prepare(`SELECT * FROM work_tasks
|
|
121
|
+
WHERE user_id = ?
|
|
122
|
+
ORDER BY created_at ASC`)
|
|
123
|
+
.all(userId);
|
|
124
|
+
return rows.map(mapTaskRecord);
|
|
125
|
+
}
|
|
126
|
+
listAllTaskRecords() {
|
|
127
|
+
return getDatabase()
|
|
128
|
+
.prepare('SELECT * FROM work_tasks ORDER BY created_at ASC')
|
|
129
|
+
.all().map(mapTaskRecord);
|
|
130
|
+
}
|
|
131
|
+
beginUserRetirement(userId) {
|
|
132
|
+
if (this.retiringUsers.has(userId)) {
|
|
133
|
+
throw new WorkConflictError('Work tasks for this user are already being deleted.');
|
|
134
|
+
}
|
|
135
|
+
this.retiringUsers.add(userId);
|
|
136
|
+
}
|
|
137
|
+
releaseUserRetirement(userId) {
|
|
138
|
+
this.retiringUsers.delete(userId);
|
|
139
|
+
}
|
|
140
|
+
beginTaskRetirement(taskId, userId, allowRetiringUser = false) {
|
|
141
|
+
if (!allowRetiringUser) {
|
|
142
|
+
this.assertUserIsActive(userId);
|
|
143
|
+
}
|
|
144
|
+
const task = this.requireTaskRecord(taskId, userId);
|
|
145
|
+
this.assertNetworkPolicyStable(taskId);
|
|
146
|
+
if (this.retiringTasks.has(taskId)) {
|
|
147
|
+
throw new WorkConflictError('This Work task is already being deleted.');
|
|
148
|
+
}
|
|
149
|
+
this.retiringTasks.add(taskId);
|
|
150
|
+
return task;
|
|
151
|
+
}
|
|
152
|
+
releaseTaskRetirement(taskId) {
|
|
153
|
+
this.retiringTasks.delete(taskId);
|
|
154
|
+
}
|
|
155
|
+
finalizeTaskRetirement(taskId) {
|
|
156
|
+
this.retiringTasks.delete(taskId);
|
|
157
|
+
}
|
|
158
|
+
requireMutableTaskRecord(taskId, userId) {
|
|
159
|
+
this.assertUserIsActive(userId);
|
|
160
|
+
const task = this.requireTaskRecord(taskId, userId);
|
|
161
|
+
this.assertTaskIsActive(taskId);
|
|
162
|
+
this.assertNetworkPolicyStable(taskId);
|
|
163
|
+
return task;
|
|
164
|
+
}
|
|
165
|
+
assertTaskMutationAllowed(taskId, userId) {
|
|
166
|
+
this.requireMutableTaskRecord(taskId, userId);
|
|
167
|
+
}
|
|
168
|
+
assertUserIsActive(userId) {
|
|
169
|
+
if (this.retiringUsers.has(userId)) {
|
|
170
|
+
throw new WorkConflictError('This user is being deleted.');
|
|
171
|
+
}
|
|
172
|
+
const current = getDatabase()
|
|
173
|
+
.prepare('SELECT role FROM users WHERE id = ?')
|
|
174
|
+
.get(userId);
|
|
175
|
+
if (current?.role !== 'admin') {
|
|
176
|
+
throw new WorkForbiddenError();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
assertTaskIsActive(taskId) {
|
|
180
|
+
if (this.retiringTasks.has(taskId)) {
|
|
181
|
+
throw new WorkConflictError('This Work task is being deleted.');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
assertNetworkPolicyStable(taskId) {
|
|
185
|
+
if (this.networkPolicyChanges.has(taskId)) {
|
|
186
|
+
throw new WorkConflictError('This Work task network policy is being changed.');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
beginNetworkPolicyChange(taskId, userId) {
|
|
190
|
+
const task = this.requireMutableTaskRecord(taskId, userId);
|
|
191
|
+
if (this.getActiveRun(taskId)) {
|
|
192
|
+
throw new WorkConflictError('Network access cannot be changed during an active run.');
|
|
193
|
+
}
|
|
194
|
+
this.networkPolicyChanges.add(taskId);
|
|
195
|
+
return task;
|
|
196
|
+
}
|
|
197
|
+
releaseNetworkPolicyChange(taskId) {
|
|
198
|
+
this.networkPolicyChanges.delete(taskId);
|
|
199
|
+
}
|
|
200
|
+
beginPreview(taskId, userId, allowActiveRun = false) {
|
|
201
|
+
const transaction = getDatabase().transaction(() => {
|
|
202
|
+
const task = this.requireMutableTaskRecord(taskId, userId);
|
|
203
|
+
const activeRun = this.getActiveRun(taskId);
|
|
204
|
+
if (activeRun && !allowActiveRun) {
|
|
205
|
+
throw new WorkConflictError('Wait for the active Work run to finish before starting a preview.');
|
|
206
|
+
}
|
|
207
|
+
this.assertRuntimeAdmission(userId, taskId);
|
|
208
|
+
if (!ACTIVE_PREVIEW_STATUSES.includes(task.previewStatus)) {
|
|
209
|
+
this.updatePreview(taskId, 'starting');
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
transaction();
|
|
213
|
+
}
|
|
214
|
+
getTaskDetail(taskId, userId) {
|
|
215
|
+
const row = this.getTaskRow(taskId, userId);
|
|
216
|
+
return row ? this.detailFromRow(row) : undefined;
|
|
217
|
+
}
|
|
218
|
+
requireTaskDetail(taskId, userId) {
|
|
219
|
+
const task = this.getTaskDetail(taskId, userId);
|
|
220
|
+
if (!task) {
|
|
221
|
+
throw new WorkNotFoundError();
|
|
222
|
+
}
|
|
223
|
+
return task;
|
|
224
|
+
}
|
|
225
|
+
getTaskRecord(taskId, userId) {
|
|
226
|
+
const row = this.getTaskRow(taskId, userId);
|
|
227
|
+
return row ? mapTaskRecord(row) : undefined;
|
|
228
|
+
}
|
|
229
|
+
requireTaskRecord(taskId, userId) {
|
|
230
|
+
const task = this.getTaskRecord(taskId, userId);
|
|
231
|
+
if (!task) {
|
|
232
|
+
throw new WorkNotFoundError();
|
|
233
|
+
}
|
|
234
|
+
return task;
|
|
235
|
+
}
|
|
236
|
+
updateTask(taskId, userId, updates) {
|
|
237
|
+
const task = this.requireMutableTaskRecord(taskId, userId);
|
|
238
|
+
if (updates.networkEnabled !== undefined &&
|
|
239
|
+
updates.networkEnabled !== task.networkEnabled &&
|
|
240
|
+
this.getActiveRun(taskId)) {
|
|
241
|
+
throw new WorkConflictError('Network access cannot be changed during an active run.');
|
|
242
|
+
}
|
|
243
|
+
const title = updates.title === undefined ? task.title : cleanTitle(updates.title);
|
|
244
|
+
const model = updates.model === undefined ? task.model : cleanRequired(updates.model);
|
|
245
|
+
const provider = normalizeProvider({
|
|
246
|
+
providerType: updates.providerType ?? task.providerType,
|
|
247
|
+
providerId: updates.providerType === 'ollama'
|
|
248
|
+
? undefined
|
|
249
|
+
: updates.providerId === undefined
|
|
250
|
+
? task.providerId
|
|
251
|
+
: updates.providerId,
|
|
252
|
+
});
|
|
253
|
+
const networkEnabled = updates.networkEnabled ?? task.networkEnabled;
|
|
254
|
+
getDatabase()
|
|
255
|
+
.prepare(`UPDATE work_tasks
|
|
256
|
+
SET title = ?, model = ?, provider_type = ?, provider_id = ?,
|
|
257
|
+
network_enabled = ?, updated_at = ?
|
|
258
|
+
WHERE id = ? AND user_id = ?`)
|
|
259
|
+
.run(title, model, provider.providerType, provider.providerId || null, networkEnabled ? 1 : 0, Date.now(), taskId, userId);
|
|
260
|
+
return this.requireTaskDetail(taskId, userId);
|
|
261
|
+
}
|
|
262
|
+
commitNetworkChange(taskId, userId, updates) {
|
|
263
|
+
this.assertUserIsActive(userId);
|
|
264
|
+
const task = this.requireTaskRecord(taskId, userId);
|
|
265
|
+
this.assertTaskIsActive(taskId);
|
|
266
|
+
if (!this.networkPolicyChanges.has(taskId)) {
|
|
267
|
+
throw new WorkConflictError('This Work task has no active network policy change.');
|
|
268
|
+
}
|
|
269
|
+
if (updates.networkEnabled !== task.networkEnabled &&
|
|
270
|
+
this.getActiveRun(taskId)) {
|
|
271
|
+
throw new WorkConflictError('Network access cannot be changed during an active run.');
|
|
272
|
+
}
|
|
273
|
+
const title = updates.title === undefined ? task.title : cleanTitle(updates.title);
|
|
274
|
+
const model = updates.model === undefined ? task.model : cleanRequired(updates.model);
|
|
275
|
+
const provider = normalizeProvider({
|
|
276
|
+
providerType: updates.providerType ?? task.providerType,
|
|
277
|
+
providerId: updates.providerType === 'ollama'
|
|
278
|
+
? undefined
|
|
279
|
+
: updates.providerId === undefined
|
|
280
|
+
? task.providerId
|
|
281
|
+
: updates.providerId,
|
|
282
|
+
});
|
|
283
|
+
const networkChanged = updates.networkEnabled !== task.networkEnabled;
|
|
284
|
+
getDatabase()
|
|
285
|
+
.prepare(`UPDATE work_tasks
|
|
286
|
+
SET title = ?, model = ?, provider_type = ?, provider_id = ?,
|
|
287
|
+
network_enabled = ?,
|
|
288
|
+
preview_status = CASE
|
|
289
|
+
WHEN ? = 1 THEN 'stopped'
|
|
290
|
+
ELSE preview_status
|
|
291
|
+
END,
|
|
292
|
+
preview_url = CASE
|
|
293
|
+
WHEN ? = 1 THEN NULL
|
|
294
|
+
ELSE preview_url
|
|
295
|
+
END,
|
|
296
|
+
updated_at = ?
|
|
297
|
+
WHERE id = ? AND user_id = ?`)
|
|
298
|
+
.run(title, model, provider.providerType, provider.providerId || null, updates.networkEnabled ? 1 : 0, networkChanged ? 1 : 0, networkChanged ? 1 : 0, Date.now(), taskId, userId);
|
|
299
|
+
}
|
|
300
|
+
assertTaskAdmission(userId) {
|
|
301
|
+
const counts = getDatabase()
|
|
302
|
+
.prepare(`SELECT
|
|
303
|
+
COUNT(*) AS total,
|
|
304
|
+
COALESCE(SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END), 0) AS per_user
|
|
305
|
+
FROM work_tasks`)
|
|
306
|
+
.get(userId);
|
|
307
|
+
if (counts.per_user >= workAdmissionLimits.maxTasksPerUser) {
|
|
308
|
+
throw new WorkAdmissionError(`This administrator already has the maximum of ${workAdmissionLimits.maxTasksPerUser} Work tasks.`, 'WORK_USER_TASK_LIMIT');
|
|
309
|
+
}
|
|
310
|
+
if (counts.total >= workAdmissionLimits.maxTasksGlobal) {
|
|
311
|
+
throw new WorkAdmissionError(`This Libre WebUI instance already has the maximum of ${workAdmissionLimits.maxTasksGlobal} Work tasks.`, 'WORK_GLOBAL_TASK_LIMIT');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
assertRuntimeAdmission(userId, taskId) {
|
|
315
|
+
const activeRunPlaceholders = ACTIVE_RUN_STATUSES.map(() => '?').join(', ');
|
|
316
|
+
const activePreviewPlaceholders = ACTIVE_PREVIEW_STATUSES.map(() => '?').join(', ');
|
|
317
|
+
const counts = getDatabase()
|
|
318
|
+
.prepare(`WITH active_tasks AS (
|
|
319
|
+
SELECT DISTINCT work_tasks.id AS task_id, work_tasks.user_id
|
|
320
|
+
FROM work_tasks
|
|
321
|
+
JOIN work_runs ON work_runs.task_id = work_tasks.id
|
|
322
|
+
WHERE work_runs.status IN (${activeRunPlaceholders})
|
|
323
|
+
UNION
|
|
324
|
+
SELECT id AS task_id, user_id
|
|
325
|
+
FROM work_tasks
|
|
326
|
+
WHERE preview_status IN (${activePreviewPlaceholders})
|
|
327
|
+
)
|
|
328
|
+
SELECT
|
|
329
|
+
COUNT(*) AS total,
|
|
330
|
+
COALESCE(SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END), 0) AS per_user
|
|
331
|
+
FROM active_tasks
|
|
332
|
+
WHERE ? IS NULL OR task_id != ?`)
|
|
333
|
+
.get(...ACTIVE_RUN_STATUSES, ...ACTIVE_PREVIEW_STATUSES, userId, taskId || null, taskId || null);
|
|
334
|
+
if (counts.per_user >= workAdmissionLimits.maxActiveRuntimesPerUser) {
|
|
335
|
+
throw new WorkAdmissionError(`This administrator already has ${workAdmissionLimits.maxActiveRuntimesPerUser} active Work runtime(s). Wait for a run or preview to stop.`, 'WORK_USER_RUNTIME_LIMIT');
|
|
336
|
+
}
|
|
337
|
+
if (counts.total >= workAdmissionLimits.maxActiveRuntimesGlobal) {
|
|
338
|
+
throw new WorkAdmissionError(`This Libre WebUI instance already has ${workAdmissionLimits.maxActiveRuntimesGlobal} active Work runtime(s). Wait for a run or preview to stop.`, 'WORK_GLOBAL_RUNTIME_LIMIT');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
deleteTask(taskId, userId) {
|
|
342
|
+
const task = this.requireTaskRecord(taskId, userId);
|
|
343
|
+
const result = getDatabase()
|
|
344
|
+
.prepare('DELETE FROM work_tasks WHERE id = ? AND user_id = ?')
|
|
345
|
+
.run(taskId, userId);
|
|
346
|
+
if (!result.changes) {
|
|
347
|
+
throw new WorkNotFoundError();
|
|
348
|
+
}
|
|
349
|
+
return task;
|
|
350
|
+
}
|
|
351
|
+
addMessage(taskId, runId, role, kind, content, metadata) {
|
|
352
|
+
const db = getDatabase();
|
|
353
|
+
const id = uuidv4();
|
|
354
|
+
const createdAt = Date.now();
|
|
355
|
+
const messageIndex = this.nextMessageIndex(taskId);
|
|
356
|
+
const boundedContent = boundUtf8(content, WORK_MESSAGE_MAX_BYTES);
|
|
357
|
+
db.prepare(`INSERT INTO work_messages (
|
|
358
|
+
id, task_id, run_id, role, kind, content, metadata,
|
|
359
|
+
message_index, created_at
|
|
360
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, taskId, runId || null, role, kind, boundedContent, metadata ? JSON.stringify(metadata) : null, messageIndex, createdAt);
|
|
361
|
+
this.touchTask(taskId);
|
|
362
|
+
return {
|
|
363
|
+
id,
|
|
364
|
+
taskId,
|
|
365
|
+
runId,
|
|
366
|
+
messageIndex,
|
|
367
|
+
role,
|
|
368
|
+
kind,
|
|
369
|
+
content: boundedContent,
|
|
370
|
+
metadata,
|
|
371
|
+
createdAt,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
getMessages(taskId) {
|
|
375
|
+
const rows = getDatabase()
|
|
376
|
+
.prepare(`SELECT id, task_id, run_id, message_index, role, kind, content,
|
|
377
|
+
metadata, created_at
|
|
378
|
+
FROM work_messages
|
|
379
|
+
WHERE task_id = ?
|
|
380
|
+
ORDER BY message_index ASC`)
|
|
381
|
+
.all(taskId);
|
|
382
|
+
return rows.map(mapMessage);
|
|
383
|
+
}
|
|
384
|
+
getRecentConversationMessages(taskId, limit = 30) {
|
|
385
|
+
const boundedLimit = Math.min(30, Math.max(1, Math.trunc(limit)));
|
|
386
|
+
const rows = getDatabase()
|
|
387
|
+
.prepare(`SELECT id, task_id, run_id, message_index, role, kind, content,
|
|
388
|
+
metadata, created_at
|
|
389
|
+
FROM work_messages
|
|
390
|
+
WHERE task_id = ?
|
|
391
|
+
AND kind = 'message'
|
|
392
|
+
AND role IN ('user', 'assistant')
|
|
393
|
+
ORDER BY message_index DESC
|
|
394
|
+
LIMIT ?`)
|
|
395
|
+
.all(taskId, boundedLimit);
|
|
396
|
+
const selected = [];
|
|
397
|
+
let selectedBytes = 0;
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
const bytes = Buffer.byteLength(row.content, 'utf8');
|
|
400
|
+
if (selected.length > 0 &&
|
|
401
|
+
selectedBytes + bytes > WORK_CONTEXT_MAX_BYTES) {
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
selected.push(row);
|
|
405
|
+
selectedBytes += Math.min(bytes, WORK_MESSAGE_MAX_BYTES);
|
|
406
|
+
}
|
|
407
|
+
return selected.reverse().map(mapMessage);
|
|
408
|
+
}
|
|
409
|
+
getMessagePage(taskId, before, limit = WORK_MESSAGE_PAGE_SIZE) {
|
|
410
|
+
const pageSize = Math.min(WORK_MESSAGE_PAGE_SIZE, Math.max(1, Math.trunc(limit)));
|
|
411
|
+
const rows = (before === undefined
|
|
412
|
+
? getDatabase()
|
|
413
|
+
.prepare(`SELECT id, task_id, run_id, message_index, role, kind, content,
|
|
414
|
+
metadata, created_at
|
|
415
|
+
FROM work_messages
|
|
416
|
+
WHERE task_id = ?
|
|
417
|
+
ORDER BY message_index DESC
|
|
418
|
+
LIMIT ?`)
|
|
419
|
+
.all(taskId, pageSize + 1)
|
|
420
|
+
: getDatabase()
|
|
421
|
+
.prepare(`SELECT id, task_id, run_id, message_index, role, kind, content,
|
|
422
|
+
metadata, created_at
|
|
423
|
+
FROM work_messages
|
|
424
|
+
WHERE task_id = ? AND message_index < ?
|
|
425
|
+
ORDER BY message_index DESC
|
|
426
|
+
LIMIT ?`)
|
|
427
|
+
.all(taskId, before, pageSize + 1));
|
|
428
|
+
const selectedRows = [];
|
|
429
|
+
let selectedBytes = 0;
|
|
430
|
+
for (const row of rows.slice(0, pageSize)) {
|
|
431
|
+
const bytes = Math.min(Buffer.byteLength(row.content, 'utf8'), WORK_MESSAGE_MAX_BYTES);
|
|
432
|
+
if (selectedRows.length > 0 &&
|
|
433
|
+
selectedBytes + bytes > WORK_MESSAGE_PAGE_MAX_BYTES) {
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
selectedRows.push(row);
|
|
437
|
+
selectedBytes += bytes;
|
|
438
|
+
}
|
|
439
|
+
const hasMore = rows.length > selectedRows.length || rows.length > pageSize;
|
|
440
|
+
const pageRows = selectedRows.reverse();
|
|
441
|
+
return {
|
|
442
|
+
messages: pageRows.map(mapMessage),
|
|
443
|
+
cursor: hasMore ? pageRows[0]?.message_index : undefined,
|
|
444
|
+
hasMore,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
getRun(runId) {
|
|
448
|
+
const row = getDatabase()
|
|
449
|
+
.prepare('SELECT * FROM work_runs WHERE id = ?')
|
|
450
|
+
.get(runId);
|
|
451
|
+
return row ? mapRun(row) : undefined;
|
|
452
|
+
}
|
|
453
|
+
getActiveRun(taskId) {
|
|
454
|
+
const placeholders = ACTIVE_RUN_STATUSES.map(() => '?').join(', ');
|
|
455
|
+
const row = getDatabase()
|
|
456
|
+
.prepare(`SELECT * FROM work_runs
|
|
457
|
+
WHERE task_id = ? AND status IN (${placeholders})
|
|
458
|
+
ORDER BY created_at DESC LIMIT 1`)
|
|
459
|
+
.get(taskId, ...ACTIVE_RUN_STATUSES);
|
|
460
|
+
return row ? mapRun(row) : undefined;
|
|
461
|
+
}
|
|
462
|
+
updateRun(runId, status, options = {}) {
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
getDatabase()
|
|
465
|
+
.prepare(`UPDATE work_runs
|
|
466
|
+
SET status = ?,
|
|
467
|
+
error = ?,
|
|
468
|
+
started_at = CASE WHEN ? = 1 THEN COALESCE(started_at, ?) ELSE started_at END,
|
|
469
|
+
finished_at = CASE WHEN ? = 1 THEN ? ELSE finished_at END
|
|
470
|
+
WHERE id = ?`)
|
|
471
|
+
.run(status, options.error || null, options.started ? 1 : 0, now, options.finished ? 1 : 0, now, runId);
|
|
472
|
+
const run = this.getRun(runId);
|
|
473
|
+
if (!run) {
|
|
474
|
+
throw new WorkNotFoundError('Work run not found.');
|
|
475
|
+
}
|
|
476
|
+
return run;
|
|
477
|
+
}
|
|
478
|
+
updateTaskStatus(taskId, status) {
|
|
479
|
+
getDatabase()
|
|
480
|
+
.prepare(`UPDATE work_tasks
|
|
481
|
+
SET status = ?, updated_at = ?
|
|
482
|
+
WHERE id = ?`)
|
|
483
|
+
.run(status, Date.now(), taskId);
|
|
484
|
+
}
|
|
485
|
+
updatePreview(taskId, status, previewUrl) {
|
|
486
|
+
getDatabase()
|
|
487
|
+
.prepare(`UPDATE work_tasks
|
|
488
|
+
SET preview_status = ?, preview_url = ?, updated_at = ?
|
|
489
|
+
WHERE id = ?`)
|
|
490
|
+
.run(status, previewUrl || null, Date.now(), taskId);
|
|
491
|
+
}
|
|
492
|
+
recoverOnStartup() {
|
|
493
|
+
let db;
|
|
494
|
+
let taskRows;
|
|
495
|
+
try {
|
|
496
|
+
db = getDatabase();
|
|
497
|
+
taskRows = db.prepare('SELECT * FROM work_tasks').all();
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
logger.error('Failed to read Work tasks for startup recovery:', error);
|
|
501
|
+
// Without the complete task inventory we cannot prove that every
|
|
502
|
+
// preexisting command or preview container has been stopped. Abort
|
|
503
|
+
// startup instead of allowing Work to run without supervision.
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
let interruptedRuns = 0;
|
|
507
|
+
let activePreviews = 0;
|
|
508
|
+
try {
|
|
509
|
+
interruptedRuns = db
|
|
510
|
+
.prepare(`SELECT COUNT(*) AS count FROM work_runs
|
|
511
|
+
WHERE status IN ('queued', 'preparing', 'running')`)
|
|
512
|
+
.get().count;
|
|
513
|
+
activePreviews = db
|
|
514
|
+
.prepare(`SELECT COUNT(*) AS count FROM work_tasks
|
|
515
|
+
WHERE preview_status IN ('starting', 'running')`)
|
|
516
|
+
.get().count;
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
logger.error('Failed to count interrupted Work state:', error);
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const now = Date.now();
|
|
523
|
+
const transaction = db.transaction(() => {
|
|
524
|
+
db.prepare(`UPDATE work_tasks
|
|
525
|
+
SET status = 'failed', updated_at = ?
|
|
526
|
+
WHERE status IN ('preparing', 'running')
|
|
527
|
+
OR id IN (
|
|
528
|
+
SELECT task_id FROM work_runs
|
|
529
|
+
WHERE status IN ('queued', 'preparing', 'running')
|
|
530
|
+
)`).run(now);
|
|
531
|
+
db.prepare(`UPDATE work_tasks
|
|
532
|
+
SET preview_status = 'stopped', preview_url = NULL, updated_at = ?
|
|
533
|
+
WHERE preview_status != 'stopped' OR preview_url IS NOT NULL`).run(now);
|
|
534
|
+
db.prepare(`UPDATE work_runs
|
|
535
|
+
SET status = 'failed',
|
|
536
|
+
error = 'Backend restarted while this run was active.',
|
|
537
|
+
finished_at = ?
|
|
538
|
+
WHERE status IN ('queued', 'preparing', 'running')`).run(now);
|
|
539
|
+
});
|
|
540
|
+
transaction();
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
// Container teardown is the security-critical half of recovery. Return
|
|
544
|
+
// the rows already read even if SQLite cannot persist terminal states,
|
|
545
|
+
// so startup can still stop every known command and preview process.
|
|
546
|
+
logger.error('Failed to persist recovered Work state on startup:', error);
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
tasks: taskRows.map(mapTaskRecord),
|
|
550
|
+
interruptedRuns,
|
|
551
|
+
activePreviews,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
getTaskRow(taskId, userId) {
|
|
555
|
+
return getDatabase()
|
|
556
|
+
.prepare('SELECT * FROM work_tasks WHERE id = ? AND user_id = ?')
|
|
557
|
+
.get(taskId, userId);
|
|
558
|
+
}
|
|
559
|
+
detailFromRow(row) {
|
|
560
|
+
const messagePage = this.getMessagePage(row.id);
|
|
561
|
+
return {
|
|
562
|
+
...this.summaryFromRow(row),
|
|
563
|
+
messages: messagePage.messages,
|
|
564
|
+
messageCursor: messagePage.cursor,
|
|
565
|
+
hasMoreMessages: messagePage.hasMore,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
summaryFromRow(row) {
|
|
569
|
+
return {
|
|
570
|
+
id: row.id,
|
|
571
|
+
title: row.title,
|
|
572
|
+
model: row.model,
|
|
573
|
+
providerType: row.provider_type,
|
|
574
|
+
providerId: row.provider_id || undefined,
|
|
575
|
+
status: row.status,
|
|
576
|
+
networkEnabled: Boolean(row.network_enabled),
|
|
577
|
+
createdAt: row.created_at,
|
|
578
|
+
updatedAt: row.updated_at,
|
|
579
|
+
activeRun: this.getActiveRun(row.id),
|
|
580
|
+
previewUrl: row.preview_url || undefined,
|
|
581
|
+
previewStatus: row.preview_status,
|
|
582
|
+
workspacePath: '/workspace',
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
nextMessageIndex(taskId) {
|
|
586
|
+
const row = getDatabase()
|
|
587
|
+
.prepare(`SELECT COALESCE(MAX(message_index), -1) + 1 AS next_index
|
|
588
|
+
FROM work_messages WHERE task_id = ?`)
|
|
589
|
+
.get(taskId);
|
|
590
|
+
return row.next_index;
|
|
591
|
+
}
|
|
592
|
+
touchTask(taskId) {
|
|
593
|
+
getDatabase()
|
|
594
|
+
.prepare('UPDATE work_tasks SET updated_at = ? WHERE id = ?')
|
|
595
|
+
.run(Date.now(), taskId);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
export class WorkNotFoundError extends Error {
|
|
599
|
+
constructor(message = 'Work task not found.') {
|
|
600
|
+
super(message);
|
|
601
|
+
this.status = 404;
|
|
602
|
+
this.name = 'WorkNotFoundError';
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
export class WorkConflictError extends Error {
|
|
606
|
+
constructor(message) {
|
|
607
|
+
super(message);
|
|
608
|
+
this.status = 409;
|
|
609
|
+
this.name = 'WorkConflictError';
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
export class WorkForbiddenError extends Error {
|
|
613
|
+
constructor(message = 'Admin access is required for Work tasks.') {
|
|
614
|
+
super(message);
|
|
615
|
+
this.status = 403;
|
|
616
|
+
this.name = 'WorkForbiddenError';
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
export class WorkAdmissionError extends Error {
|
|
620
|
+
constructor(message, code) {
|
|
621
|
+
super(message);
|
|
622
|
+
this.status = 429;
|
|
623
|
+
this.name = 'WorkAdmissionError';
|
|
624
|
+
this.code = code;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const mapTaskRecord = (row) => ({
|
|
628
|
+
id: row.id,
|
|
629
|
+
userId: row.user_id,
|
|
630
|
+
title: row.title,
|
|
631
|
+
model: row.model,
|
|
632
|
+
providerType: row.provider_type,
|
|
633
|
+
providerId: row.provider_id || undefined,
|
|
634
|
+
status: row.status,
|
|
635
|
+
networkEnabled: Boolean(row.network_enabled),
|
|
636
|
+
volumeName: row.volume_name,
|
|
637
|
+
containerName: row.container_name,
|
|
638
|
+
previewUrl: row.preview_url || undefined,
|
|
639
|
+
previewStatus: row.preview_status,
|
|
640
|
+
createdAt: row.created_at,
|
|
641
|
+
updatedAt: row.updated_at,
|
|
642
|
+
});
|
|
643
|
+
const mapRun = (row) => ({
|
|
644
|
+
id: row.id,
|
|
645
|
+
taskId: row.task_id,
|
|
646
|
+
model: row.model,
|
|
647
|
+
providerType: row.provider_type,
|
|
648
|
+
providerId: row.provider_id || undefined,
|
|
649
|
+
status: row.status,
|
|
650
|
+
error: row.error || undefined,
|
|
651
|
+
createdAt: row.created_at,
|
|
652
|
+
startedAt: row.started_at || undefined,
|
|
653
|
+
finishedAt: row.finished_at || undefined,
|
|
654
|
+
});
|
|
655
|
+
const mapMessage = (row) => ({
|
|
656
|
+
id: row.id,
|
|
657
|
+
taskId: row.task_id,
|
|
658
|
+
runId: row.run_id || undefined,
|
|
659
|
+
messageIndex: row.message_index,
|
|
660
|
+
role: row.role,
|
|
661
|
+
kind: row.kind,
|
|
662
|
+
content: boundUtf8(row.content, WORK_MESSAGE_MAX_BYTES),
|
|
663
|
+
metadata: parseMetadata(row.metadata),
|
|
664
|
+
createdAt: row.created_at,
|
|
665
|
+
});
|
|
666
|
+
const parseMetadata = (value) => {
|
|
667
|
+
if (!value)
|
|
668
|
+
return undefined;
|
|
669
|
+
try {
|
|
670
|
+
const parsed = JSON.parse(value);
|
|
671
|
+
return parsed && typeof parsed === 'object'
|
|
672
|
+
? parsed
|
|
673
|
+
: undefined;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
const deriveTitle = (message) => {
|
|
680
|
+
const firstLine = message.trim().split(/\r?\n/, 1)[0] || 'New Work';
|
|
681
|
+
return cleanTitle(firstLine.slice(0, 80));
|
|
682
|
+
};
|
|
683
|
+
const cleanTitle = (value) => value.trim().slice(0, 120) || 'New Work';
|
|
684
|
+
const cleanRequired = (value) => {
|
|
685
|
+
const cleaned = value.trim();
|
|
686
|
+
if (!cleaned) {
|
|
687
|
+
throw new Error('Model cannot be empty.');
|
|
688
|
+
}
|
|
689
|
+
return cleaned;
|
|
690
|
+
};
|
|
691
|
+
function positiveInteger(value, fallback) {
|
|
692
|
+
const parsed = Number(value);
|
|
693
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
694
|
+
}
|
|
695
|
+
const normalizeProvider = (provider) => {
|
|
696
|
+
if (provider.providerType === 'ollama') {
|
|
697
|
+
return { providerType: 'ollama' };
|
|
698
|
+
}
|
|
699
|
+
const providerId = provider.providerId?.trim();
|
|
700
|
+
if (!providerId) {
|
|
701
|
+
throw new Error('Plugin provider ID cannot be empty.');
|
|
702
|
+
}
|
|
703
|
+
return { providerType: 'plugin', providerId };
|
|
704
|
+
};
|
|
705
|
+
const boundUtf8 = (value, maxBytes) => {
|
|
706
|
+
if (Buffer.byteLength(value, 'utf8') <= maxBytes)
|
|
707
|
+
return value;
|
|
708
|
+
const suffix = '\n... message truncated ...';
|
|
709
|
+
const budget = Math.max(0, maxBytes - Buffer.byteLength(suffix, 'utf8'));
|
|
710
|
+
const prefix = Buffer.from(value, 'utf8')
|
|
711
|
+
.subarray(0, budget)
|
|
712
|
+
.toString('utf8')
|
|
713
|
+
.replace(/\uFFFD$/, '');
|
|
714
|
+
return `${prefix}${suffix}`;
|
|
715
|
+
};
|
|
716
|
+
export const workTaskService = new WorkTaskService();
|
|
717
|
+
export default workTaskService;
|
|
718
|
+
//# sourceMappingURL=workTaskService.js.map
|