@prur/dsh-chat-service 0.1.14 → 0.1.16
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/lib/engine.d.ts +47 -6
- package/lib/engine.js +50 -6
- package/lib/index.d.ts +46 -9
- package/lib/index.js +330 -51
- package/lib/runner.d.ts +13 -2
- package/lib/runner.js +27 -14
- package/lib/store.d.ts +57 -2
- package/lib/store.js +140 -2
- package/lib/typert.host.js +67 -3
- package/lib/typert.remote-client.js +23 -0
- package/lib/types.d.ts +80 -2
- package/package.json +6 -5
package/lib/index.js
CHANGED
|
@@ -47,9 +47,10 @@ import { join } from 'node:path';
|
|
|
47
47
|
import z from '@deepseek-ai/schemastery';
|
|
48
48
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
49
49
|
import { ChatStore, ChatStorageError } from './store.js';
|
|
50
|
-
import { ChatTurnRunner
|
|
50
|
+
import { ChatTurnRunner } from './runner.js';
|
|
51
51
|
import { ChatEventBus, ChatEventsGateway } from './events.js';
|
|
52
|
-
import { deriveTitle } from './engine.js';
|
|
52
|
+
import { deriveTitle, regenerateTarget } from './engine.js';
|
|
53
|
+
import { admitEncodedImages } from '@deepseek-ai/dsh-attachment';
|
|
53
54
|
/** Default page size for `chat/history`. */
|
|
54
55
|
const DEFAULT_HISTORY_PAGE = 50;
|
|
55
56
|
/** Upper bound on one history page. */
|
|
@@ -92,6 +93,11 @@ let ChatService = (() => {
|
|
|
92
93
|
let _delete_decorators;
|
|
93
94
|
let _history_decorators;
|
|
94
95
|
let _send_decorators;
|
|
96
|
+
let _archive_decorators;
|
|
97
|
+
let _deleteMessage_decorators;
|
|
98
|
+
let _regenerate_decorators;
|
|
99
|
+
let _capabilities_decorators;
|
|
100
|
+
let _attachment_decorators;
|
|
95
101
|
let _cancel_decorators;
|
|
96
102
|
let _selectModel_decorators;
|
|
97
103
|
let _update_decorators;
|
|
@@ -104,6 +110,11 @@ let ChatService = (() => {
|
|
|
104
110
|
_delete_decorators = [Remote('delete')];
|
|
105
111
|
_history_decorators = [Remote('history')];
|
|
106
112
|
_send_decorators = [Remote('send')];
|
|
113
|
+
_archive_decorators = [Remote('archive')];
|
|
114
|
+
_deleteMessage_decorators = [Remote('deleteMessage')];
|
|
115
|
+
_regenerate_decorators = [Remote('regenerate')];
|
|
116
|
+
_capabilities_decorators = [Remote('capabilities')];
|
|
117
|
+
_attachment_decorators = [Remote('attachment')];
|
|
107
118
|
_cancel_decorators = [Remote('cancel')];
|
|
108
119
|
_selectModel_decorators = [Remote('selectModel')];
|
|
109
120
|
_update_decorators = [Remote('update')];
|
|
@@ -113,6 +124,11 @@ let ChatService = (() => {
|
|
|
113
124
|
__esDecorate(this, null, _delete_decorators, { kind: "method", name: "delete", static: false, private: false, access: { has: obj => "delete" in obj, get: obj => obj.delete }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
114
125
|
__esDecorate(this, null, _history_decorators, { kind: "method", name: "history", static: false, private: false, access: { has: obj => "history" in obj, get: obj => obj.history }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
115
126
|
__esDecorate(this, null, _send_decorators, { kind: "method", name: "send", static: false, private: false, access: { has: obj => "send" in obj, get: obj => obj.send }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
127
|
+
__esDecorate(this, null, _archive_decorators, { kind: "method", name: "archive", static: false, private: false, access: { has: obj => "archive" in obj, get: obj => obj.archive }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
128
|
+
__esDecorate(this, null, _deleteMessage_decorators, { kind: "method", name: "deleteMessage", static: false, private: false, access: { has: obj => "deleteMessage" in obj, get: obj => obj.deleteMessage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
129
|
+
__esDecorate(this, null, _regenerate_decorators, { kind: "method", name: "regenerate", static: false, private: false, access: { has: obj => "regenerate" in obj, get: obj => obj.regenerate }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
130
|
+
__esDecorate(this, null, _capabilities_decorators, { kind: "method", name: "capabilities", static: false, private: false, access: { has: obj => "capabilities" in obj, get: obj => obj.capabilities }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
131
|
+
__esDecorate(this, null, _attachment_decorators, { kind: "method", name: "attachment", static: false, private: false, access: { has: obj => "attachment" in obj, get: obj => obj.attachment }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
116
132
|
__esDecorate(this, null, _cancel_decorators, { kind: "method", name: "cancel", static: false, private: false, access: { has: obj => "cancel" in obj, get: obj => obj.cancel }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
117
133
|
__esDecorate(this, null, _selectModel_decorators, { kind: "method", name: "selectModel", static: false, private: false, access: { has: obj => "selectModel" in obj, get: obj => obj.selectModel }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
118
134
|
__esDecorate(this, null, _update_decorators, { kind: "method", name: "update", static: false, private: false, access: { has: obj => "update" in obj, get: obj => obj.update }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -144,6 +160,9 @@ let ChatService = (() => {
|
|
|
144
160
|
/** Re-run queued sends and settle half-finished conversations on startup. */
|
|
145
161
|
async recover() {
|
|
146
162
|
await this.store.ensure();
|
|
163
|
+
// Legacy records without seqHighWater get normalized once (mock the
|
|
164
|
+
// monotonic identity for regenerations and future allocations).
|
|
165
|
+
await this.store.normalizeSeqHighWaters();
|
|
147
166
|
const records = await this.store.list();
|
|
148
167
|
for (const record of records) {
|
|
149
168
|
if (record.running) {
|
|
@@ -168,6 +187,7 @@ let ChatService = (() => {
|
|
|
168
187
|
messageCount: (await this.store.readMessages(record.conversationId)).length,
|
|
169
188
|
// Read-only: never create a runner just to answer a list query.
|
|
170
189
|
running: record.running || (this.runners.get(record.conversationId)?.isRunning() ?? false),
|
|
190
|
+
archived: record.archived === true,
|
|
171
191
|
})));
|
|
172
192
|
return { ok: true, value: { items } };
|
|
173
193
|
}
|
|
@@ -192,14 +212,16 @@ let ChatService = (() => {
|
|
|
192
212
|
systemPrompt: null,
|
|
193
213
|
queue: [],
|
|
194
214
|
running: false,
|
|
215
|
+
seqHighWater: 0,
|
|
195
216
|
};
|
|
196
217
|
await this.store.put(record);
|
|
197
218
|
return { ok: true, value: { conversationId: record.conversationId } };
|
|
198
219
|
}
|
|
199
220
|
/** Rename a conversation. */
|
|
200
221
|
async rename(conversationId, title) {
|
|
201
|
-
|
|
202
|
-
|
|
222
|
+
// Atomic record mutation: concurrent archive/select must not restore a
|
|
223
|
+
// stale renamed title (review P2).
|
|
224
|
+
await this.store.mutateRecord(conversationId, record => ({ ...record, title, updatedAt: Date.now() }));
|
|
203
225
|
return { ok: true, value: {} };
|
|
204
226
|
}
|
|
205
227
|
/** Delete a conversation and its message log; idempotent. */
|
|
@@ -220,35 +242,220 @@ let ChatService = (() => {
|
|
|
220
242
|
/**
|
|
221
243
|
* Send one user turn into a conversation: the user message persists and is
|
|
222
244
|
* anchored through `chat/message`, then its turn joins the FIFO queue.
|
|
245
|
+
* Image parts are admitted against the deployment attachment limits and
|
|
246
|
+
* promoted to durable references on the persisted message (design §3).
|
|
223
247
|
*/
|
|
224
248
|
async send(conversationId, content) {
|
|
225
249
|
const record = await this.store.require(conversationId);
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
-
throw new ChatServiceError('invalid-request', 'chat: send needs at least one
|
|
250
|
+
const parts = validateContentParts(content);
|
|
251
|
+
if (parts.length === 0) {
|
|
252
|
+
throw new ChatServiceError('invalid-request', 'chat: send needs at least one content part');
|
|
253
|
+
}
|
|
254
|
+
const imageParts = parts.filter(part => part.type === 'image');
|
|
255
|
+
const attachments = this.ctx.get('attachments');
|
|
256
|
+
if (imageParts.length > 0) {
|
|
257
|
+
if (attachments === undefined) {
|
|
258
|
+
throw new ChatServiceError('image-unsupported', 'chat: host attachment service is unavailable');
|
|
259
|
+
}
|
|
260
|
+
let info;
|
|
261
|
+
try {
|
|
262
|
+
info = await this.resolveModelInfo(record.provider, record.model);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// 未知/不可达:fail-closed(与 sessions 准入一致——拒绝发图并报错呈现)。
|
|
266
|
+
}
|
|
267
|
+
if (!this.imageInputAllowed(info)) {
|
|
268
|
+
throw new ChatServiceError('image-unsupported', `chat: model "${record.model}" does not support image input`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const seq = await this.store.reserveTurnSeq(conversationId);
|
|
272
|
+
let blocks = [];
|
|
273
|
+
if (imageParts.length > 0 && attachments !== undefined) {
|
|
274
|
+
// Image parts are admitted in order; the durable refs replace the upload
|
|
275
|
+
// bytes on the persisted message (cheap to page back through chat/attachment).
|
|
276
|
+
const refs = await admitEncodedImages(attachments, imageParts.map(image => ({
|
|
277
|
+
mediaType: image.mediaType,
|
|
278
|
+
data: image.data,
|
|
279
|
+
...image.name === undefined ? {} : { name: image.name },
|
|
280
|
+
})));
|
|
281
|
+
let next = 0;
|
|
282
|
+
for (const part of parts) {
|
|
283
|
+
if (part.type === 'text') {
|
|
284
|
+
blocks.push({ type: 'text', text: part.text });
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
const ref = refs[next];
|
|
288
|
+
next += 1;
|
|
289
|
+
blocks.push({ type: 'image', attachment: toChatImageRef(ref) });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
blocks = parts
|
|
295
|
+
.filter((part) => part.type === 'text')
|
|
296
|
+
.map(part => ({ type: 'text', text: part.text }));
|
|
229
297
|
}
|
|
230
|
-
const history = await this.store.readMessages(conversationId);
|
|
231
298
|
const message = {
|
|
232
|
-
seq
|
|
299
|
+
seq,
|
|
233
300
|
role: 'user',
|
|
234
301
|
blocks,
|
|
235
302
|
createdAt: Date.now(),
|
|
236
303
|
};
|
|
237
304
|
await this.store.appendMessage(conversationId, message);
|
|
238
305
|
this.emitMessage(conversationId, message);
|
|
239
|
-
const
|
|
306
|
+
const derived = deriveTitle(parts.filter((part) => part.type === 'text').map(part => part.text).join(''));
|
|
307
|
+
// 标题落锁内决定:send 早先快照允许并发 rename 抢在前头,派生标题若在
|
|
308
|
+
// 快照上求值会覆盖用户命名——在 mutate 回调里以锁内当前记录为准。
|
|
240
309
|
// Bind the queued send to its message seq: a queued turn's context must
|
|
241
|
-
// not include LATER queued user messages (boundary in runTurn).
|
|
310
|
+
// not include LATER queued user messages (boundary in runTurn). The queue
|
|
311
|
+
// append is an atomic read-modify-write — a concurrent send must never
|
|
312
|
+
// lose this entry to an outdated snapshot (store.mutateRecord).
|
|
242
313
|
const pending = { seq: message.seq, content: blocks, createdAt: Date.now() };
|
|
243
|
-
await this.store.
|
|
314
|
+
await this.store.mutateRecord(conversationId, record => ({
|
|
244
315
|
...record,
|
|
245
|
-
title,
|
|
316
|
+
title: record.title ?? derived,
|
|
246
317
|
updatedAt: Date.now(),
|
|
247
318
|
queue: [...record.queue, pending],
|
|
319
|
+
}));
|
|
320
|
+
this.runnerFor(conversationId).enqueue(pending);
|
|
321
|
+
return { ok: true, value: { accepted: true } };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Toggle a conversation's archive flag (M4). Archived conversations keep
|
|
325
|
+
* their messages and settings; the client hides them from the active view.
|
|
326
|
+
*/
|
|
327
|
+
async archive(conversationId, archived) {
|
|
328
|
+
await this.store.require(conversationId);
|
|
329
|
+
await this.store.mutateRecord(conversationId, record => ({
|
|
330
|
+
...record,
|
|
331
|
+
archived,
|
|
332
|
+
updatedAt: Date.now(),
|
|
333
|
+
}));
|
|
334
|
+
return { ok: true, value: {} };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Delete a single message (design §2.3): the host rewrites the message
|
|
338
|
+
* file, so subsequent context assembly excludes it. Queued sends bound to
|
|
339
|
+
* the deleted seq are dropped too; an in-flight turn keeps its assembled
|
|
340
|
+
* context and settles normally. Idempotent: absent seq reports removed:
|
|
341
|
+
* false without error.
|
|
342
|
+
*/
|
|
343
|
+
async deleteMessage(conversationId, seq) {
|
|
344
|
+
await this.store.require(conversationId);
|
|
345
|
+
const result = await this.store.deleteMessage(conversationId, seq);
|
|
346
|
+
// Both queue mirrors must stay in sync: the runner's in-memory queue and
|
|
347
|
+
// the persisted index queue (a queued turn for a deleted user message
|
|
348
|
+
// would otherwise run as an orphan response — review 🔴).
|
|
349
|
+
this.runners.get(conversationId)?.removeQueued(seq);
|
|
350
|
+
if (result.removed) {
|
|
351
|
+
await this.store.mutateRecord(conversationId, record => ({
|
|
352
|
+
...record,
|
|
353
|
+
queue: record.queue.filter(pending => pending.seq !== seq),
|
|
354
|
+
updatedAt: Date.now(),
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
return { ok: true, value: { removed: result.removed } };
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Regenerate the last assistant message (design §3): remove it, then rerun
|
|
361
|
+
* the last user turn with the same context boundary. Rejected while a turn
|
|
362
|
+
* is running or when there is no assistant message to regenerate.
|
|
363
|
+
*/
|
|
364
|
+
async regenerate(conversationId) {
|
|
365
|
+
const record = await this.store.require(conversationId);
|
|
366
|
+
const runner = this.runners.get(conversationId);
|
|
367
|
+
// A running turn or an existing queue means the conversation's tail is
|
|
368
|
+
// not stable: regenerating would reorder the timeline under the client
|
|
369
|
+
// (review 🟡#11). The client only offers regenerate on the last settled
|
|
370
|
+
// assistant message and never while running.
|
|
371
|
+
if (record.running || record.queue.length > 0 || (runner?.isRunning() ?? false)) {
|
|
372
|
+
throw new ChatServiceError('invalid-request', 'chat: a turn is running or queued');
|
|
373
|
+
}
|
|
374
|
+
// One serialized state transition (review P1): tail check + deletion +
|
|
375
|
+
// queue insert under the store write lock—competing regenerates/sends see
|
|
376
|
+
// the previous op's outcome instead of a stale view.
|
|
377
|
+
const pending = await this.store.mutateConversation(conversationId, async (record, history) => {
|
|
378
|
+
const target = regenerateTarget(history);
|
|
379
|
+
if (target === undefined || target.assistant === undefined) {
|
|
380
|
+
throw new ChatServiceError('invalid-request', 'chat: no assistant message to regenerate');
|
|
381
|
+
}
|
|
382
|
+
if (target.user === undefined) {
|
|
383
|
+
throw new ChatServiceError('invalid-request', 'chat: no user message to regenerate against');
|
|
384
|
+
}
|
|
385
|
+
// The user message whose turn is regenerated must be the newest user
|
|
386
|
+
// message; otherwise an unanswered user message exists above it and the
|
|
387
|
+
// regenerated answer would land before it (timeline confusion).
|
|
388
|
+
const newerUser = history.some(message => message.role === 'user' && message.seq > target.assistant.seq);
|
|
389
|
+
if (newerUser) {
|
|
390
|
+
throw new ChatServiceError('invalid-request', 'chat: a newer user message exists');
|
|
391
|
+
}
|
|
392
|
+
const pending = { seq: target.user.seq, content: target.user.blocks, createdAt: Date.now() };
|
|
393
|
+
return {
|
|
394
|
+
nextRecord: {
|
|
395
|
+
...record,
|
|
396
|
+
queue: [...record.queue, pending],
|
|
397
|
+
updatedAt: Date.now(),
|
|
398
|
+
},
|
|
399
|
+
nextMessages: history.filter(message => message.seq !== target.assistant.seq),
|
|
400
|
+
result: pending,
|
|
401
|
+
};
|
|
248
402
|
});
|
|
249
403
|
this.runnerFor(conversationId).enqueue(pending);
|
|
250
404
|
return { ok: true, value: { accepted: true } };
|
|
251
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Model capability probe for the conversation's current selection
|
|
408
|
+
* (adapter metadata; design §4.6): the client preflights image sending
|
|
409
|
+
* instead of discovering a refusal after the bytes were picked.
|
|
410
|
+
*/
|
|
411
|
+
async capabilities(conversationId) {
|
|
412
|
+
const record = await this.store.require(conversationId);
|
|
413
|
+
let info;
|
|
414
|
+
try {
|
|
415
|
+
info = await this.resolveModelInfo(record.provider, record.model);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// 探测失败不抛:预检以保守结论(不支持/未知窗口)呈现,客户端可重试。
|
|
419
|
+
}
|
|
420
|
+
const imageInput = info !== undefined
|
|
421
|
+
&& this.imageInputAllowed(info)
|
|
422
|
+
&& this.ctx.get('attachments') !== undefined;
|
|
423
|
+
return {
|
|
424
|
+
ok: true,
|
|
425
|
+
value: {
|
|
426
|
+
provider: record.provider,
|
|
427
|
+
model: record.model,
|
|
428
|
+
imageInput,
|
|
429
|
+
contextWindow: info?.context?.contextWindow ?? null,
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Read one image's bytes after proving the conversation's message log
|
|
435
|
+
* references its attachment id (mirror of `session.attachment`'s
|
|
436
|
+
* authorization discipline; design §3 读图).
|
|
437
|
+
*/
|
|
438
|
+
async attachment(conversationId, attachmentId) {
|
|
439
|
+
await this.store.require(conversationId);
|
|
440
|
+
const attachments = this.ctx.get('attachments');
|
|
441
|
+
if (attachments === undefined) {
|
|
442
|
+
throw new ChatServiceError('image-unsupported', 'chat: host attachment service is unavailable');
|
|
443
|
+
}
|
|
444
|
+
const messages = await this.store.readMessages(conversationId);
|
|
445
|
+
const ref = findImageRef(messages, attachmentId);
|
|
446
|
+
if (ref === undefined) {
|
|
447
|
+
throw new ChatServiceError('invalid-request', 'chat: conversation does not reference this attachment');
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const stored = await attachments.readImage(toAttachmentRef(ref));
|
|
451
|
+
return { ok: true, value: { attachment: toChatImageRef(stored.ref), data: Buffer.from(stored.data).toString('base64') } };
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
// AttachmentError 携带稳定 code(INVALID_ATTACHMENT_REF 等);typert 网关
|
|
455
|
+
// 按消息投射——统一包装为 ChatServiceError 保 code 稳定(评审 ⚪#8)。
|
|
456
|
+
throw new ChatServiceError('invalid-request', `chat: cannot read attachment: ${error.message}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
252
459
|
/** Abort the in-flight turn (frozen partial is persisted); idempotent. */
|
|
253
460
|
async cancel(conversationId) {
|
|
254
461
|
await this.store.require(conversationId);
|
|
@@ -260,39 +467,50 @@ let ChatService = (() => {
|
|
|
260
467
|
if (provider.length === 0 || model.length === 0) {
|
|
261
468
|
throw new ChatServiceError('invalid-request', 'chat: provider and model must be non-empty');
|
|
262
469
|
}
|
|
263
|
-
|
|
264
|
-
await this.store.put({ ...record, provider, model, updatedAt: Date.now() });
|
|
470
|
+
await this.store.mutateRecord(conversationId, record => ({ ...record, provider, model, updatedAt: Date.now() }));
|
|
265
471
|
return { ok: true, value: { provider, model } };
|
|
266
472
|
}
|
|
267
473
|
/** Update conversation settings; `null` in the patch unsets a field. */
|
|
268
474
|
async update(conversationId, patch) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
systemPrompt,
|
|
286
|
-
|
|
287
|
-
contextMessages,
|
|
288
|
-
updatedAt,
|
|
289
|
-
};
|
|
290
|
-
await this.store.put(next);
|
|
475
|
+
// 整个合并在写锁内完成(读当前值 + 校验 + 写回):先前 require 快照与
|
|
476
|
+
// mutate 分离的读改写会让两个并发 update 互相覆盖(与 rename/archive 的
|
|
477
|
+
// P2 同类);校验抛错发生在锁内,写文件前终止,不留半更新。
|
|
478
|
+
const next = await this.store.mutateRecord(conversationId, record => {
|
|
479
|
+
const systemPrompt = patch.systemPrompt === undefined ? record.systemPrompt : patch.systemPrompt;
|
|
480
|
+
if (systemPrompt !== null && systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS) {
|
|
481
|
+
throw new ChatServiceError('invalid-request', `chat: system prompt exceeds ${MAX_SYSTEM_PROMPT_CHARS} characters`);
|
|
482
|
+
}
|
|
483
|
+
const temperature = patch.temperature === undefined ? record.temperature : patch.temperature;
|
|
484
|
+
if (temperature !== null && (!Number.isFinite(temperature) || temperature < 0 || temperature > 2)) {
|
|
485
|
+
throw new ChatServiceError('invalid-request', 'chat: temperature must be within 0..2');
|
|
486
|
+
}
|
|
487
|
+
const contextMessages = patch.contextMessages === undefined ? record.contextMessages : patch.contextMessages;
|
|
488
|
+
if (contextMessages !== null && (!Number.isInteger(contextMessages) || contextMessages <= 0)) {
|
|
489
|
+
throw new ChatServiceError('invalid-request', 'chat: contextMessages must be a positive integer');
|
|
490
|
+
}
|
|
491
|
+
return { ...record, systemPrompt, temperature, contextMessages, updatedAt: Date.now() };
|
|
492
|
+
});
|
|
291
493
|
return { ok: true, value: settingsView(next) };
|
|
292
494
|
}
|
|
293
495
|
defaultSelection() {
|
|
294
496
|
return this.ctx.get('agentDefaultModel')?.currentSelection();
|
|
295
497
|
}
|
|
498
|
+
/** Adapter metadata for the conversation's model (structural llm seam). */
|
|
499
|
+
async resolveModelInfo(provider, model) {
|
|
500
|
+
return this.ctx.llm.resolveModelInfo(provider, model);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Whether the model accepts image input. Unknown capability is a refusal
|
|
504
|
+
* (fail-closed, mirror of the session image admission preflight outcome:
|
|
505
|
+
* an unresolvable adapter must not silently accept an image request).
|
|
506
|
+
*/
|
|
507
|
+
imageInputAllowed(info) {
|
|
508
|
+
// Fail-closed: only an explicit 'image' modality is treated as capable;
|
|
509
|
+
// unknown (absent) capability is a refusal (review P2).
|
|
510
|
+
return info !== undefined
|
|
511
|
+
&& info.inputModalities !== undefined
|
|
512
|
+
&& info.inputModalities.includes('image');
|
|
513
|
+
}
|
|
296
514
|
runnerFor(conversationId) {
|
|
297
515
|
let runner = this.runners.get(conversationId);
|
|
298
516
|
if (runner === undefined) {
|
|
@@ -324,18 +542,22 @@ let ChatService = (() => {
|
|
|
324
542
|
}
|
|
325
543
|
/** Persist turn-start bookkeeping: shift the queued send and mark running. */
|
|
326
544
|
async turnStart(conversationId) {
|
|
327
|
-
|
|
328
|
-
|
|
545
|
+
// Atomic read-modify-write: a concurrent chat/send must not lose its
|
|
546
|
+
// queue append to this outdated snapshot (store.mutateRecord).
|
|
547
|
+
await this.store.mutateRecord(conversationId, record => ({
|
|
329
548
|
...record,
|
|
330
549
|
running: true,
|
|
331
550
|
queue: record.queue.slice(1),
|
|
332
551
|
updatedAt: Date.now(),
|
|
333
|
-
});
|
|
552
|
+
}));
|
|
334
553
|
}
|
|
335
554
|
/** Persist turn-end bookkeeping: release the running flag. */
|
|
336
555
|
async turnEnd(conversationId) {
|
|
337
|
-
|
|
338
|
-
|
|
556
|
+
await this.store.mutateRecord(conversationId, record => ({
|
|
557
|
+
...record,
|
|
558
|
+
running: false,
|
|
559
|
+
updatedAt: Date.now(),
|
|
560
|
+
}));
|
|
339
561
|
}
|
|
340
562
|
emitMessage(conversationId, message) {
|
|
341
563
|
this.bus.publish('chat/message', [conversationId, message.seq, message]);
|
|
@@ -351,16 +573,73 @@ function clampPage(maxMessages) {
|
|
|
351
573
|
return DEFAULT_HISTORY_PAGE;
|
|
352
574
|
return Math.min(Math.max(Math.trunc(maxMessages), 1), MAX_HISTORY_PAGE);
|
|
353
575
|
}
|
|
354
|
-
/**
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
576
|
+
/** Accepted raster media types (mirror of the image admission enum). */
|
|
577
|
+
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
578
|
+
/** Accept text and image content parts; anything else is rejected up front. */
|
|
579
|
+
function validateContentParts(content) {
|
|
580
|
+
const parts = [];
|
|
581
|
+
for (const part of content) {
|
|
582
|
+
if (typeof part !== 'object' || part === null) {
|
|
583
|
+
throw new ChatServiceError('unsupported-block', 'chat: content parts must be objects');
|
|
584
|
+
}
|
|
585
|
+
if (part.type === 'text') {
|
|
586
|
+
if (typeof part.text !== 'string') {
|
|
587
|
+
throw new ChatServiceError('unsupported-block', 'chat: text part needs a string text');
|
|
588
|
+
}
|
|
589
|
+
parts.push({ type: 'text', text: part.text });
|
|
590
|
+
}
|
|
591
|
+
else if (part.type === 'image') {
|
|
592
|
+
if (typeof part.mediaType !== 'string' || typeof part.data !== 'string' || part.data.length === 0) {
|
|
593
|
+
throw new ChatServiceError('unsupported-block', 'chat: image part needs mediaType and data');
|
|
594
|
+
}
|
|
595
|
+
if (!IMAGE_MEDIA_TYPES.includes(part.mediaType)) {
|
|
596
|
+
throw new ChatServiceError('unsupported-block', `chat: unsupported image media type: ${part.mediaType}`);
|
|
597
|
+
}
|
|
598
|
+
parts.push({
|
|
599
|
+
type: 'image',
|
|
600
|
+
mediaType: part.mediaType,
|
|
601
|
+
data: part.data,
|
|
602
|
+
...part.name === undefined ? {} : { name: part.name },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
throw new ChatServiceError('unsupported-block', 'chat: unknown content part type');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return parts;
|
|
610
|
+
}
|
|
611
|
+
/** Map a durable harness image ref onto the chat wire ref (subset shape). */
|
|
612
|
+
function toChatImageRef(ref) {
|
|
613
|
+
return {
|
|
614
|
+
attachmentId: ref.attachmentId,
|
|
615
|
+
mediaType: ref.mediaType,
|
|
616
|
+
bytes: ref.bytes,
|
|
617
|
+
width: ref.width,
|
|
618
|
+
height: ref.height,
|
|
619
|
+
...ref.name === undefined ? {} : { name: ref.name },
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/** Map a chat wire ref back onto the harness ref shape for store reads. */
|
|
623
|
+
function toAttachmentRef(ref) {
|
|
624
|
+
return {
|
|
625
|
+
attachmentId: ref.attachmentId,
|
|
626
|
+
mediaType: ref.mediaType,
|
|
627
|
+
bytes: ref.bytes,
|
|
628
|
+
width: ref.width,
|
|
629
|
+
height: ref.height,
|
|
630
|
+
...ref.name === undefined ? {} : { name: ref.name },
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/** Find the chat message's image ref by attachment id (authorization walk). */
|
|
634
|
+
function findImageRef(messages, attachmentId) {
|
|
635
|
+
for (const message of messages) {
|
|
636
|
+
for (const block of message.blocks) {
|
|
637
|
+
if (block.type === 'image' && block.attachment.attachmentId === attachmentId) {
|
|
638
|
+
return block.attachment;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
362
641
|
}
|
|
363
|
-
return
|
|
642
|
+
return undefined;
|
|
364
643
|
}
|
|
365
644
|
function settingsView(record) {
|
|
366
645
|
return {
|
package/lib/runner.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface LlmLike {
|
|
|
40
40
|
readonly context?: {
|
|
41
41
|
readonly contextWindow: number;
|
|
42
42
|
};
|
|
43
|
+
readonly inputModalities?: readonly string[];
|
|
43
44
|
}>;
|
|
44
45
|
}
|
|
45
46
|
/** Event sink for the forwarded chat triplet (host allowlist). */
|
|
@@ -59,12 +60,12 @@ export interface TurnRecord {
|
|
|
59
60
|
/** Storage interface the runner needs (subset of {@link ChatStore}). */
|
|
60
61
|
export interface RunnerStore {
|
|
61
62
|
readMessages(conversationId: string): Promise<ChatMessage[]>;
|
|
63
|
+
/** Allocate the next monotonic seq (durable high-water; never reused). */
|
|
64
|
+
reserveTurnSeq(conversationId: string): Promise<number>;
|
|
62
65
|
appendMessage(conversationId: string, message: ChatMessage): Promise<{
|
|
63
66
|
trimmed: boolean;
|
|
64
67
|
}>;
|
|
65
68
|
}
|
|
66
|
-
/** Next monotonic message sequence for a conversation's persisted history. */
|
|
67
|
-
export declare function nextSeq(messages: readonly ChatMessage[]): number;
|
|
68
69
|
/**
|
|
69
70
|
* Serialized per-conversation turn machine: sends enqueue; the queue drains
|
|
70
71
|
* one turn at a time; cancel aborts only the in-flight turn.
|
|
@@ -92,6 +93,16 @@ export declare class ChatTurnRunner {
|
|
|
92
93
|
enqueue(send: PendingSend): void;
|
|
93
94
|
/** Abort the in-flight turn; returns whether one was running. */
|
|
94
95
|
cancel(): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Remove one still-queued send (mirror of the index queue filter in
|
|
98
|
+
* `chat/deleteMessage`): the runner's in-memory queue and the persisted
|
|
99
|
+
* index queue must never drift, or a deleted user message would still be
|
|
100
|
+
* answered with an orphan assistant turn. In-flight sends are never
|
|
101
|
+
* touched (their turn settles; deletion of their context boundary is
|
|
102
|
+
* handled by the runTurn existence check).
|
|
103
|
+
* @returns whether a queued send with `seq` was removed.
|
|
104
|
+
*/
|
|
105
|
+
removeQueued(seq: number): boolean;
|
|
95
106
|
/** Stop queue advances; an in-flight turn still settles before the runner is dropped. */
|
|
96
107
|
dispose(): void;
|
|
97
108
|
private drain;
|
package/lib/runner.js
CHANGED
|
@@ -20,15 +20,6 @@ function mapFinishKind(kind) {
|
|
|
20
20
|
default: return 'completed';
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
/** Next monotonic message sequence for a conversation's persisted history. */
|
|
24
|
-
export function nextSeq(messages) {
|
|
25
|
-
let last = 0;
|
|
26
|
-
for (const message of messages) {
|
|
27
|
-
if (message.seq > last)
|
|
28
|
-
last = message.seq;
|
|
29
|
-
}
|
|
30
|
-
return last + 1;
|
|
31
|
-
}
|
|
32
23
|
/**
|
|
33
24
|
* Serialized per-conversation turn machine: sends enqueue; the queue drains
|
|
34
25
|
* one turn at a time; cancel aborts only the in-flight turn.
|
|
@@ -71,6 +62,22 @@ export class ChatTurnRunner {
|
|
|
71
62
|
this.controller.abort();
|
|
72
63
|
return true;
|
|
73
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Remove one still-queued send (mirror of the index queue filter in
|
|
67
|
+
* `chat/deleteMessage`): the runner's in-memory queue and the persisted
|
|
68
|
+
* index queue must never drift, or a deleted user message would still be
|
|
69
|
+
* answered with an orphan assistant turn. In-flight sends are never
|
|
70
|
+
* touched (their turn settles; deletion of their context boundary is
|
|
71
|
+
* handled by the runTurn existence check).
|
|
72
|
+
* @returns whether a queued send with `seq` was removed.
|
|
73
|
+
*/
|
|
74
|
+
removeQueued(seq) {
|
|
75
|
+
const index = this.queue.findIndex(send => send.seq === seq);
|
|
76
|
+
if (index < 0)
|
|
77
|
+
return false;
|
|
78
|
+
this.queue.splice(index, 1);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
74
81
|
/** Stop queue advances; an in-flight turn still settles before the runner is dropped. */
|
|
75
82
|
dispose() {
|
|
76
83
|
this.disposed = true;
|
|
@@ -105,14 +112,20 @@ export class ChatTurnRunner {
|
|
|
105
112
|
async runTurn(send) {
|
|
106
113
|
const record = await this.deps.record();
|
|
107
114
|
const all = await this.deps.store.readMessages(this.conversationId);
|
|
115
|
+
// The send's user message was deleted while this send waited in the
|
|
116
|
+
// queue: the turn has no prompt to answer — skip it silently (the index
|
|
117
|
+
// queue mirror is already filtered; this guards the in-memory queue).
|
|
118
|
+
if (!all.some(message => message.seq === send.seq && message.role === 'user')) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
108
121
|
// Context boundary: this turn answers only its own user message. Later
|
|
109
122
|
// (queued) user messages stay out of the prompt until their own turn.
|
|
110
|
-
const history =
|
|
111
|
-
? all.filter(message => message.seq <= send.seq)
|
|
112
|
-
: all;
|
|
123
|
+
const history = all.filter(message => message.seq <= send.seq);
|
|
113
124
|
const window = await this.resolveWindow(record);
|
|
114
125
|
const assembled = assembleContext(history, record.contextMessages, window);
|
|
115
|
-
|
|
126
|
+
// Durable monotonic seq identity: reserved (never reused) even when this
|
|
127
|
+
// turn later aborts — the high-water mark only ever advances.
|
|
128
|
+
const seq = await this.deps.store.reserveTurnSeq(this.conversationId);
|
|
116
129
|
const controller = new AbortController();
|
|
117
130
|
this.controller = controller;
|
|
118
131
|
this.emitStatus({ running: true, turn: seq });
|
|
@@ -180,7 +193,7 @@ export class ChatTurnRunner {
|
|
|
180
193
|
? [{ type: 'text', text }]
|
|
181
194
|
: [];
|
|
182
195
|
// An empty abort persists nothing (nothing was frozen to settle).
|
|
183
|
-
if (
|
|
196
|
+
if (finishReason === 'aborted' && text.length === 0) {
|
|
184
197
|
this.emitStatus({ running: false, turn: seq, reason: 'aborted' });
|
|
185
198
|
return;
|
|
186
199
|
}
|