@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/store.d.ts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* append cannot corrupt a log.
|
|
11
11
|
* @module @prur/dsh-chat-service/src/store
|
|
12
12
|
*/
|
|
13
|
-
import type {
|
|
13
|
+
import type { ChatBlock, ChatMessage } from './types.ts';
|
|
14
14
|
/** One still-queued user send (content persisted before the turn runs). */
|
|
15
15
|
export interface PendingSend {
|
|
16
16
|
/** Persisted user message seq this send belongs to — the turn's context boundary. */
|
|
17
17
|
readonly seq: number;
|
|
18
|
-
|
|
18
|
+
/** Persisted blocks of the user message (durable refs; no upload bytes). */
|
|
19
|
+
readonly content: readonly ChatBlock[];
|
|
19
20
|
readonly createdAt: number;
|
|
20
21
|
}
|
|
21
22
|
/** Durable per-conversation record, held in `index.json`. */
|
|
@@ -32,6 +33,14 @@ export interface ChatIndexRecord {
|
|
|
32
33
|
readonly queue: readonly PendingSend[];
|
|
33
34
|
/** Mirror of the live run state, persisted so restart recovery can resume the queue. */
|
|
34
35
|
readonly running: boolean;
|
|
36
|
+
/** Archive flag (M4); legacy records treat absence as false. */
|
|
37
|
+
readonly archived?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Durable monotonic sequence high-water mark (never reused — even when a
|
|
40
|
+
* message is deleted, the next allocated seq keeps advancing). Legacy
|
|
41
|
+
* records without the field normalize from their log on first use.
|
|
42
|
+
*/
|
|
43
|
+
readonly seqHighWater: number;
|
|
35
44
|
}
|
|
36
45
|
/** Storage failure raised by a missing conversation; mapped to an RPC error. */
|
|
37
46
|
export declare class ChatStorageError extends Error {
|
|
@@ -62,18 +71,64 @@ export declare class ChatStore {
|
|
|
62
71
|
/** @throws {@link ChatStorageError} when the conversation does not exist. */
|
|
63
72
|
require(conversationId: string): Promise<ChatIndexRecord>;
|
|
64
73
|
put(record: ChatIndexRecord): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Atomic read-modify-write of one conversation record: the read and the
|
|
76
|
+
* write happen inside one store-wide write lock, so a concurrent queue
|
|
77
|
+
* append / flag flip cannot be lost to an outdated snapshot (the
|
|
78
|
+
* require-then-put pattern is a lost-update hazard under concurrent
|
|
79
|
+
* `chat/send` / `chat/regenerate` / turn bookkeeping).
|
|
80
|
+
* @throws {@link ChatStorageError} when the conversation does not exist.
|
|
81
|
+
*/
|
|
82
|
+
mutateRecord(conversationId: string, mutate: (record: ChatIndexRecord) => ChatIndexRecord): Promise<ChatIndexRecord>;
|
|
65
83
|
/** Delete a conversation and its log; idempotent. */
|
|
66
84
|
remove(conversationId: string): Promise<void>;
|
|
67
85
|
readMessages(conversationId: string): Promise<ChatMessage[]>;
|
|
86
|
+
/**
|
|
87
|
+
* Serialize one combined conversation mutation (read record + messages,
|
|
88
|
+
* write index, optionally rewrite the log) under the store-wide write
|
|
89
|
+
* lock. Competing tail mutations (regenerate / send / deleteMessage) are
|
|
90
|
+
* serialized at this boundary, so a second regenerate cannot double-enqueue
|
|
91
|
+
* the same turn against a stale view (review P1).
|
|
92
|
+
*/
|
|
93
|
+
mutateConversation<T>(conversationId: string, mutate: (record: ChatIndexRecord, messages: ChatMessage[]) => Promise<{
|
|
94
|
+
nextRecord: ChatIndexRecord;
|
|
95
|
+
nextMessages?: ChatMessage[];
|
|
96
|
+
result: T;
|
|
97
|
+
}>): Promise<T>;
|
|
98
|
+
/**
|
|
99
|
+
* Allocate the next monotonic message seq from the record high-water mark
|
|
100
|
+
* and persist the bump atomically. Sequence identities are never reused —
|
|
101
|
+
* even after a message is deleted, the next allocation keeps advancing
|
|
102
|
+
* (a crash between this bump and the log append only skips a seq).
|
|
103
|
+
*/
|
|
104
|
+
reserveTurnSeq(conversationId: string): Promise<number>;
|
|
68
105
|
/**
|
|
69
106
|
* Append one message under the write lock and maintain the per-conversation
|
|
70
107
|
* cap: when the cap would be exceeded, the oldest messages are dropped and
|
|
71
108
|
* a system marker row is appended in their place (client renders a notice).
|
|
109
|
+
* The marker seq is a fresh high-water value (above every real seq), so a
|
|
110
|
+
* trimmed conversation keeps clean monotonic anchors.
|
|
72
111
|
* @returns whether trimming occurred.
|
|
73
112
|
*/
|
|
74
113
|
appendMessage(conversationId: string, message: ChatMessage): Promise<{
|
|
75
114
|
trimmed: boolean;
|
|
76
115
|
}>;
|
|
116
|
+
/**
|
|
117
|
+
* Normalize legacy records without `seqHighWater`: persist the log's max
|
|
118
|
+
* seq so regeneration and future allocations keep monotonic identity.
|
|
119
|
+
*/
|
|
120
|
+
normalizeSeqHighWaters(): Promise<void>;
|
|
121
|
+
/**
|
|
122
|
+
* Rewrite the log without one message (design §2.3: host rewrites the
|
|
123
|
+
* message file; subsequent context assembly recalculates). Missing seq is
|
|
124
|
+
* reported via `removed: false` (idempotent). Never touches the running
|
|
125
|
+
* flag or the queue — queued sends bound to the deleted seq are dropped by
|
|
126
|
+
* the caller.
|
|
127
|
+
* @returns whether a message with `seq` was present and removed.
|
|
128
|
+
*/
|
|
129
|
+
deleteMessage(conversationId: string, seq: number): Promise<{
|
|
130
|
+
removed: boolean;
|
|
131
|
+
}>;
|
|
77
132
|
/**
|
|
78
133
|
* Page history newest-first paging semantics like `session.history`: the
|
|
79
134
|
* tail page (no `beforeSeq`) returns the newest `maxMessages` messages in
|
package/lib/store.js
CHANGED
|
@@ -26,6 +26,15 @@ export class ChatStorageError extends Error {
|
|
|
26
26
|
export const CHAT_MESSAGE_CAP = 500;
|
|
27
27
|
/** Marker row inserted after trimming: keeps the timeline readable for clients. */
|
|
28
28
|
export const CHAT_TRIM_MARKER_TEXT = '上下文已裁剪';
|
|
29
|
+
/** Next monotonic message sequence from a history's max seq (legacy path). */
|
|
30
|
+
function nextSeqOf(messages) {
|
|
31
|
+
let last = 0;
|
|
32
|
+
for (const message of messages) {
|
|
33
|
+
if (message.seq > last)
|
|
34
|
+
last = message.seq;
|
|
35
|
+
}
|
|
36
|
+
return last + 1;
|
|
37
|
+
}
|
|
29
38
|
/** conversationId charset guard: ids are opaque tokens, never path syntax. */
|
|
30
39
|
const CONVERSATION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
31
40
|
/** Whether a candidate conversation id is safe to use as a filename stem. */
|
|
@@ -62,6 +71,12 @@ export class ChatStore {
|
|
|
62
71
|
return join(this.chatHome, 'index.json');
|
|
63
72
|
}
|
|
64
73
|
logPath(conversationId) {
|
|
74
|
+
// 纵深防御(安全审计 2026-08-26):RPC 边界的 zod regex 是唯一闸门——若未来
|
|
75
|
+
// 新增 @Remote 路径漏用该 codec,join() 会直接逃逸 $DSH_HOME/chat。store 层
|
|
76
|
+
// 重校验一次,把"路径安全"从不变量化边界提前到每个文件操作。
|
|
77
|
+
if (!isSafeConversationId(conversationId)) {
|
|
78
|
+
throw new ChatStorageError('corrupt', `chat store: unsafe conversationId ${JSON.stringify(conversationId)}`);
|
|
79
|
+
}
|
|
65
80
|
return join(this.chatHome, `${conversationId}.jsonl`);
|
|
66
81
|
}
|
|
67
82
|
/** Serialize every mutation: one store-wide chain, errors contained per op. */
|
|
@@ -98,6 +113,27 @@ export class ChatStore {
|
|
|
98
113
|
await writeFileAtomic(this.indexPath(), JSON.stringify(next, null, 2));
|
|
99
114
|
});
|
|
100
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Atomic read-modify-write of one conversation record: the read and the
|
|
118
|
+
* write happen inside one store-wide write lock, so a concurrent queue
|
|
119
|
+
* append / flag flip cannot be lost to an outdated snapshot (the
|
|
120
|
+
* require-then-put pattern is a lost-update hazard under concurrent
|
|
121
|
+
* `chat/send` / `chat/regenerate` / turn bookkeeping).
|
|
122
|
+
* @throws {@link ChatStorageError} when the conversation does not exist.
|
|
123
|
+
*/
|
|
124
|
+
async mutateRecord(conversationId, mutate) {
|
|
125
|
+
return this.withWriteLock(async () => {
|
|
126
|
+
const records = await readJson(this.indexPath(), []);
|
|
127
|
+
const index = records.findIndex(record => record.conversationId === conversationId);
|
|
128
|
+
if (index < 0) {
|
|
129
|
+
throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
|
|
130
|
+
}
|
|
131
|
+
const next = mutate(records[index]);
|
|
132
|
+
records[index] = next;
|
|
133
|
+
await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
101
137
|
/** Delete a conversation and its log; idempotent. */
|
|
102
138
|
async remove(conversationId) {
|
|
103
139
|
return this.withWriteLock(async () => {
|
|
@@ -131,15 +167,72 @@ export class ChatStore {
|
|
|
131
167
|
throw new ChatStorageError('io', `chat: cannot read log: ${error.message}`);
|
|
132
168
|
}
|
|
133
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Serialize one combined conversation mutation (read record + messages,
|
|
172
|
+
* write index, optionally rewrite the log) under the store-wide write
|
|
173
|
+
* lock. Competing tail mutations (regenerate / send / deleteMessage) are
|
|
174
|
+
* serialized at this boundary, so a second regenerate cannot double-enqueue
|
|
175
|
+
* the same turn against a stale view (review P1).
|
|
176
|
+
*/
|
|
177
|
+
async mutateConversation(conversationId, mutate) {
|
|
178
|
+
return this.withWriteLock(async () => {
|
|
179
|
+
const records = await readJson(this.indexPath(), []);
|
|
180
|
+
const index = records.findIndex(record => record.conversationId === conversationId);
|
|
181
|
+
if (index < 0) {
|
|
182
|
+
throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
|
|
183
|
+
}
|
|
184
|
+
const messages = await this.readMessages(conversationId);
|
|
185
|
+
const { nextRecord, nextMessages, result } = await mutate(records[index], messages);
|
|
186
|
+
records[index] = nextRecord;
|
|
187
|
+
await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
|
|
188
|
+
if (nextMessages !== undefined) {
|
|
189
|
+
await writeFileAtomic(this.logPath(conversationId), `${nextMessages.map(item => JSON.stringify(item)).join('\n')}\n`);
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Allocate the next monotonic message seq from the record high-water mark
|
|
196
|
+
* and persist the bump atomically. Sequence identities are never reused —
|
|
197
|
+
* even after a message is deleted, the next allocation keeps advancing
|
|
198
|
+
* (a crash between this bump and the log append only skips a seq).
|
|
199
|
+
*/
|
|
200
|
+
async reserveTurnSeq(conversationId) {
|
|
201
|
+
return this.withWriteLock(async () => {
|
|
202
|
+
await this.ensure();
|
|
203
|
+
const records = await readJson(this.indexPath(), []);
|
|
204
|
+
const index = records.findIndex(record => record.conversationId === conversationId);
|
|
205
|
+
if (index < 0) {
|
|
206
|
+
throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
|
|
207
|
+
}
|
|
208
|
+
const record = records[index];
|
|
209
|
+
const messages = await this.readMessages(conversationId);
|
|
210
|
+
const next = (record.seqHighWater ?? nextSeqOf(messages)) + 1;
|
|
211
|
+
records[index] = { ...record, seqHighWater: next };
|
|
212
|
+
await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
|
|
213
|
+
return next;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
134
216
|
/**
|
|
135
217
|
* Append one message under the write lock and maintain the per-conversation
|
|
136
218
|
* cap: when the cap would be exceeded, the oldest messages are dropped and
|
|
137
219
|
* a system marker row is appended in their place (client renders a notice).
|
|
220
|
+
* The marker seq is a fresh high-water value (above every real seq), so a
|
|
221
|
+
* trimmed conversation keeps clean monotonic anchors.
|
|
138
222
|
* @returns whether trimming occurred.
|
|
139
223
|
*/
|
|
140
224
|
async appendMessage(conversationId, message) {
|
|
141
225
|
return this.withWriteLock(async () => {
|
|
142
226
|
await this.ensure();
|
|
227
|
+
// 锁内核对 index 存在性:对话已删除时其日志必须一起死亡——并发
|
|
228
|
+
// chat/delete 与 send 的竞态若绕开此检查,会按旧路径复活一个孤儿日志
|
|
229
|
+
// (index 是唯一事实源;孤儿文件客户端不可见,但会永久残留)。
|
|
230
|
+
const records = await readJson(this.indexPath(), []);
|
|
231
|
+
const index = records.findIndex(item => item.conversationId === conversationId);
|
|
232
|
+
if (index < 0) {
|
|
233
|
+
throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
|
|
234
|
+
}
|
|
235
|
+
const record = records[index];
|
|
143
236
|
const messages = await this.readMessages(conversationId);
|
|
144
237
|
messages.push(message);
|
|
145
238
|
const excess = messages.length - CHAT_MESSAGE_CAP;
|
|
@@ -150,20 +243,65 @@ export class ChatStore {
|
|
|
150
243
|
// One marker at a time: drop every existing system row, then trim the
|
|
151
244
|
// oldest real rows to the cap minus the marker slot, then append the
|
|
152
245
|
// fresh marker (a notice at the trim boundary, not a per-append flood).
|
|
153
|
-
const real = messages.filter(
|
|
246
|
+
const real = messages.filter(item => item.role !== 'system');
|
|
154
247
|
const kept = real.slice(real.length - (CHAT_MESSAGE_CAP - 1));
|
|
248
|
+
const markerSeq = (record.seqHighWater ?? nextSeqOf(messages)) + 1;
|
|
155
249
|
const marker = {
|
|
156
|
-
seq:
|
|
250
|
+
seq: markerSeq,
|
|
157
251
|
role: 'system',
|
|
158
252
|
blocks: [{ type: 'text', text: CHAT_TRIM_MARKER_TEXT }],
|
|
159
253
|
createdAt: Date.now(),
|
|
160
254
|
};
|
|
255
|
+
records[index] = { ...record, seqHighWater: markerSeq };
|
|
256
|
+
await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
|
|
161
257
|
next = [...kept, marker];
|
|
162
258
|
}
|
|
163
259
|
await writeFileAtomic(this.logPath(conversationId), `${next.map(item => JSON.stringify(item)).join('\n')}\n`);
|
|
164
260
|
return { trimmed };
|
|
165
261
|
});
|
|
166
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Normalize legacy records without `seqHighWater`: persist the log's max
|
|
265
|
+
* seq so regeneration and future allocations keep monotonic identity.
|
|
266
|
+
*/
|
|
267
|
+
async normalizeSeqHighWaters() {
|
|
268
|
+
return this.withWriteLock(async () => {
|
|
269
|
+
const records = await readJson(this.indexPath(), []);
|
|
270
|
+
let changed = false;
|
|
271
|
+
for (const record of records) {
|
|
272
|
+
if (record.seqHighWater === undefined) {
|
|
273
|
+
const messages = await this.readMessages(record.conversationId);
|
|
274
|
+
records[records.findIndex(item => item.conversationId === record.conversationId)] = {
|
|
275
|
+
...record,
|
|
276
|
+
seqHighWater: nextSeqOf(messages),
|
|
277
|
+
archived: record.archived === true,
|
|
278
|
+
};
|
|
279
|
+
changed = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (changed) {
|
|
283
|
+
await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Rewrite the log without one message (design §2.3: host rewrites the
|
|
289
|
+
* message file; subsequent context assembly recalculates). Missing seq is
|
|
290
|
+
* reported via `removed: false` (idempotent). Never touches the running
|
|
291
|
+
* flag or the queue — queued sends bound to the deleted seq are dropped by
|
|
292
|
+
* the caller.
|
|
293
|
+
* @returns whether a message with `seq` was present and removed.
|
|
294
|
+
*/
|
|
295
|
+
async deleteMessage(conversationId, seq) {
|
|
296
|
+
return this.withWriteLock(async () => {
|
|
297
|
+
const messages = await this.readMessages(conversationId);
|
|
298
|
+
const next = messages.filter(message => message.seq !== seq);
|
|
299
|
+
if (next.length === messages.length)
|
|
300
|
+
return { removed: false };
|
|
301
|
+
await writeFileAtomic(this.logPath(conversationId), `${next.map(item => JSON.stringify(item)).join('\n')}\n`);
|
|
302
|
+
return { removed: true };
|
|
303
|
+
});
|
|
304
|
+
}
|
|
167
305
|
/**
|
|
168
306
|
* Page history newest-first paging semantics like `session.history`: the
|
|
169
307
|
* tail page (no `beforeSeq`) returns the newest `maxMessages` messages in
|
package/lib/typert.host.js
CHANGED
|
@@ -16,10 +16,23 @@ const textBlockSchema = z.object({
|
|
|
16
16
|
type: z.literal('text'),
|
|
17
17
|
text: z.string(),
|
|
18
18
|
}).readonly();
|
|
19
|
+
const imageRefSchema = z.object({
|
|
20
|
+
attachmentId: z.string().min(1),
|
|
21
|
+
mediaType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
|
|
22
|
+
bytes: z.number().int().nonnegative(),
|
|
23
|
+
width: z.number().int().positive(),
|
|
24
|
+
height: z.number().int().positive(),
|
|
25
|
+
name: z.string().optional(),
|
|
26
|
+
}).readonly();
|
|
27
|
+
const imageBlockSchema = z.object({
|
|
28
|
+
type: z.literal('image'),
|
|
29
|
+
attachment: imageRefSchema,
|
|
30
|
+
}).readonly();
|
|
31
|
+
const blockSchema = z.union([textBlockSchema, imageBlockSchema]);
|
|
19
32
|
const messageSchema = z.object({
|
|
20
33
|
seq: z.number().int().nonnegative(),
|
|
21
34
|
role: z.enum(['user', 'assistant', 'system']),
|
|
22
|
-
blocks: z.array(
|
|
35
|
+
blocks: z.array(blockSchema).readonly(),
|
|
23
36
|
model: z.string().optional(),
|
|
24
37
|
provider: z.string().optional(),
|
|
25
38
|
createdAt: z.number().int().nonnegative().optional(),
|
|
@@ -33,6 +46,7 @@ const summarySchema = z.object({
|
|
|
33
46
|
updatedAt: z.number().int().nonnegative(),
|
|
34
47
|
messageCount: z.number().int().nonnegative(),
|
|
35
48
|
running: z.boolean(),
|
|
49
|
+
archived: z.boolean(),
|
|
36
50
|
}).readonly();
|
|
37
51
|
const settingsSchema = z.object({
|
|
38
52
|
conversationId: conversationIdSchema,
|
|
@@ -49,7 +63,13 @@ const patchSchema = z.object({
|
|
|
49
63
|
temperature: z.number().nullable().optional(),
|
|
50
64
|
contextMessages: z.number().int().positive().nullable().optional(),
|
|
51
65
|
}).readonly();
|
|
52
|
-
const
|
|
66
|
+
const imageUploadSchema = z.object({
|
|
67
|
+
type: z.literal('image'),
|
|
68
|
+
mediaType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
|
|
69
|
+
data: z.string().min(1),
|
|
70
|
+
name: z.string().optional(),
|
|
71
|
+
}).readonly();
|
|
72
|
+
const contentSchema = z.array(z.union([textBlockSchema, imageUploadSchema])).min(1).readonly();
|
|
53
73
|
const emptyOkSchema = z.object({}).readonly();
|
|
54
74
|
const listResultSchema = z.object({
|
|
55
75
|
ok: z.literal(true),
|
|
@@ -82,6 +102,27 @@ const updateResultSchema = z.object({
|
|
|
82
102
|
ok: z.literal(true),
|
|
83
103
|
value: settingsSchema,
|
|
84
104
|
}).readonly();
|
|
105
|
+
const deleteMessageResultSchema = z.object({
|
|
106
|
+
ok: z.literal(true),
|
|
107
|
+
value: z.object({ removed: z.boolean() }),
|
|
108
|
+
}).readonly();
|
|
109
|
+
const regenerateResultSchema = sendResultSchema;
|
|
110
|
+
const capabilitiesResultSchema = z.object({
|
|
111
|
+
ok: z.literal(true),
|
|
112
|
+
value: z.object({
|
|
113
|
+
provider: z.string(),
|
|
114
|
+
model: z.string(),
|
|
115
|
+
imageInput: z.boolean(),
|
|
116
|
+
contextWindow: z.number().int().positive().nullable(),
|
|
117
|
+
}),
|
|
118
|
+
}).readonly();
|
|
119
|
+
const attachmentResultSchema = z.object({
|
|
120
|
+
ok: z.literal(true),
|
|
121
|
+
value: z.object({
|
|
122
|
+
attachment: imageRefSchema,
|
|
123
|
+
data: z.string(),
|
|
124
|
+
}),
|
|
125
|
+
}).readonly();
|
|
85
126
|
const RESULT_TYPE_SYMBOLS = {
|
|
86
127
|
list: '@prur/dsh-chat-service/types#ChatListResult',
|
|
87
128
|
create: '@prur/dsh-chat-service/types#ChatCreateResult',
|
|
@@ -92,6 +133,11 @@ const RESULT_TYPE_SYMBOLS = {
|
|
|
92
133
|
cancel: '@prur/dsh-chat-service/types#ChatEmptyResult',
|
|
93
134
|
selectModel: '@prur/dsh-chat-service/types#ChatSelectModelResult',
|
|
94
135
|
update: '@prur/dsh-chat-service/types#ChatUpdateResult',
|
|
136
|
+
deleteMessage: '@prur/dsh-chat-service/types#ChatDeleteMessageResult',
|
|
137
|
+
regenerate: '@prur/dsh-chat-service/types#ChatRegenerateResult',
|
|
138
|
+
capabilities: '@prur/dsh-chat-service/types#ChatCapabilitiesResult',
|
|
139
|
+
attachment: '@prur/dsh-chat-service/types#ChatAttachmentResult',
|
|
140
|
+
archive: '@prur/dsh-chat-service/types#ChatEmptyResult',
|
|
95
141
|
};
|
|
96
142
|
const invocation = (id, method, parameters, result) => ({
|
|
97
143
|
id: `@prur/dsh-chat-service#${id}`,
|
|
@@ -131,7 +177,7 @@ export const TYPERT = {
|
|
|
131
177
|
], historyResultSchema),
|
|
132
178
|
invocation('chat/send', 'send', [
|
|
133
179
|
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
134
|
-
{ name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: '@prur/dsh-chat-service/types#
|
|
180
|
+
{ name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: '@prur/dsh-chat-service/types#ChatContentPart[]', schema: contentSchema } },
|
|
135
181
|
], sendResultSchema),
|
|
136
182
|
invocation('chat/cancel', 'cancel', [
|
|
137
183
|
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
@@ -145,6 +191,24 @@ export const TYPERT = {
|
|
|
145
191
|
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
146
192
|
{ name: 'patch', wire: 'patch', source: 'json', codec: { mode: 'strict', typeSymbol: '@prur/dsh-chat-service/types#ChatUpdatePatch', schema: patchSchema } },
|
|
147
193
|
], updateResultSchema),
|
|
194
|
+
invocation('chat/archive', 'archive', [
|
|
195
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
196
|
+
{ name: 'archived', wire: 'archived', source: 'json', codec: { mode: 'strict', typeSymbol: 'boolean', schema: z.boolean() } },
|
|
197
|
+
], emptyResultSchema),
|
|
198
|
+
invocation('chat/deleteMessage', 'deleteMessage', [
|
|
199
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
200
|
+
{ name: 'seq', wire: 'seq', source: 'json', codec: { mode: 'strict', typeSymbol: 'number', schema: z.number().int().nonnegative() } },
|
|
201
|
+
], deleteMessageResultSchema),
|
|
202
|
+
invocation('chat/regenerate', 'regenerate', [
|
|
203
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
204
|
+
], regenerateResultSchema),
|
|
205
|
+
invocation('chat/capabilities', 'capabilities', [
|
|
206
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
207
|
+
], capabilitiesResultSchema),
|
|
208
|
+
invocation('chat/attachment', 'attachment', [
|
|
209
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
|
|
210
|
+
{ name: 'attachmentId', wire: 'attachmentId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: z.string().min(1) } },
|
|
211
|
+
], attachmentResultSchema),
|
|
148
212
|
],
|
|
149
213
|
model: {
|
|
150
214
|
services: [],
|
|
@@ -15,6 +15,11 @@ const RESULT_TYPE_SYMBOLS = {
|
|
|
15
15
|
cancel: '@prur/dsh-chat-service/types#ChatEmptyResult',
|
|
16
16
|
selectModel: '@prur/dsh-chat-service/types#ChatSelectModelResult',
|
|
17
17
|
update: '@prur/dsh-chat-service/types#ChatUpdateResult',
|
|
18
|
+
deleteMessage: '@prur/dsh-chat-service/types#ChatDeleteMessageResult',
|
|
19
|
+
regenerate: '@prur/dsh-chat-service/types#ChatRegenerateResult',
|
|
20
|
+
capabilities: '@prur/dsh-chat-service/types#ChatCapabilitiesResult',
|
|
21
|
+
attachment: '@prur/dsh-chat-service/types#ChatAttachmentResult',
|
|
22
|
+
archive: '@prur/dsh-chat-service/types#ChatEmptyResult',
|
|
18
23
|
};
|
|
19
24
|
const descriptor = (id, method, parameters) => ({
|
|
20
25
|
id: `@prur/dsh-chat-service#${id}`,
|
|
@@ -65,6 +70,24 @@ export const TYPERT_REMOTE = {
|
|
|
65
70
|
{ name: 'conversationId', wire: 'conversationId', source: 'json' },
|
|
66
71
|
{ name: 'patch', wire: 'patch', source: 'json' },
|
|
67
72
|
]),
|
|
73
|
+
descriptor('chat/archive', 'archive', [
|
|
74
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json' },
|
|
75
|
+
{ name: 'archived', wire: 'archived', source: 'json' },
|
|
76
|
+
]),
|
|
77
|
+
descriptor('chat/deleteMessage', 'deleteMessage', [
|
|
78
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json' },
|
|
79
|
+
{ name: 'seq', wire: 'seq', source: 'json' },
|
|
80
|
+
]),
|
|
81
|
+
descriptor('chat/regenerate', 'regenerate', [
|
|
82
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json' },
|
|
83
|
+
]),
|
|
84
|
+
descriptor('chat/capabilities', 'capabilities', [
|
|
85
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json' },
|
|
86
|
+
]),
|
|
87
|
+
descriptor('chat/attachment', 'attachment', [
|
|
88
|
+
{ name: 'conversationId', wire: 'conversationId', source: 'json' },
|
|
89
|
+
{ name: 'attachmentId', wire: 'attachmentId', source: 'json' },
|
|
90
|
+
]),
|
|
68
91
|
],
|
|
69
92
|
};
|
|
70
93
|
export default TYPERT_REMOTE;
|
package/lib/types.d.ts
CHANGED
|
@@ -4,11 +4,43 @@
|
|
|
4
4
|
* shape lives here; the DSH-Mobile SDK mirrors it in `ChatRemoteClient`.
|
|
5
5
|
* @module @prur/dsh-chat-service/src/types
|
|
6
6
|
*/
|
|
7
|
-
/** One text content block
|
|
7
|
+
/** One text content block. */
|
|
8
8
|
export interface ChatTextBlock {
|
|
9
9
|
readonly type: 'text';
|
|
10
10
|
readonly text: string;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* A durable raster image reference carried by a persisted chat message.
|
|
14
|
+
* The bytes live in the harness attachment store; clients fetch them through
|
|
15
|
+
* `chat/attachment` after proving the conversation references the id
|
|
16
|
+
* (`chat/history` / `chat/message` events carry this ref in the block).
|
|
17
|
+
*/
|
|
18
|
+
export interface ChatImageAttachmentRef {
|
|
19
|
+
readonly attachmentId: string;
|
|
20
|
+
readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';
|
|
21
|
+
readonly bytes: number;
|
|
22
|
+
readonly width: number;
|
|
23
|
+
readonly height: number;
|
|
24
|
+
readonly name?: string;
|
|
25
|
+
}
|
|
26
|
+
/** One image content block as persisted on the wire (ref without bytes). */
|
|
27
|
+
export interface ChatImageBlock {
|
|
28
|
+
readonly type: 'image';
|
|
29
|
+
readonly attachment: ChatImageAttachmentRef;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One client-submitted content part for `chat/send`: a text span, or a
|
|
33
|
+
* base64 image upload admitted against the deployment's attachment limits
|
|
34
|
+
* and promoted to a durable {@link ChatImageBlock} on the message.
|
|
35
|
+
*/
|
|
36
|
+
export type ChatContentPart = ChatTextBlock | {
|
|
37
|
+
readonly type: 'image';
|
|
38
|
+
readonly mediaType: ChatImageAttachmentRef['mediaType'];
|
|
39
|
+
readonly data: string;
|
|
40
|
+
readonly name?: string;
|
|
41
|
+
};
|
|
42
|
+
/** One chat message content block persisted on the wire. */
|
|
43
|
+
export type ChatBlock = ChatTextBlock | ChatImageBlock;
|
|
12
44
|
/** Reason a running turn settled, carried by the `chat/status` event. */
|
|
13
45
|
export type ChatTurnReason = 'completed' | 'aborted' | 'error' | 'max-tokens' | 'context-trimmed';
|
|
14
46
|
/**
|
|
@@ -18,7 +50,7 @@ export type ChatTurnReason = 'completed' | 'aborted' | 'error' | 'max-tokens' |
|
|
|
18
50
|
export interface ChatMessage {
|
|
19
51
|
readonly seq: number;
|
|
20
52
|
readonly role: 'user' | 'assistant' | 'system';
|
|
21
|
-
readonly blocks: readonly
|
|
53
|
+
readonly blocks: readonly ChatBlock[];
|
|
22
54
|
/** Provider route that produced an assistant message. */
|
|
23
55
|
readonly model?: string;
|
|
24
56
|
/** Provider route key that produced an assistant message. */
|
|
@@ -45,6 +77,8 @@ export interface ChatConversationSummary {
|
|
|
45
77
|
readonly updatedAt: number;
|
|
46
78
|
readonly messageCount: number;
|
|
47
79
|
readonly running: boolean;
|
|
80
|
+
/** Archived conversations stay in the store but drop out of the active view. */
|
|
81
|
+
readonly archived: boolean;
|
|
48
82
|
}
|
|
49
83
|
/** Settings view of one conversation (returned by `chat/update`). */
|
|
50
84
|
export interface ChatConversationSettings {
|
|
@@ -57,6 +91,15 @@ export interface ChatConversationSettings {
|
|
|
57
91
|
readonly systemPrompt: string | null;
|
|
58
92
|
readonly updatedAt: number;
|
|
59
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* One `chat/update` field patch: `undefined` keeps the current value,
|
|
96
|
+
* `null` unsets it.
|
|
97
|
+
*/
|
|
98
|
+
export interface ChatUpdatePatch {
|
|
99
|
+
readonly systemPrompt?: string | null;
|
|
100
|
+
readonly temperature?: number | null;
|
|
101
|
+
readonly contextMessages?: number | null;
|
|
102
|
+
}
|
|
60
103
|
/** Successful chats of `chat/list`. */
|
|
61
104
|
export type ChatListResult = {
|
|
62
105
|
readonly ok: true;
|
|
@@ -104,3 +147,38 @@ export type ChatUpdateResult = {
|
|
|
104
147
|
readonly ok: true;
|
|
105
148
|
readonly value: ChatConversationSettings;
|
|
106
149
|
};
|
|
150
|
+
/** Successful value of `chat/deleteMessage` (idempotent: removed or absent). */
|
|
151
|
+
export type ChatDeleteMessageResult = {
|
|
152
|
+
readonly ok: true;
|
|
153
|
+
readonly value: {
|
|
154
|
+
readonly removed: boolean;
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
/** Successful value of `chat/regenerate`. */
|
|
158
|
+
export type ChatRegenerateResult = {
|
|
159
|
+
readonly ok: true;
|
|
160
|
+
readonly value: {
|
|
161
|
+
readonly accepted: true;
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
/** Successful value of `chat/capabilities`. */
|
|
165
|
+
export type ChatCapabilitiesResult = {
|
|
166
|
+
readonly ok: true;
|
|
167
|
+
readonly value: {
|
|
168
|
+
readonly provider: string;
|
|
169
|
+
readonly model: string;
|
|
170
|
+
/** Whether the model accepts image input (adapter `inputModalities`). */
|
|
171
|
+
readonly imageInput: boolean;
|
|
172
|
+
/** Advertised context window in tokens; null when unknown. */
|
|
173
|
+
readonly contextWindow: number | null;
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
/** Successful value of `chat/attachment`. */
|
|
177
|
+
export type ChatAttachmentResult = {
|
|
178
|
+
readonly ok: true;
|
|
179
|
+
readonly value: {
|
|
180
|
+
readonly attachment: ChatImageAttachmentRef;
|
|
181
|
+
/** Canonical base64 encoding of the image bytes. */
|
|
182
|
+
readonly data: string;
|
|
183
|
+
};
|
|
184
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prur/dsh-chat-service",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Typert Remote namespace exposing a plain streaming model chat (no agent loop) to DSH Mobile clients",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,17 +32,18 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dsh": null,
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"zod": "^4.4.3",
|
|
36
35
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
37
36
|
"ws": "^8.21.0",
|
|
38
|
-
"
|
|
37
|
+
"zod": "^4.4.3",
|
|
38
|
+
"@prur/dsh-client-connection-mobile": "^0.1.16"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
42
|
+
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.8 || ^0.1.1-rc.2",
|
|
43
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8 || ^0.1.1-rc.2",
|
|
42
44
|
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8 || ^0.1.1-rc.2",
|
|
43
45
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8 || ^0.1.1-rc.2",
|
|
44
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8 || ^0.1.1-rc.2"
|
|
45
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8 || ^0.1.1-rc.2"
|
|
46
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8 || ^0.1.1-rc.2"
|
|
46
47
|
},
|
|
47
48
|
"files": [
|
|
48
49
|
"lib/*.js",
|