@yeaft/webchat-agent 1.0.155 → 1.0.157
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/package.json +1 -1
- package/yeaft/llm/openai-responses.js +13 -11
- package/yeaft/models.js +20 -5
- package/yeaft/work-center/attachments.js +126 -0
- package/yeaft/work-center/bridge.js +15 -18
- package/yeaft/work-center/controller.js +6 -4
- package/yeaft/work-center/service.js +50 -5
- package/yeaft/work-center/store.js +7 -2
package/package.json
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from './adapter.js';
|
|
42
42
|
import {
|
|
43
43
|
normalizeEffort,
|
|
44
|
+
getModelEffortOptions,
|
|
44
45
|
getThinkingCapability,
|
|
45
46
|
mapEffortToOpenAIReasoning,
|
|
46
47
|
} from '../models.js';
|
|
@@ -57,10 +58,9 @@ function thinkingV1Enabled() {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
|
-
* Translate a normalised effort
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* adaptive efforts must be dropped, not downgraded.
|
|
61
|
+
* Translate a normalised effort into the OpenAI Responses `reasoning.effort`
|
|
62
|
+
* field. Model capability filtering happens at the call site because `max` is
|
|
63
|
+
* supported by GPT-5.6 but not by older OpenAI reasoning models.
|
|
64
64
|
*/
|
|
65
65
|
function effortForResponses(effort) {
|
|
66
66
|
return mapEffortToOpenAIReasoning(effort);
|
|
@@ -248,7 +248,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
248
248
|
// ─── Streaming ──────────────────────────────────────────
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'minimal'|'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
|
|
251
|
+
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'minimal'|'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', effortContext?: object, extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void, onRequestStart?: () => void }} params
|
|
252
252
|
*
|
|
253
253
|
* NOTE on `extraBody`: any keys you spread here are merged verbatim into
|
|
254
254
|
* the wire body and — because the verbatim debug feature is intentionally
|
|
@@ -257,7 +257,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
257
257
|
* `api-key` headers are auto-redacted (see `redactRawRequest` in
|
|
258
258
|
* `adapter.js`); request-body fields are caller-controlled.
|
|
259
259
|
*/
|
|
260
|
-
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, extraBody, signal, onRawExchange, onRequestStart }) {
|
|
260
|
+
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext = {}, extraBody, signal, onRawExchange, onRequestStart }) {
|
|
261
261
|
if (signal?.aborted) throw new LLMAbortError();
|
|
262
262
|
|
|
263
263
|
const body = {
|
|
@@ -276,8 +276,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
276
276
|
// Unknown / unsupported models silently drop the field.
|
|
277
277
|
const normEffort = normalizeEffort(effort);
|
|
278
278
|
if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
|
|
279
|
-
const cap = getThinkingCapability(model);
|
|
280
|
-
|
|
279
|
+
const cap = getThinkingCapability(model, effortContext);
|
|
280
|
+
const supportedEfforts = getModelEffortOptions(model, effortContext);
|
|
281
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning' && supportedEfforts.includes(normEffort)) {
|
|
281
282
|
const wireEffort = effortForResponses(normEffort);
|
|
282
283
|
if (wireEffort) body.reasoning = { effort: wireEffort };
|
|
283
284
|
}
|
|
@@ -517,7 +518,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
517
518
|
* expose them, mirror the stream() instrumentation. Parity with
|
|
518
519
|
* anthropic.js's `call()`.
|
|
519
520
|
*/
|
|
520
|
-
async call({ model, system, messages, maxTokens = 4096, effort, effortSource, extraBody, signal, onRequestStart }) {
|
|
521
|
+
async call({ model, system, messages, maxTokens = 4096, effort, effortSource, effortContext = {}, extraBody, signal, onRequestStart }) {
|
|
521
522
|
if (signal?.aborted) throw new LLMAbortError();
|
|
522
523
|
|
|
523
524
|
const body = {
|
|
@@ -530,8 +531,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
530
531
|
// Mirror stream()'s thinking injection for non-streaming side queries.
|
|
531
532
|
const normEffort = normalizeEffort(effort);
|
|
532
533
|
if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
|
|
533
|
-
const cap = getThinkingCapability(model);
|
|
534
|
-
|
|
534
|
+
const cap = getThinkingCapability(model, effortContext);
|
|
535
|
+
const supportedEfforts = getModelEffortOptions(model, effortContext);
|
|
536
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning' && supportedEfforts.includes(normEffort)) {
|
|
535
537
|
const wireEffort = effortForResponses(normEffort);
|
|
536
538
|
if (wireEffort) body.reasoning = { effort: wireEffort };
|
|
537
539
|
}
|
package/yeaft/models.js
CHANGED
|
@@ -34,7 +34,7 @@ import { lookupModelLimitSync } from './llm/models-dev.js';
|
|
|
34
34
|
* @property {'anthropic' | 'anthropic-adaptive' | 'openai-reasoning' | 'none'} [thinkingProtocol] — task-327a:
|
|
35
35
|
* 'anthropic' → thinking:{type:'enabled', budget_tokens:N}
|
|
36
36
|
* 'anthropic-adaptive' → thinking:{type:'adaptive'} + output_config:{effort}
|
|
37
|
-
* 'openai-reasoning' → reasoning:{effort:'minimal'|'low'|'medium'|'high'|'xhigh'}
|
|
37
|
+
* 'openai-reasoning' → reasoning:{effort:'minimal'|'low'|'medium'|'high'|'xhigh'|'max'}
|
|
38
38
|
* 'none' (default) → parameter silently dropped by router
|
|
39
39
|
* @property {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null} [defaultEffort] — adapter-level default
|
|
40
40
|
* when caller doesn't specify effort (null = no default / decision-tree decides).
|
|
@@ -405,12 +405,12 @@ export const ANTHROPIC_THINKING_BUDGETS = {
|
|
|
405
405
|
};
|
|
406
406
|
|
|
407
407
|
/**
|
|
408
|
-
* Map a Yeaft effort level to the OpenAI reasoning.effort enum.
|
|
409
|
-
*
|
|
410
|
-
*
|
|
408
|
+
* Map a Yeaft effort level to the OpenAI reasoning.effort enum. Supported
|
|
409
|
+
* values are model-dependent; capability filtering happens before this wire
|
|
410
|
+
* translation. Unsupported values are dropped; the adapter MUST NOT error.
|
|
411
411
|
*
|
|
412
412
|
* @param {Effort} effort
|
|
413
|
-
* @returns {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | null}
|
|
413
|
+
* @returns {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null}
|
|
414
414
|
*/
|
|
415
415
|
export function mapEffortToOpenAIReasoning(effort) {
|
|
416
416
|
if (!effort) return null;
|
|
@@ -420,11 +420,13 @@ export function mapEffortToOpenAIReasoning(effort) {
|
|
|
420
420
|
case 'medium': return 'medium';
|
|
421
421
|
case 'high': return 'high';
|
|
422
422
|
case 'xhigh': return 'xhigh';
|
|
423
|
+
case 'max': return 'max';
|
|
423
424
|
default: return null;
|
|
424
425
|
}
|
|
425
426
|
}
|
|
426
427
|
|
|
427
428
|
export const OPENAI_REASONING_EFFORT_OPTIONS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
|
|
429
|
+
export const OPENAI_MAX_REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
428
430
|
export const ANTHROPIC_MANUAL_EFFORT_OPTIONS = ['low', 'medium', 'high'];
|
|
429
431
|
export const ANTHROPIC_ADAPTIVE_EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
430
432
|
export const ANTHROPIC_ADAPTIVE_MAX_EFFORT_OPTIONS = ['low', 'medium', 'high', 'max'];
|
|
@@ -454,6 +456,19 @@ function inferThinkingCapability(model) {
|
|
|
454
456
|
const id = parseModelRef(model).modelId.toLowerCase();
|
|
455
457
|
if (!id) return null;
|
|
456
458
|
|
|
459
|
+
// GPT-5.6 adds the model-specific `max` tier. Keep this ahead of the generic
|
|
460
|
+
// GPT inference so older OpenAI models do not advertise a value they reject.
|
|
461
|
+
// Provider suffixes such as gpt-5.6-sol are variants of the same model family.
|
|
462
|
+
if (/^gpt-5\.6($|[-.])/.test(id)) {
|
|
463
|
+
return {
|
|
464
|
+
supportsThinking: true,
|
|
465
|
+
thinkingProtocol: 'openai-reasoning',
|
|
466
|
+
defaultEffort: null,
|
|
467
|
+
maxBudgetTokens: null,
|
|
468
|
+
effortOptions: OPENAI_MAX_REASONING_EFFORT_OPTIONS,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
457
472
|
if (/^(gpt-5|o1|o3|o4|chatgpt-|codex-)/.test(id)) {
|
|
458
473
|
return { supportsThinking: true, thinkingProtocol: 'openai-reasoning', defaultEffort: null, maxBudgetTokens: null };
|
|
459
474
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readFileSync,
|
|
10
10
|
realpathSync,
|
|
11
11
|
rmSync,
|
|
12
|
+
unlinkSync,
|
|
12
13
|
writeFileSync,
|
|
13
14
|
} from 'node:fs';
|
|
14
15
|
import { createHash, randomUUID } from 'node:crypto';
|
|
@@ -175,6 +176,43 @@ function removeCreatedDirectory(state) {
|
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
function openAttachmentDirectory(root, workItemId) {
|
|
180
|
+
if (process.platform !== 'linux') {
|
|
181
|
+
throw new Error('Secure WorkItem attachment access requires Linux');
|
|
182
|
+
}
|
|
183
|
+
const { attachmentRoot, itemDirectory } = attachmentDirectory(root, workItemId);
|
|
184
|
+
const rootDescriptor = openDirectory(attachmentRoot, 'WorkItem attachment root');
|
|
185
|
+
try {
|
|
186
|
+
const ownerName = safeWorkItemId(workItemId);
|
|
187
|
+
const itemDescriptor = openDirectory(
|
|
188
|
+
`/proc/self/fd/${rootDescriptor}/${ownerName}`,
|
|
189
|
+
'WorkItem attachment owner directory',
|
|
190
|
+
);
|
|
191
|
+
try {
|
|
192
|
+
assertDescriptorMatchesPath(rootDescriptor, attachmentRoot, 'WorkItem attachment root');
|
|
193
|
+
assertDescriptorMatchesPath(itemDescriptor, itemDirectory, 'WorkItem attachment owner directory');
|
|
194
|
+
return { attachmentRoot, itemDirectory, ownerName, rootDescriptor, itemDescriptor };
|
|
195
|
+
} catch (error) {
|
|
196
|
+
closeSync(itemDescriptor);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
closeSync(rootDescriptor);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function removeAttachmentFile(directoryState, storageName) {
|
|
206
|
+
if (!/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) return;
|
|
207
|
+
assertDescriptorMatchesPath(directoryState.rootDescriptor, directoryState.attachmentRoot, 'WorkItem attachment root');
|
|
208
|
+
assertDescriptorMatchesPath(directoryState.itemDescriptor, directoryState.itemDirectory, 'WorkItem attachment owner directory');
|
|
209
|
+
try {
|
|
210
|
+
unlinkSync(`/proc/self/fd/${directoryState.itemDescriptor}/${storageName}`);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
178
216
|
export function persistWorkItemAttachments(files, options = {}) {
|
|
179
217
|
if (!Array.isArray(files) || files.length === 0) return [];
|
|
180
218
|
if (files.length > MAX_WORK_ITEM_ATTACHMENTS) {
|
|
@@ -226,6 +264,74 @@ export function persistWorkItemAttachments(files, options = {}) {
|
|
|
226
264
|
}
|
|
227
265
|
}
|
|
228
266
|
|
|
267
|
+
export function appendWorkItemAttachments(existing, files, options = {}) {
|
|
268
|
+
if (!Array.isArray(files) || files.length === 0) return [];
|
|
269
|
+
const current = Array.isArray(existing) ? existing : [];
|
|
270
|
+
if (current.length === 0) return persistWorkItemAttachments(files, options);
|
|
271
|
+
if (current.length + files.length > MAX_WORK_ITEM_ATTACHMENTS) {
|
|
272
|
+
throw new Error(`WorkItem supports at most ${MAX_WORK_ITEM_ATTACHMENTS} attachments`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let totalBytes = current.reduce((total, attachment) => total + (Number(attachment?.size) || 0), 0);
|
|
276
|
+
const prepared = files.map((file, index) => {
|
|
277
|
+
if (!file || typeof file !== 'object' || Array.isArray(file)) {
|
|
278
|
+
throw new Error(`Attachment ${index + 1} is invalid`);
|
|
279
|
+
}
|
|
280
|
+
const name = safeDisplayName(file.name, current.length + index);
|
|
281
|
+
const mimeType = typeof file.mimeType === 'string' && file.mimeType.trim()
|
|
282
|
+
? file.mimeType.trim().slice(0, 255)
|
|
283
|
+
: 'application/octet-stream';
|
|
284
|
+
const kind = assertSupportedWorkItemAttachment(name, mimeType);
|
|
285
|
+
const buffer = decodeBase64(file.data);
|
|
286
|
+
assertWorkItemAttachmentSize(buffer.length);
|
|
287
|
+
totalBytes += buffer.length;
|
|
288
|
+
if (totalBytes > MAX_WORK_ITEM_ATTACHMENT_BYTES) {
|
|
289
|
+
throw new Error(`WorkItem attachments exceed ${MAX_WORK_ITEM_ATTACHMENT_BYTES} bytes`);
|
|
290
|
+
}
|
|
291
|
+
const id = randomUUID();
|
|
292
|
+
return {
|
|
293
|
+
buffer,
|
|
294
|
+
attachment: {
|
|
295
|
+
id,
|
|
296
|
+
name,
|
|
297
|
+
storageName: `${id}${safeExtension(name)}`,
|
|
298
|
+
mimeType,
|
|
299
|
+
size: buffer.length,
|
|
300
|
+
sha256: digest(buffer),
|
|
301
|
+
kind,
|
|
302
|
+
isImage: kind === 'image',
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const directoryState = openAttachmentDirectory(options.root, options.workItemId);
|
|
308
|
+
const written = [];
|
|
309
|
+
try {
|
|
310
|
+
for (const entry of prepared) {
|
|
311
|
+
writeAttachmentFile(directoryState, entry.attachment.storageName, entry.buffer);
|
|
312
|
+
written.push(entry.attachment);
|
|
313
|
+
}
|
|
314
|
+
return written;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
for (const attachment of written) {
|
|
317
|
+
try { removeAttachmentFile(directoryState, attachment.storageName); } catch {}
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
} finally {
|
|
321
|
+
closeDirectoryState(directoryState);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function removeWorkItemAttachmentFiles(root, workItemId, attachments) {
|
|
326
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return;
|
|
327
|
+
const directoryState = openAttachmentDirectory(root, workItemId);
|
|
328
|
+
try {
|
|
329
|
+
for (const attachment of attachments) removeAttachmentFile(directoryState, attachment?.storageName);
|
|
330
|
+
} finally {
|
|
331
|
+
closeDirectoryState(directoryState);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
229
335
|
export function removeWorkItemAttachments(root, workItemId, options = {}) {
|
|
230
336
|
if (!root || !workItemId) return;
|
|
231
337
|
if (process.platform !== 'linux') {
|
|
@@ -286,6 +392,26 @@ function resolveAttachmentPath(root, workItemId, attachment) {
|
|
|
286
392
|
return { filePath: actualPath, size: stat.size, itemDirectory: itemRoot };
|
|
287
393
|
}
|
|
288
394
|
|
|
395
|
+
export function readWorkItemAttachment(workItem, attachmentId, options = {}) {
|
|
396
|
+
const attachment = Array.isArray(workItem?.attachments)
|
|
397
|
+
? workItem.attachments.find(item => item?.id === attachmentId)
|
|
398
|
+
: null;
|
|
399
|
+
if (!attachment) throw new Error('WorkItem attachment not found');
|
|
400
|
+
const resolved = resolveAttachmentPath(options.root, workItem.id, attachment);
|
|
401
|
+
const buffer = readFileSync(resolved.filePath);
|
|
402
|
+
if (resolved.size !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
|
|
403
|
+
throw new Error(`WorkItem attachment changed after creation: ${attachment.name || attachment.id}`);
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
id: attachment.id,
|
|
407
|
+
name: attachment.name,
|
|
408
|
+
mimeType: attachment.mimeType,
|
|
409
|
+
size: resolved.size,
|
|
410
|
+
isImage: attachment.isImage === true,
|
|
411
|
+
data: buffer.toString('base64'),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
289
415
|
function escapePromptText(value) {
|
|
290
416
|
return String(value || '')
|
|
291
417
|
.replace(/&/g, '&')
|
|
@@ -20,21 +20,18 @@ let serviceFactory = null;
|
|
|
20
20
|
const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'guide', 'retry']);
|
|
21
21
|
// `files` is an internal server-to-Agent field. The browser relay rejects any
|
|
22
22
|
// client-supplied value and only emits files resolved from owned upload ids.
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'start',
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
function browserCreatePayload(value) {
|
|
23
|
+
const BROWSER_FILE_FIELDS = Object.freeze({
|
|
24
|
+
create: [
|
|
25
|
+
'title', 'goal', 'acceptanceCriteria', 'workDir', 'reuseMemory', 'origin',
|
|
26
|
+
'linkedSessionIds', 'files', 'start',
|
|
27
|
+
],
|
|
28
|
+
guide: ['id', 'guidance', 'actionId', 'revision', 'files'],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function browserFilePayload(op, value) {
|
|
36
32
|
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
37
|
-
|
|
33
|
+
const fields = BROWSER_FILE_FIELDS[op] || [];
|
|
34
|
+
return Object.fromEntries(fields
|
|
38
35
|
.filter(field => Object.prototype.hasOwnProperty.call(source, field))
|
|
39
36
|
.map(field => [field, source[field]]));
|
|
40
37
|
}
|
|
@@ -176,10 +173,10 @@ export async function handleWorkCenterRequest(msg) {
|
|
|
176
173
|
data = await readSettingsResponse();
|
|
177
174
|
} else {
|
|
178
175
|
const workCenter = await ensureWorkCenter();
|
|
179
|
-
|
|
180
|
-
op,
|
|
181
|
-
|
|
182
|
-
);
|
|
176
|
+
const payload = Object.hasOwn(BROWSER_FILE_FIELDS, op)
|
|
177
|
+
? browserFilePayload(op, msg.payload)
|
|
178
|
+
: (msg.payload || {});
|
|
179
|
+
data = await workCenter.handle(op, payload);
|
|
183
180
|
}
|
|
184
181
|
if (BROWSER_DETAIL_OPS.has(op)) data = projectWorkItemDetail(data);
|
|
185
182
|
send({
|
|
@@ -172,7 +172,9 @@ export class WorkflowController {
|
|
|
172
172
|
|
|
173
173
|
guide(id, input = {}) {
|
|
174
174
|
const guidance = typeof input.guidance === 'string' ? input.guidance.trim().slice(0, 8_000) : '';
|
|
175
|
-
|
|
175
|
+
const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
|
|
176
|
+
if (!guidance && addedAttachmentCount === 0) throw new Error('guidance or attachments are required');
|
|
177
|
+
const guidanceSummary = guidance || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
|
|
176
178
|
const expected = {
|
|
177
179
|
actionId: typeof input.actionId === 'string' ? input.actionId : '',
|
|
178
180
|
revision: Number(input.revision),
|
|
@@ -180,11 +182,11 @@ export class WorkflowController {
|
|
|
180
182
|
if (!expected.actionId || !Number.isInteger(expected.revision)) {
|
|
181
183
|
throw new Error('actionId and revision are required for guidance');
|
|
182
184
|
}
|
|
183
|
-
const detail = this.store.addActionGuidance(id,
|
|
185
|
+
const detail = this.store.addActionGuidance(id, guidanceSummary, expected, (workItem, previous) => {
|
|
184
186
|
const context = [...(previous.context || []), {
|
|
185
187
|
type: 'guidance',
|
|
186
188
|
role: 'user',
|
|
187
|
-
summary:
|
|
189
|
+
summary: guidanceSummary,
|
|
188
190
|
evidence: [],
|
|
189
191
|
}];
|
|
190
192
|
const step = {
|
|
@@ -200,7 +202,7 @@ export class WorkflowController {
|
|
|
200
202
|
instruction: actionInstruction(step, workItem, context),
|
|
201
203
|
maxAttempts: previous.maxAttempts || 2,
|
|
202
204
|
};
|
|
203
|
-
});
|
|
205
|
+
}, input.attachments);
|
|
204
206
|
if (!detail) throw new Error(`WorkItem not found: ${id}`);
|
|
205
207
|
return detail;
|
|
206
208
|
}
|
|
@@ -5,7 +5,10 @@ import { WorkItemStore } from './store.js';
|
|
|
5
5
|
import { WorkflowController } from './controller.js';
|
|
6
6
|
import { WorkItemWatcher } from './watcher.js';
|
|
7
7
|
import {
|
|
8
|
+
appendWorkItemAttachments,
|
|
8
9
|
persistWorkItemAttachments,
|
|
10
|
+
readWorkItemAttachment,
|
|
11
|
+
removeWorkItemAttachmentFiles,
|
|
9
12
|
removeWorkItemAttachments,
|
|
10
13
|
} from './attachments.js';
|
|
11
14
|
import { projectWorkItemDetail, projectWorkItemSummary } from './projection.js';
|
|
@@ -157,15 +160,57 @@ export class WorkCenterService {
|
|
|
157
160
|
}
|
|
158
161
|
case 'guide': {
|
|
159
162
|
const id = requiredString(payload.id, 'id');
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
const workItem = this.#requiredItem(id);
|
|
164
|
+
let addedAttachments = [];
|
|
165
|
+
let detail;
|
|
166
|
+
try {
|
|
167
|
+
addedAttachments = appendWorkItemAttachments(workItem.attachments, payload.files, {
|
|
168
|
+
root: this.attachmentRoot,
|
|
169
|
+
workItemId: id,
|
|
170
|
+
});
|
|
171
|
+
detail = this.controller.guide(id, {
|
|
172
|
+
guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
|
|
173
|
+
actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
|
|
174
|
+
revision: payload.revision,
|
|
175
|
+
addedAttachmentCount: addedAttachments.length,
|
|
176
|
+
attachments: [...(workItem.attachments || []), ...addedAttachments],
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
try {
|
|
180
|
+
if ((workItem.attachments || []).length === 0 && addedAttachments.length > 0) {
|
|
181
|
+
removeWorkItemAttachments(this.attachmentRoot, id);
|
|
182
|
+
} else {
|
|
183
|
+
removeWorkItemAttachmentFiles(this.attachmentRoot, id, addedAttachments);
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
165
188
|
this.watcher.abortInvalidWorkItemRuns(id);
|
|
166
189
|
this.#emit({ type: 'action.guidance_added', workItem: detail });
|
|
167
190
|
return detail;
|
|
168
191
|
}
|
|
192
|
+
case 'preview_attachment': {
|
|
193
|
+
const id = requiredString(payload.id, 'id');
|
|
194
|
+
const attachment = readWorkItemAttachment(
|
|
195
|
+
this.#requiredItem(id),
|
|
196
|
+
requiredString(payload.attachmentId, 'attachmentId'),
|
|
197
|
+
{ root: this.attachmentRoot },
|
|
198
|
+
);
|
|
199
|
+
return {
|
|
200
|
+
attachment: {
|
|
201
|
+
id: attachment.id,
|
|
202
|
+
name: attachment.name,
|
|
203
|
+
mimeType: attachment.mimeType,
|
|
204
|
+
size: attachment.size,
|
|
205
|
+
isImage: attachment.isImage,
|
|
206
|
+
},
|
|
207
|
+
previewData: {
|
|
208
|
+
data: attachment.data,
|
|
209
|
+
mimeType: attachment.mimeType,
|
|
210
|
+
filename: attachment.name,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
169
214
|
case 'retry': {
|
|
170
215
|
const detail = this.controller.retry(requiredString(payload.id, 'id'), {
|
|
171
216
|
answer: typeof payload.answer === 'string' ? payload.answer : '',
|
|
@@ -536,7 +536,7 @@ export class WorkItemStore {
|
|
|
536
536
|
});
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
-
addActionGuidance(id, guidance, expected, makeAction) {
|
|
539
|
+
addActionGuidance(id, guidance, expected, makeAction, attachments = null) {
|
|
540
540
|
return withTransaction(this.db, () => {
|
|
541
541
|
const workItem = this.getWorkItem(id);
|
|
542
542
|
if (!workItem) return null;
|
|
@@ -563,7 +563,12 @@ export class WorkItemStore {
|
|
|
563
563
|
contractRevision: workItem.revision,
|
|
564
564
|
}, this.#nextSequence(id), now);
|
|
565
565
|
this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
|
|
566
|
-
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(
|
|
566
|
+
current_run_id = NULL, attachments = ?, updated_at = ? WHERE id = ?`).run(
|
|
567
|
+
action.id,
|
|
568
|
+
stringify(Array.isArray(attachments) ? attachments : workItem.attachments),
|
|
569
|
+
now,
|
|
570
|
+
id,
|
|
571
|
+
);
|
|
567
572
|
this.appendEvent(id, 'action.guidance_added', { guidance }, { actionId: action.id });
|
|
568
573
|
return this.getWorkItemDetail(id);
|
|
569
574
|
});
|