@prur/dsh-chat-service 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/engine.d.ts +39 -0
- package/lib/engine.js +71 -0
- package/lib/events.d.ts +57 -0
- package/lib/events.js +114 -0
- package/lib/index.d.ts +96 -0
- package/lib/index.js +379 -0
- package/lib/runner.d.ts +102 -0
- package/lib/runner.js +222 -0
- package/lib/store.d.ts +88 -0
- package/lib/store.js +185 -0
- package/lib/typert.host.d.ts +50 -0
- package/lib/typert.host.js +155 -0
- package/lib/typert.remote-client.d.ts +32 -0
- package/lib/typert.remote-client.js +70 -0
- package/lib/types.d.ts +106 -0
- package/lib/types.js +7 -0
- package/package.json +55 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat Typert Remote namespace: a plain streaming model chat domain
|
|
3
|
+
* (conversations stored under `$DSH_HOME/chat/`) that never enters the
|
|
4
|
+
* session/agent loop — no tools, no approvals, no workspace. Directly wired
|
|
5
|
+
* to `ctx.llm.stream`; progress is pushed through the plugin's own WebSocket
|
|
6
|
+
* downlink (`/chat/events`, see {@link ChatEventsGateway}) as
|
|
7
|
+
* `host/remote-event`-shaped frames, so no host code change is needed.
|
|
8
|
+
* @module @prur/dsh-chat-service
|
|
9
|
+
*/
|
|
10
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
11
|
+
var useValue = arguments.length > 2;
|
|
12
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
13
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
14
|
+
}
|
|
15
|
+
return useValue ? value : void 0;
|
|
16
|
+
};
|
|
17
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
18
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
19
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
20
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
21
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
22
|
+
var _, done = false;
|
|
23
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
24
|
+
var context = {};
|
|
25
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
26
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
27
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
28
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
29
|
+
if (kind === "accessor") {
|
|
30
|
+
if (result === void 0) continue;
|
|
31
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
32
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
33
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
34
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
35
|
+
}
|
|
36
|
+
else if (_ = accept(result)) {
|
|
37
|
+
if (kind === "field") initializers.unshift(_);
|
|
38
|
+
else descriptor[key] = _;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
42
|
+
done = true;
|
|
43
|
+
};
|
|
44
|
+
import { randomUUID } from 'node:crypto';
|
|
45
|
+
import { homedir } from 'node:os';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
import z from '@deepseek-ai/schemastery';
|
|
48
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
49
|
+
import { ChatStore, ChatStorageError } from './store.js';
|
|
50
|
+
import { ChatTurnRunner, nextSeq } from './runner.js';
|
|
51
|
+
import { ChatEventBus, ChatEventsGateway } from './events.js';
|
|
52
|
+
import { deriveTitle } from './engine.js';
|
|
53
|
+
/** Default page size for `chat/history`. */
|
|
54
|
+
const DEFAULT_HISTORY_PAGE = 50;
|
|
55
|
+
/** Upper bound on one history page. */
|
|
56
|
+
const MAX_HISTORY_PAGE = 500;
|
|
57
|
+
/** Maximum system prompt characters. */
|
|
58
|
+
const MAX_SYSTEM_PROMPT_CHARS = 16_000;
|
|
59
|
+
/** Stable Cordis plugin name. */
|
|
60
|
+
export const name = 'chat-service';
|
|
61
|
+
/** Business failure carrying a stable code for diagnostics. */
|
|
62
|
+
export class ChatServiceError extends Error {
|
|
63
|
+
code;
|
|
64
|
+
constructor(code, message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.name = 'ChatServiceError';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Resolve the harness home the chat store lives under (env overrides default). */
|
|
71
|
+
export function chatHomePath() {
|
|
72
|
+
const envHome = process.env['DSH_HOME'];
|
|
73
|
+
return join(envHome ?? join(homedir(), '.dsh'), 'chat');
|
|
74
|
+
}
|
|
75
|
+
/** Validate the composition config; defaults to an empty fence (loopback only). */
|
|
76
|
+
export const Config = z.object({
|
|
77
|
+
trustedHosts: z.array(String).default([]),
|
|
78
|
+
// Optional by schemastery convention: only `.required()` fields are mandatory.
|
|
79
|
+
apiToken: z.string().min(1),
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* Host face of the chat namespace: one service per host, conversations keyed
|
|
83
|
+
* by `conversationId`, turns serialized per conversation through
|
|
84
|
+
* {@link ChatTurnRunner}, push frames via {@link ChatEventsGateway}.
|
|
85
|
+
*/
|
|
86
|
+
let ChatService = (() => {
|
|
87
|
+
let _classSuper = TypertRemoteService;
|
|
88
|
+
let _instanceExtraInitializers = [];
|
|
89
|
+
let _list_decorators;
|
|
90
|
+
let _create_decorators;
|
|
91
|
+
let _rename_decorators;
|
|
92
|
+
let _delete_decorators;
|
|
93
|
+
let _history_decorators;
|
|
94
|
+
let _send_decorators;
|
|
95
|
+
let _cancel_decorators;
|
|
96
|
+
let _selectModel_decorators;
|
|
97
|
+
let _update_decorators;
|
|
98
|
+
return class ChatService extends _classSuper {
|
|
99
|
+
static {
|
|
100
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
101
|
+
_list_decorators = [Remote('list')];
|
|
102
|
+
_create_decorators = [Remote('create')];
|
|
103
|
+
_rename_decorators = [Remote('rename')];
|
|
104
|
+
_delete_decorators = [Remote('delete')];
|
|
105
|
+
_history_decorators = [Remote('history')];
|
|
106
|
+
_send_decorators = [Remote('send')];
|
|
107
|
+
_cancel_decorators = [Remote('cancel')];
|
|
108
|
+
_selectModel_decorators = [Remote('selectModel')];
|
|
109
|
+
_update_decorators = [Remote('update')];
|
|
110
|
+
__esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
111
|
+
__esDecorate(this, null, _create_decorators, { kind: "method", name: "create", static: false, private: false, access: { has: obj => "create" in obj, get: obj => obj.create }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
112
|
+
__esDecorate(this, null, _rename_decorators, { kind: "method", name: "rename", static: false, private: false, access: { has: obj => "rename" in obj, get: obj => obj.rename }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
113
|
+
__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
|
+
__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
|
+
__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);
|
|
116
|
+
__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
|
+
__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
|
+
__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);
|
|
119
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
120
|
+
}
|
|
121
|
+
/** Required services: the LLM runtime and the HTTP carrier for the downlink. */
|
|
122
|
+
static inject = ['llm', 'webServer'];
|
|
123
|
+
store = (__runInitializers(this, _instanceExtraInitializers), new ChatStore(chatHomePath()));
|
|
124
|
+
runners = new Map();
|
|
125
|
+
bus = new ChatEventBus();
|
|
126
|
+
gateway;
|
|
127
|
+
constructor(ctx, config) {
|
|
128
|
+
super(ctx, 'chat');
|
|
129
|
+
this.gateway = new ChatEventsGateway(ctx, config.trustedHosts, config.apiToken);
|
|
130
|
+
this.bus.subscribe(frame => this.gateway.broadcast(frame));
|
|
131
|
+
// Recover pending queues after a host restart: a half-finished turn can
|
|
132
|
+
// never resume (its assistant message was never persisted), so running
|
|
133
|
+
// conversations settle as aborted; queued sends re-enter their runners.
|
|
134
|
+
void this.recover().catch((error) => {
|
|
135
|
+
ctx.logger.warn('chat: recovery failed');
|
|
136
|
+
ctx.logger.warn(error);
|
|
137
|
+
});
|
|
138
|
+
ctx.effect(() => () => {
|
|
139
|
+
for (const runner of this.runners.values())
|
|
140
|
+
runner.dispose();
|
|
141
|
+
this.runners.clear();
|
|
142
|
+
}, '@prur/dsh-chat-service: dispose runners');
|
|
143
|
+
}
|
|
144
|
+
/** Re-run queued sends and settle half-finished conversations on startup. */
|
|
145
|
+
async recover() {
|
|
146
|
+
await this.store.ensure();
|
|
147
|
+
const records = await this.store.list();
|
|
148
|
+
for (const record of records) {
|
|
149
|
+
if (record.running) {
|
|
150
|
+
await this.store.put({ ...record, running: false });
|
|
151
|
+
this.emitStatus(record.conversationId, { running: false, turn: 0, reason: 'aborted' });
|
|
152
|
+
}
|
|
153
|
+
if (record.queue.length > 0) {
|
|
154
|
+
for (const send of record.queue)
|
|
155
|
+
this.runnerFor(record.conversationId).enqueue(send);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** List conversations newest-first with a running flag. */
|
|
160
|
+
async list() {
|
|
161
|
+
const records = await this.store.list();
|
|
162
|
+
const items = await Promise.all(records.map(async (record) => ({
|
|
163
|
+
conversationId: record.conversationId,
|
|
164
|
+
title: record.title,
|
|
165
|
+
provider: record.provider,
|
|
166
|
+
model: record.model,
|
|
167
|
+
updatedAt: record.updatedAt,
|
|
168
|
+
messageCount: (await this.store.readMessages(record.conversationId)).length,
|
|
169
|
+
// Read-only: never create a runner just to answer a list query.
|
|
170
|
+
running: record.running || (this.runners.get(record.conversationId)?.isRunning() ?? false),
|
|
171
|
+
})));
|
|
172
|
+
return { ok: true, value: { items } };
|
|
173
|
+
}
|
|
174
|
+
/** Create a conversation; absent provider/model defaults to the host's current model. */
|
|
175
|
+
async create(provider, model, title) {
|
|
176
|
+
const defaults = this.defaultSelection();
|
|
177
|
+
const resolvedProvider = provider ?? defaults?.provider;
|
|
178
|
+
const resolvedModel = model ?? defaults?.model;
|
|
179
|
+
if (resolvedProvider === undefined || resolvedModel === undefined) {
|
|
180
|
+
throw new ChatServiceError('no-model', 'chat: no model available — provide provider and model, or configure a host default model');
|
|
181
|
+
}
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const record = {
|
|
184
|
+
conversationId: randomUUID(),
|
|
185
|
+
title: title ?? null,
|
|
186
|
+
createdAt: now,
|
|
187
|
+
updatedAt: now,
|
|
188
|
+
provider: resolvedProvider,
|
|
189
|
+
model: resolvedModel,
|
|
190
|
+
temperature: null,
|
|
191
|
+
contextMessages: null,
|
|
192
|
+
systemPrompt: null,
|
|
193
|
+
queue: [],
|
|
194
|
+
running: false,
|
|
195
|
+
};
|
|
196
|
+
await this.store.put(record);
|
|
197
|
+
return { ok: true, value: { conversationId: record.conversationId } };
|
|
198
|
+
}
|
|
199
|
+
/** Rename a conversation. */
|
|
200
|
+
async rename(conversationId, title) {
|
|
201
|
+
const record = await this.store.require(conversationId);
|
|
202
|
+
await this.store.put({ ...record, title, updatedAt: Date.now() });
|
|
203
|
+
return { ok: true, value: {} };
|
|
204
|
+
}
|
|
205
|
+
/** Delete a conversation and its message log; idempotent. */
|
|
206
|
+
async delete(conversationId) {
|
|
207
|
+
const runner = this.runners.get(conversationId);
|
|
208
|
+
runner?.cancel();
|
|
209
|
+
runner?.dispose();
|
|
210
|
+
this.runners.delete(conversationId);
|
|
211
|
+
await this.store.remove(conversationId);
|
|
212
|
+
return { ok: true, value: {} };
|
|
213
|
+
}
|
|
214
|
+
/** Page a conversation's history newest-first under the `beforeSeq` anchor. */
|
|
215
|
+
async history(conversationId, beforeSeq, maxMessages) {
|
|
216
|
+
await this.store.require(conversationId);
|
|
217
|
+
const page = await this.store.paginate(conversationId, beforeSeq, clampPage(maxMessages));
|
|
218
|
+
return { ok: true, value: page };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Send one user turn into a conversation: the user message persists and is
|
|
222
|
+
* anchored through `chat/message`, then its turn joins the FIFO queue.
|
|
223
|
+
*/
|
|
224
|
+
async send(conversationId, content) {
|
|
225
|
+
const record = await this.store.require(conversationId);
|
|
226
|
+
const blocks = validateTextBlocks(content);
|
|
227
|
+
if (blocks.length === 0) {
|
|
228
|
+
throw new ChatServiceError('invalid-request', 'chat: send needs at least one text block');
|
|
229
|
+
}
|
|
230
|
+
const history = await this.store.readMessages(conversationId);
|
|
231
|
+
const message = {
|
|
232
|
+
seq: nextSeq(history),
|
|
233
|
+
role: 'user',
|
|
234
|
+
blocks,
|
|
235
|
+
createdAt: Date.now(),
|
|
236
|
+
};
|
|
237
|
+
await this.store.appendMessage(conversationId, message);
|
|
238
|
+
this.emitMessage(conversationId, message);
|
|
239
|
+
const title = record.title ?? deriveTitle(blocks.map(block => block.text).join(''));
|
|
240
|
+
// Bind the queued send to its message seq: a queued turn's context must
|
|
241
|
+
// not include LATER queued user messages (boundary in runTurn).
|
|
242
|
+
const pending = { seq: message.seq, content: blocks, createdAt: Date.now() };
|
|
243
|
+
await this.store.put({
|
|
244
|
+
...record,
|
|
245
|
+
title,
|
|
246
|
+
updatedAt: Date.now(),
|
|
247
|
+
queue: [...record.queue, pending],
|
|
248
|
+
});
|
|
249
|
+
this.runnerFor(conversationId).enqueue(pending);
|
|
250
|
+
return { ok: true, value: { accepted: true } };
|
|
251
|
+
}
|
|
252
|
+
/** Abort the in-flight turn (frozen partial is persisted); idempotent. */
|
|
253
|
+
async cancel(conversationId) {
|
|
254
|
+
await this.store.require(conversationId);
|
|
255
|
+
this.runnerFor(conversationId).cancel();
|
|
256
|
+
return { ok: true, value: {} };
|
|
257
|
+
}
|
|
258
|
+
/** Switch the conversation's model selection. */
|
|
259
|
+
async selectModel(conversationId, provider, model) {
|
|
260
|
+
if (provider.length === 0 || model.length === 0) {
|
|
261
|
+
throw new ChatServiceError('invalid-request', 'chat: provider and model must be non-empty');
|
|
262
|
+
}
|
|
263
|
+
const record = await this.store.require(conversationId);
|
|
264
|
+
await this.store.put({ ...record, provider, model, updatedAt: Date.now() });
|
|
265
|
+
return { ok: true, value: { provider, model } };
|
|
266
|
+
}
|
|
267
|
+
/** Update conversation settings; `null` in the patch unsets a field. */
|
|
268
|
+
async update(conversationId, patch) {
|
|
269
|
+
const record = await this.store.require(conversationId);
|
|
270
|
+
const systemPrompt = patch.systemPrompt === undefined ? record.systemPrompt : patch.systemPrompt;
|
|
271
|
+
if (systemPrompt !== null && systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS) {
|
|
272
|
+
throw new ChatServiceError('invalid-request', `chat: system prompt exceeds ${MAX_SYSTEM_PROMPT_CHARS} characters`);
|
|
273
|
+
}
|
|
274
|
+
const temperature = patch.temperature === undefined ? record.temperature : patch.temperature;
|
|
275
|
+
if (temperature !== null && (!Number.isFinite(temperature) || temperature < 0 || temperature > 2)) {
|
|
276
|
+
throw new ChatServiceError('invalid-request', 'chat: temperature must be within 0..2');
|
|
277
|
+
}
|
|
278
|
+
const contextMessages = patch.contextMessages === undefined ? record.contextMessages : patch.contextMessages;
|
|
279
|
+
if (contextMessages !== null && (!Number.isInteger(contextMessages) || contextMessages <= 0)) {
|
|
280
|
+
throw new ChatServiceError('invalid-request', 'chat: contextMessages must be a positive integer');
|
|
281
|
+
}
|
|
282
|
+
const updatedAt = Date.now();
|
|
283
|
+
const next = {
|
|
284
|
+
...record,
|
|
285
|
+
systemPrompt,
|
|
286
|
+
temperature,
|
|
287
|
+
contextMessages,
|
|
288
|
+
updatedAt,
|
|
289
|
+
};
|
|
290
|
+
await this.store.put(next);
|
|
291
|
+
return { ok: true, value: settingsView(next) };
|
|
292
|
+
}
|
|
293
|
+
defaultSelection() {
|
|
294
|
+
return this.ctx.get('agentDefaultModel')?.currentSelection();
|
|
295
|
+
}
|
|
296
|
+
runnerFor(conversationId) {
|
|
297
|
+
let runner = this.runners.get(conversationId);
|
|
298
|
+
if (runner === undefined) {
|
|
299
|
+
runner = new ChatTurnRunner(conversationId, {
|
|
300
|
+
sink: {
|
|
301
|
+
emitMessage: (id, message) => this.emitMessage(id, message),
|
|
302
|
+
emitChunk: (id, seq, text) => this.bus.publish('chat/chunk', [id, seq, text]),
|
|
303
|
+
emitStatus: (id, status) => this.emitStatus(id, status),
|
|
304
|
+
},
|
|
305
|
+
llm: this.ctx.llm,
|
|
306
|
+
store: this.store,
|
|
307
|
+
record: () => this.turnRecord(conversationId),
|
|
308
|
+
onTurnStart: () => this.turnStart(conversationId),
|
|
309
|
+
onTurnEnd: () => this.turnEnd(conversationId),
|
|
310
|
+
});
|
|
311
|
+
this.runners.set(conversationId, runner);
|
|
312
|
+
}
|
|
313
|
+
return runner;
|
|
314
|
+
}
|
|
315
|
+
async turnRecord(conversationId) {
|
|
316
|
+
const record = await this.store.require(conversationId);
|
|
317
|
+
return {
|
|
318
|
+
provider: record.provider,
|
|
319
|
+
model: record.model,
|
|
320
|
+
temperature: record.temperature,
|
|
321
|
+
contextMessages: record.contextMessages,
|
|
322
|
+
systemPrompt: record.systemPrompt,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/** Persist turn-start bookkeeping: shift the queued send and mark running. */
|
|
326
|
+
async turnStart(conversationId) {
|
|
327
|
+
const record = await this.store.require(conversationId);
|
|
328
|
+
await this.store.put({
|
|
329
|
+
...record,
|
|
330
|
+
running: true,
|
|
331
|
+
queue: record.queue.slice(1),
|
|
332
|
+
updatedAt: Date.now(),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/** Persist turn-end bookkeeping: release the running flag. */
|
|
336
|
+
async turnEnd(conversationId) {
|
|
337
|
+
const record = await this.store.require(conversationId);
|
|
338
|
+
await this.store.put({ ...record, running: false, updatedAt: Date.now() });
|
|
339
|
+
}
|
|
340
|
+
emitMessage(conversationId, message) {
|
|
341
|
+
this.bus.publish('chat/message', [conversationId, message.seq, message]);
|
|
342
|
+
}
|
|
343
|
+
emitStatus(conversationId, status) {
|
|
344
|
+
this.bus.publish('chat/status', [conversationId, status]);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
})();
|
|
348
|
+
export { ChatService };
|
|
349
|
+
function clampPage(maxMessages) {
|
|
350
|
+
if (maxMessages === undefined)
|
|
351
|
+
return DEFAULT_HISTORY_PAGE;
|
|
352
|
+
return Math.min(Math.max(Math.trunc(maxMessages), 1), MAX_HISTORY_PAGE);
|
|
353
|
+
}
|
|
354
|
+
/** Accept only text blocks on the v1 wire; anything else is rejected up front. */
|
|
355
|
+
function validateTextBlocks(content) {
|
|
356
|
+
const blocks = [];
|
|
357
|
+
for (const block of content) {
|
|
358
|
+
if (typeof block !== 'object' || block === null || block.type !== 'text' || typeof block.text !== 'string') {
|
|
359
|
+
throw new ChatServiceError('unsupported-block', 'chat: v1 accepts text blocks only');
|
|
360
|
+
}
|
|
361
|
+
blocks.push({ type: 'text', text: block.text });
|
|
362
|
+
}
|
|
363
|
+
return blocks;
|
|
364
|
+
}
|
|
365
|
+
function settingsView(record) {
|
|
366
|
+
return {
|
|
367
|
+
conversationId: record.conversationId,
|
|
368
|
+
title: record.title,
|
|
369
|
+
provider: record.provider,
|
|
370
|
+
model: record.model,
|
|
371
|
+
temperature: record.temperature,
|
|
372
|
+
contextMessages: record.contextMessages,
|
|
373
|
+
systemPrompt: record.systemPrompt,
|
|
374
|
+
updatedAt: record.updatedAt,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
export default ChatService;
|
|
378
|
+
// Re-export the storage error so consumers can classify RPC failures.
|
|
379
|
+
export { ChatStorageError };
|
package/lib/runner.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming turn runner for one chat conversation: FIFO queue, one turn at a
|
|
3
|
+
* time, chunk event emission, cancel/abort settlement, and window-trim
|
|
4
|
+
* signaling. The llm seam is structural (`LlmLike`) so unit tests run with a
|
|
5
|
+
* fake stream in this repo; the live service wires `ctx.llm` in.
|
|
6
|
+
* @module @prur/dsh-chat-service/src/runner
|
|
7
|
+
*/
|
|
8
|
+
import type { ChatMessage, ChatStatus } from './types.ts';
|
|
9
|
+
import type { AssembledMessage } from './engine.ts';
|
|
10
|
+
import type { PendingSend } from './store.ts';
|
|
11
|
+
/** Options a turn passes to the llm stream (structural, llm-compatible). */
|
|
12
|
+
export interface LlmStreamOptions {
|
|
13
|
+
readonly provider: string;
|
|
14
|
+
readonly model: string;
|
|
15
|
+
readonly messages: readonly AssembledMessage[];
|
|
16
|
+
readonly system?: string;
|
|
17
|
+
readonly temperature?: number;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
/** Streaming chunk projection consumed by the runner. */
|
|
21
|
+
export interface LlmChunkLike {
|
|
22
|
+
readonly type: string;
|
|
23
|
+
readonly index?: number;
|
|
24
|
+
readonly text?: string;
|
|
25
|
+
readonly block?: {
|
|
26
|
+
readonly type: string;
|
|
27
|
+
readonly text?: string;
|
|
28
|
+
};
|
|
29
|
+
readonly reason?: {
|
|
30
|
+
readonly kind: string;
|
|
31
|
+
readonly failure?: {
|
|
32
|
+
readonly message: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Structural llm service seam (matches `ctx.llm`'s used surface). */
|
|
37
|
+
export interface LlmLike {
|
|
38
|
+
stream(options: LlmStreamOptions): AsyncIterable<LlmChunkLike>;
|
|
39
|
+
resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
|
|
40
|
+
readonly context?: {
|
|
41
|
+
readonly contextWindow: number;
|
|
42
|
+
};
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
/** Event sink for the forwarded chat triplet (host allowlist). */
|
|
46
|
+
export interface ChatEventSink {
|
|
47
|
+
emitMessage(conversationId: string, message: ChatMessage): void;
|
|
48
|
+
emitChunk(conversationId: string, seq: number, text: string): void;
|
|
49
|
+
emitStatus(conversationId: string, status: ChatStatus): void;
|
|
50
|
+
}
|
|
51
|
+
/** Per-turn configuration snapshot: the conversation record at turn start. */
|
|
52
|
+
export interface TurnRecord {
|
|
53
|
+
readonly provider: string;
|
|
54
|
+
readonly model: string;
|
|
55
|
+
readonly temperature: number | null;
|
|
56
|
+
readonly contextMessages: number | null;
|
|
57
|
+
readonly systemPrompt: string | null;
|
|
58
|
+
}
|
|
59
|
+
/** Storage interface the runner needs (subset of {@link ChatStore}). */
|
|
60
|
+
export interface RunnerStore {
|
|
61
|
+
readMessages(conversationId: string): Promise<ChatMessage[]>;
|
|
62
|
+
appendMessage(conversationId: string, message: ChatMessage): Promise<{
|
|
63
|
+
trimmed: boolean;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
/** Next monotonic message sequence for a conversation's persisted history. */
|
|
67
|
+
export declare function nextSeq(messages: readonly ChatMessage[]): number;
|
|
68
|
+
/**
|
|
69
|
+
* Serialized per-conversation turn machine: sends enqueue; the queue drains
|
|
70
|
+
* one turn at a time; cancel aborts only the in-flight turn.
|
|
71
|
+
*/
|
|
72
|
+
export declare class ChatTurnRunner {
|
|
73
|
+
private readonly conversationId;
|
|
74
|
+
private readonly deps;
|
|
75
|
+
private readonly queue;
|
|
76
|
+
private running;
|
|
77
|
+
private controller;
|
|
78
|
+
private disposed;
|
|
79
|
+
constructor(conversationId: string, deps: {
|
|
80
|
+
readonly sink: ChatEventSink;
|
|
81
|
+
readonly llm: LlmLike;
|
|
82
|
+
readonly store: RunnerStore;
|
|
83
|
+
readonly record: () => Promise<TurnRecord>;
|
|
84
|
+
/** Persist the turn-start bookkeeping (running flag, queue shift). */
|
|
85
|
+
readonly onTurnStart?: () => Promise<void>;
|
|
86
|
+
/** Persist the turn-end bookkeeping (running flag release). */
|
|
87
|
+
readonly onTurnEnd?: () => Promise<void>;
|
|
88
|
+
});
|
|
89
|
+
/** Whether a turn is currently streaming. */
|
|
90
|
+
isRunning(): boolean;
|
|
91
|
+
/** Enqueue a user send and start draining if idle. */
|
|
92
|
+
enqueue(send: PendingSend): void;
|
|
93
|
+
/** Abort the in-flight turn; returns whether one was running. */
|
|
94
|
+
cancel(): boolean;
|
|
95
|
+
/** Stop queue advances; an in-flight turn still settles before the runner is dropped. */
|
|
96
|
+
dispose(): void;
|
|
97
|
+
private drain;
|
|
98
|
+
private runTurn;
|
|
99
|
+
private emitMessage;
|
|
100
|
+
private emitStatus;
|
|
101
|
+
private resolveWindow;
|
|
102
|
+
}
|