@dxos/assistant 0.8.0 → 0.8.1-main.303c73a
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/dist/lib/browser/index.mjs +976 -536
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +2 -2
- package/dist/lib/browser/testing/index.mjs.map +1 -1
- package/dist/lib/node/index.cjs +1172 -735
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +22 -22
- package/dist/lib/node/testing/index.cjs.map +2 -2
- package/dist/lib/node-esm/index.mjs +976 -536
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing/index.mjs +2 -2
- package/dist/lib/node-esm/testing/index.mjs.map +1 -1
- package/dist/types/src/ai-service/edge-client.d.ts +19 -0
- package/dist/types/src/ai-service/edge-client.d.ts.map +1 -0
- package/dist/types/src/ai-service/index.d.ts +4 -1
- package/dist/types/src/ai-service/index.d.ts.map +1 -1
- package/dist/types/src/ai-service/message-collector.d.ts +88 -0
- package/dist/types/src/ai-service/message-collector.d.ts.map +1 -0
- package/dist/types/src/ai-service/ollama-client.d.ts +43 -0
- package/dist/types/src/ai-service/ollama-client.d.ts.map +1 -0
- package/dist/types/src/ai-service/parser.d.ts +3 -1
- package/dist/types/src/ai-service/parser.d.ts.map +1 -1
- package/dist/types/src/ai-service/service.d.ts +9 -0
- package/dist/types/src/ai-service/service.d.ts.map +1 -0
- package/dist/types/src/ai-service/stream.d.ts +2 -1
- package/dist/types/src/ai-service/stream.d.ts.map +1 -1
- package/dist/types/src/ai-service/testing/mock.d.ts +2 -2
- package/dist/types/src/ai-service/testing/mock.d.ts.map +1 -1
- package/dist/types/src/ai-service/types.d.ts +1 -1
- package/dist/types/src/ai-service/types.d.ts.map +1 -1
- package/dist/types/src/conversation/conversation.d.ts +3 -1
- package/dist/types/src/conversation/conversation.d.ts.map +1 -1
- package/dist/types/src/conversation/types.d.ts +1 -1
- package/dist/types/src/conversation/types.d.ts.map +1 -1
- package/dist/types/src/testing/query-promts.d.ts +1 -1
- package/dist/types/src/testing/query-promts.d.ts.map +1 -1
- package/dist/types/src/testing/test-data.d.ts +1 -1
- package/dist/types/src/testing/test-data.d.ts.map +1 -1
- package/dist/types/src/testing/test-schema.d.ts +1 -1
- package/dist/types/src/testing/test-schema.d.ts.map +1 -1
- package/package.json +17 -14
- package/src/ai-service/client.test.ts +94 -7
- package/src/ai-service/{client.ts → edge-client.ts} +15 -24
- package/src/ai-service/index.ts +4 -1
- package/src/ai-service/message-collector.ts +84 -0
- package/src/ai-service/ollama-client.ts +473 -0
- package/src/ai-service/parser.ts +7 -2
- package/src/ai-service/service.ts +15 -0
- package/src/ai-service/stream.ts +5 -3
- package/src/ai-service/testing/mock.ts +7 -2
- package/src/ai-service/types.ts +1 -1
- package/src/bin/chat.ts +2 -2
- package/src/conversation/conversation.test.ts +3 -3
- package/src/conversation/conversation.ts +3 -2
- package/src/conversation/types.ts +1 -1
- package/src/testing/artifacts.test.ts +2 -2
- package/src/testing/query-promts.ts +1 -1
- package/src/testing/query.test.ts +2 -2
- package/src/testing/test-data.ts +1 -1
- package/src/testing/test-schema.ts +1 -1
- package/dist/types/src/ai-service/client.d.ts +0 -25
- package/dist/types/src/ai-service/client.d.ts.map +0 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type MessageContentBlock, defineTool, type Tool, ToolResult } from '@dxos/artifact';
|
|
6
|
+
import { ObjectId, S } from '@dxos/echo-schema';
|
|
7
|
+
import { invariant } from '@dxos/invariant';
|
|
8
|
+
import { log } from '@dxos/log';
|
|
9
|
+
|
|
10
|
+
import { MessageCollector, emitMessageAsEvents } from './message-collector';
|
|
11
|
+
import { type AIServiceClient, type GenerationStream } from './service';
|
|
12
|
+
import { GenerationStreamImpl } from './stream';
|
|
13
|
+
import { ToolTypes, type GenerateRequest, type GenerationStreamEvent } from './types';
|
|
14
|
+
import { isToolUse, runTools } from '../conversation';
|
|
15
|
+
|
|
16
|
+
// TODO(burdon): Config.
|
|
17
|
+
const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
|
|
18
|
+
const DEFAULT_OLLAMA_MODEL = 'llama3.2:1b';
|
|
19
|
+
|
|
20
|
+
export type OllamaClientParams = {
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tools that are executed within the client.
|
|
25
|
+
*/
|
|
26
|
+
tools?: Tool[];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Override generation parameters.
|
|
30
|
+
*/
|
|
31
|
+
// TODO(burdon): Why not just options?
|
|
32
|
+
overrides?: {
|
|
33
|
+
model?: string;
|
|
34
|
+
temperature?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Maximum number of tool invocations in a single request that is allowed to run.
|
|
39
|
+
* @default 3
|
|
40
|
+
*/
|
|
41
|
+
maxToolInvocations?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export class OllamaClient implements AIServiceClient {
|
|
45
|
+
/**
|
|
46
|
+
* Create a test client with small local model and no temperature for predictable results.
|
|
47
|
+
*/
|
|
48
|
+
static createClient(options?: Pick<OllamaClientParams, 'tools'>) {
|
|
49
|
+
return new OllamaClient({
|
|
50
|
+
tools: options?.tools,
|
|
51
|
+
overrides: { model: DEFAULT_OLLAMA_MODEL, temperature: 0 },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if Ollama server is running and accessible.
|
|
57
|
+
* @returns Promise that resolves to true if Ollama is running, false otherwise.
|
|
58
|
+
*/
|
|
59
|
+
static async isRunning(): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(`${DEFAULT_OLLAMA_URL}/api/version`, {
|
|
62
|
+
method: 'GET',
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
},
|
|
66
|
+
signal: AbortSignal.timeout(100),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return response.ok;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private readonly _endpoint: string;
|
|
76
|
+
private readonly _tools: Tool[];
|
|
77
|
+
private readonly _modelOverride?: string;
|
|
78
|
+
private readonly _temperatureOverride?: number;
|
|
79
|
+
private readonly _maxToolInvocations: number;
|
|
80
|
+
|
|
81
|
+
constructor({ endpoint, tools, overrides, maxToolInvocations }: OllamaClientParams) {
|
|
82
|
+
this._endpoint = endpoint ?? DEFAULT_OLLAMA_URL;
|
|
83
|
+
this._tools = tools ?? [];
|
|
84
|
+
this._modelOverride = overrides?.model;
|
|
85
|
+
this._temperatureOverride = overrides?.temperature;
|
|
86
|
+
this._maxToolInvocations = maxToolInvocations ?? 3;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private _getEmbededTools(clientTools: Tool[]) {
|
|
90
|
+
return [
|
|
91
|
+
...this._tools,
|
|
92
|
+
...clientTools
|
|
93
|
+
.filter((tool) => tool.type !== undefined && WELL_KNOWN_TOOLS[tool.type])
|
|
94
|
+
.map((tool) => WELL_KNOWN_TOOLS[tool.type!]),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async *_generateStream(request: GenerateRequest, signal?: AbortSignal): AsyncIterable<GenerationStreamEvent> {
|
|
99
|
+
invariant(request.prompt === undefined, 'Prompt must be in history');
|
|
100
|
+
|
|
101
|
+
// Map DXOS request to Ollama API format.
|
|
102
|
+
const ollamaRequest: OllamaRequest = {
|
|
103
|
+
model: this._modelOverride ?? request.model ?? 'llama3',
|
|
104
|
+
messages: [],
|
|
105
|
+
stream: true,
|
|
106
|
+
options: {
|
|
107
|
+
temperature: this._temperatureOverride ?? 0.7,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Add system message if provided.
|
|
112
|
+
if (request.systemPrompt) {
|
|
113
|
+
ollamaRequest.messages.push({
|
|
114
|
+
role: 'system',
|
|
115
|
+
content: request.systemPrompt,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Add history if provided.
|
|
120
|
+
if (request.history?.length) {
|
|
121
|
+
for (const message of request.history) {
|
|
122
|
+
const role = message.role === 'assistant' ? 'assistant' : 'user';
|
|
123
|
+
|
|
124
|
+
// Process message content.
|
|
125
|
+
if (message.content.length === 0) {
|
|
126
|
+
// Skip empty messages
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const content of message.content) {
|
|
131
|
+
switch (content.type) {
|
|
132
|
+
case 'text': {
|
|
133
|
+
ollamaRequest.messages.push({
|
|
134
|
+
role,
|
|
135
|
+
content: content.text,
|
|
136
|
+
});
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 'tool_use': {
|
|
140
|
+
ollamaRequest.messages.push({
|
|
141
|
+
role: 'assistant',
|
|
142
|
+
content: '',
|
|
143
|
+
tool_calls: [
|
|
144
|
+
{
|
|
145
|
+
function: {
|
|
146
|
+
name: content.name,
|
|
147
|
+
arguments: content.input as any,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'tool_result': {
|
|
155
|
+
ollamaRequest.messages.push({
|
|
156
|
+
role: 'tool',
|
|
157
|
+
content: JSON.stringify({
|
|
158
|
+
type: 'tool_result',
|
|
159
|
+
toolUseId: content.toolUseId,
|
|
160
|
+
content: content.content,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Add tools if provided.
|
|
171
|
+
if (request.tools?.length || this._tools.length) {
|
|
172
|
+
const allTools = [...(request.tools ?? []), ...this._tools];
|
|
173
|
+
const withWellKnownTools = allTools.map((tool) =>
|
|
174
|
+
tool.type !== undefined && WELL_KNOWN_TOOLS[tool.type] ? WELL_KNOWN_TOOLS[tool.type] : tool,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (withWellKnownTools.length > 0) {
|
|
178
|
+
ollamaRequest.tools = withWellKnownTools.map((tool) => ({
|
|
179
|
+
type: 'function',
|
|
180
|
+
function: {
|
|
181
|
+
name: tool.name,
|
|
182
|
+
description: tool.description,
|
|
183
|
+
parameters: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: tool.parameters?.properties,
|
|
186
|
+
required: tool.parameters?.required,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
log.info('generate request', { history: request.history, prompt: request.prompt, ollamaRequest });
|
|
194
|
+
|
|
195
|
+
const response = await fetch(`${this._endpoint}/api/chat`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify(ollamaRequest),
|
|
201
|
+
signal,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// log.info('ollama response', { response: await response.text() });
|
|
205
|
+
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!response.body) {
|
|
211
|
+
throw new Error('Ollama API returned no response body');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Create the stream
|
|
215
|
+
const reader = response
|
|
216
|
+
.body!.pipeThrough(new TextDecoderStream())
|
|
217
|
+
.pipeThrough(new OllamaDecoderStream())
|
|
218
|
+
.getReader();
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
// Create a message ID.
|
|
222
|
+
const messageId = ObjectId.random();
|
|
223
|
+
|
|
224
|
+
// Send message_start event with proper message structure.
|
|
225
|
+
yield {
|
|
226
|
+
type: 'message_start',
|
|
227
|
+
message: {
|
|
228
|
+
id: messageId,
|
|
229
|
+
role: 'assistant',
|
|
230
|
+
content: [],
|
|
231
|
+
},
|
|
232
|
+
} as GenerationStreamEvent;
|
|
233
|
+
|
|
234
|
+
// Initialize text content block.
|
|
235
|
+
const textBlock: MessageContentBlock = {
|
|
236
|
+
type: 'text',
|
|
237
|
+
text: '',
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
let currentBlockIndex = 0;
|
|
241
|
+
|
|
242
|
+
// Send content_block_start event.
|
|
243
|
+
yield {
|
|
244
|
+
type: 'content_block_start',
|
|
245
|
+
index: currentBlockIndex,
|
|
246
|
+
content: textBlock,
|
|
247
|
+
} as GenerationStreamEvent;
|
|
248
|
+
|
|
249
|
+
while (true) {
|
|
250
|
+
const { done, value: data } = await reader.read();
|
|
251
|
+
|
|
252
|
+
if (done) {
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
log.info('ollama data', { data });
|
|
257
|
+
|
|
258
|
+
// Ollama sends chunks of text, we convert them to our event format.
|
|
259
|
+
if (data.message?.content) {
|
|
260
|
+
yield {
|
|
261
|
+
type: 'content_block_delta',
|
|
262
|
+
index: 0,
|
|
263
|
+
delta: {
|
|
264
|
+
type: 'text_delta',
|
|
265
|
+
text: data.message.content,
|
|
266
|
+
},
|
|
267
|
+
} as GenerationStreamEvent;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// If tool usage is detected.
|
|
271
|
+
if (data.message?.tool_calls && data.message?.tool_calls?.length > 0) {
|
|
272
|
+
if (data.message.tool_calls.length > 1) {
|
|
273
|
+
log.warn('Ollama returned multiple tool calls. Only the first one will be used.');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
yield {
|
|
277
|
+
type: 'content_block_stop',
|
|
278
|
+
index: currentBlockIndex++,
|
|
279
|
+
} as GenerationStreamEvent;
|
|
280
|
+
|
|
281
|
+
yield {
|
|
282
|
+
type: 'content_block_start',
|
|
283
|
+
index: currentBlockIndex,
|
|
284
|
+
content: {
|
|
285
|
+
type: 'tool_use',
|
|
286
|
+
id: ObjectId.random(),
|
|
287
|
+
name: data.message.tool_calls[0].function.name,
|
|
288
|
+
input: sanitizeToolArguments(data.message.tool_calls[0].function.arguments),
|
|
289
|
+
},
|
|
290
|
+
} as GenerationStreamEvent;
|
|
291
|
+
|
|
292
|
+
yield {
|
|
293
|
+
type: 'message_delta',
|
|
294
|
+
delta: {
|
|
295
|
+
stopReason: 'tool_use',
|
|
296
|
+
},
|
|
297
|
+
} as GenerationStreamEvent;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Send content_block_stop and message_stop events.
|
|
302
|
+
yield {
|
|
303
|
+
type: 'content_block_stop',
|
|
304
|
+
index: currentBlockIndex,
|
|
305
|
+
} as GenerationStreamEvent;
|
|
306
|
+
|
|
307
|
+
yield {
|
|
308
|
+
type: 'message_stop',
|
|
309
|
+
} as GenerationStreamEvent;
|
|
310
|
+
} finally {
|
|
311
|
+
reader.releaseLock();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async exec(request: GenerateRequest): Promise<GenerationStream> {
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
return new GenerationStreamImpl(
|
|
320
|
+
controller,
|
|
321
|
+
async function* (this: OllamaClient) {
|
|
322
|
+
const collector = new MessageCollector();
|
|
323
|
+
|
|
324
|
+
// Loop while running tools.
|
|
325
|
+
let toolInvocations = 0;
|
|
326
|
+
while (true) {
|
|
327
|
+
for await (const event of this._generateStream(
|
|
328
|
+
{
|
|
329
|
+
...request,
|
|
330
|
+
history: [
|
|
331
|
+
...(request.history ?? []),
|
|
332
|
+
...(request.prompt ? [request.prompt] : []),
|
|
333
|
+
...collector.messages,
|
|
334
|
+
],
|
|
335
|
+
prompt: undefined,
|
|
336
|
+
},
|
|
337
|
+
controller.signal,
|
|
338
|
+
)) {
|
|
339
|
+
collector.push(event);
|
|
340
|
+
yield event;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (
|
|
344
|
+
collector.messages.length > 0 &&
|
|
345
|
+
!isToolUse(collector.messages.at(-1)!, {
|
|
346
|
+
onlyToolNames: this._getEmbededTools(request.tools ?? []).map((tool) => tool.name),
|
|
347
|
+
})
|
|
348
|
+
) {
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (++toolInvocations > this._maxToolInvocations) {
|
|
353
|
+
throw new Error('Maximum number of tool invocations reached');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const result = await runTools({
|
|
357
|
+
message: collector.messages.at(-1)!,
|
|
358
|
+
tools: this._getEmbededTools(request.tools ?? []),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
switch (result.type) {
|
|
362
|
+
case 'continue': {
|
|
363
|
+
collector.pushMessage(result.message);
|
|
364
|
+
yield* emitMessageAsEvents(result.message);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case 'break': {
|
|
368
|
+
throw new Error('Tool use breaks not allowed');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}.bind(this),
|
|
373
|
+
);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
controller.abort();
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const sanitizeToolArguments = (args: any) => {
|
|
382
|
+
// TODO(dmaretskyi): Workaround for model bug.
|
|
383
|
+
if (args.type === 'object' && typeof args.properties === 'object' && Array.isArray(args.required)) {
|
|
384
|
+
return args.properties;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return args;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const SAMPLE_IMAGE_URL = 'https://images.nightcafe.studio/jobs/BNmcRhHCM1JRKoUtqSei/BNmcRhHCM1JRKoUtqSei--1--5b9rv.jpg';
|
|
391
|
+
|
|
392
|
+
const WELL_KNOWN_TOOLS: Record<string, Tool> = {
|
|
393
|
+
[ToolTypes.TextToImage]: defineTool('system', {
|
|
394
|
+
name: 'text-to-image',
|
|
395
|
+
description: 'Generate an image from a text prompt',
|
|
396
|
+
schema: S.Struct({
|
|
397
|
+
prompt: S.String.annotations({ description: 'The text prompt describing the image to generate' }),
|
|
398
|
+
}),
|
|
399
|
+
execute: async ({ prompt }) => {
|
|
400
|
+
const image = await fetch(SAMPLE_IMAGE_URL).then(async (res) => Buffer.from(await res.arrayBuffer()));
|
|
401
|
+
|
|
402
|
+
const imageBase64 = image.toString('base64');
|
|
403
|
+
const id = ObjectId.random();
|
|
404
|
+
|
|
405
|
+
return ToolResult.Success(`The generation was successful. Image ID: ${id} Prompt: ${prompt}`, [
|
|
406
|
+
{
|
|
407
|
+
type: 'image',
|
|
408
|
+
id,
|
|
409
|
+
source: {
|
|
410
|
+
type: 'base64',
|
|
411
|
+
mediaType: 'image/jpeg',
|
|
412
|
+
data: imageBase64,
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
]);
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
type OllamaMessage = {
|
|
421
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
422
|
+
content: string;
|
|
423
|
+
tool_calls?: Array<{
|
|
424
|
+
function: {
|
|
425
|
+
name: string;
|
|
426
|
+
arguments: string;
|
|
427
|
+
};
|
|
428
|
+
}>;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
type OllamaRequest = {
|
|
432
|
+
model: string;
|
|
433
|
+
messages: Array<OllamaMessage>;
|
|
434
|
+
stream: boolean;
|
|
435
|
+
options?: {
|
|
436
|
+
temperature?: number;
|
|
437
|
+
};
|
|
438
|
+
tools?: Array<{
|
|
439
|
+
type: 'function';
|
|
440
|
+
function: {
|
|
441
|
+
name: string;
|
|
442
|
+
description?: string;
|
|
443
|
+
parameters?: any;
|
|
444
|
+
};
|
|
445
|
+
}>;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
type OllamaResponseData = {
|
|
449
|
+
message?: OllamaMessage;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
class OllamaDecoderStream extends TransformStream<string, OllamaResponseData> {
|
|
453
|
+
private _buffer = '';
|
|
454
|
+
|
|
455
|
+
constructor() {
|
|
456
|
+
super({
|
|
457
|
+
transform: (chunk, controller) => {
|
|
458
|
+
this._buffer += chunk;
|
|
459
|
+
const lines = this._buffer.split('\n');
|
|
460
|
+
this._buffer = lines.pop() || '';
|
|
461
|
+
for (const line of lines) {
|
|
462
|
+
controller.enqueue(JSON.parse(line.trim()));
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
flush: (controller) => {
|
|
466
|
+
this._buffer = this._buffer.trim();
|
|
467
|
+
if (this._buffer.length > 0) {
|
|
468
|
+
controller.enqueue(JSON.parse(this._buffer));
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
package/src/ai-service/parser.ts
CHANGED
|
@@ -9,8 +9,9 @@ import { invariant } from '@dxos/invariant';
|
|
|
9
9
|
import { log } from '@dxos/log';
|
|
10
10
|
import { isNotFalsy, safeParseJson } from '@dxos/util';
|
|
11
11
|
|
|
12
|
-
import { type GenerationStream } from './
|
|
12
|
+
import { type GenerationStream } from './service';
|
|
13
13
|
import { StreamTransform, type StreamBlock } from './transform';
|
|
14
|
+
import type { GenerationStreamEvent } from './types';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Parse mixed content of plain text, XML fragments, and JSON blocks.
|
|
@@ -31,6 +32,8 @@ export class MixedStreamParser {
|
|
|
31
32
|
*/
|
|
32
33
|
public update = new Event<MessageContentBlock>();
|
|
33
34
|
|
|
35
|
+
public streamEvent = new Event<GenerationStreamEvent>();
|
|
36
|
+
|
|
34
37
|
/**
|
|
35
38
|
* Current message.
|
|
36
39
|
*/
|
|
@@ -77,6 +80,8 @@ export class MixedStreamParser {
|
|
|
77
80
|
const messagesCollected: Message[] = [];
|
|
78
81
|
|
|
79
82
|
for await (const event of stream) {
|
|
83
|
+
this.streamEvent.emit(event);
|
|
84
|
+
|
|
80
85
|
// log.info('event', { type: event.type, event });
|
|
81
86
|
switch (event.type) {
|
|
82
87
|
//
|
|
@@ -88,7 +93,7 @@ export class MixedStreamParser {
|
|
|
88
93
|
log.warn('unexpected message_start');
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
this._message = createStatic(Message, { role:
|
|
96
|
+
this._message = createStatic(Message, { role: event.message.role, content: [...event.message.content] });
|
|
92
97
|
this.message.emit(this._message);
|
|
93
98
|
break;
|
|
94
99
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type GenerateRequest, type GenerationStreamEvent } from './types';
|
|
6
|
+
|
|
7
|
+
// TODO(burdon): Create wrapper for edge/ollama. Create session?
|
|
8
|
+
export interface AIServiceClient {
|
|
9
|
+
exec(request: GenerateRequest): Promise<GenerationStream>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GenerationStream extends AsyncIterable<GenerationStreamEvent> {
|
|
13
|
+
abort(): void;
|
|
14
|
+
complete(): Promise<void>;
|
|
15
|
+
}
|
package/src/ai-service/stream.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { Trigger } from '@dxos/async';
|
|
6
6
|
import { log } from '@dxos/log';
|
|
7
7
|
|
|
8
|
+
import type { GenerationStream } from './service';
|
|
8
9
|
import { type GenerationStreamEvent } from './types';
|
|
9
10
|
import { iterSSEMessages } from './util';
|
|
10
11
|
|
|
@@ -49,14 +50,14 @@ export const createGenerationStream = (response: Response, controller = new Abor
|
|
|
49
50
|
}
|
|
50
51
|
};
|
|
51
52
|
|
|
52
|
-
return new
|
|
53
|
+
return new GenerationStreamImpl(controller, iterator);
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* Server-Sent Events (SSE) stream from the AI service.
|
|
57
58
|
* https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
|
|
58
59
|
*/
|
|
59
|
-
export class
|
|
60
|
+
export class GenerationStreamImpl implements GenerationStream {
|
|
60
61
|
/**
|
|
61
62
|
* Trigger event when the stream is done.
|
|
62
63
|
*/
|
|
@@ -99,7 +100,7 @@ export class GenerationStream implements AsyncIterable<GenerationStreamEvent> {
|
|
|
99
100
|
private _createIterator(): AsyncIterator<GenerationStreamEvent> {
|
|
100
101
|
const self = this;
|
|
101
102
|
return (this._iterator ??= (() => {
|
|
102
|
-
const generator = async function* (this:
|
|
103
|
+
const generator = async function* (this: GenerationStreamImpl) {
|
|
103
104
|
try {
|
|
104
105
|
for await (const event of self._getIterator()) {
|
|
105
106
|
yield event;
|
|
@@ -107,6 +108,7 @@ export class GenerationStream implements AsyncIterable<GenerationStreamEvent> {
|
|
|
107
108
|
this._done.wake();
|
|
108
109
|
} catch (err: any) {
|
|
109
110
|
this._done.throw(err);
|
|
111
|
+
throw err;
|
|
110
112
|
}
|
|
111
113
|
};
|
|
112
114
|
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type GenerationStream,
|
|
7
|
+
type AIServiceClient,
|
|
8
|
+
type GenerateRequest,
|
|
9
|
+
createGenerationStream,
|
|
10
|
+
} from '@dxos/assistant';
|
|
6
11
|
|
|
7
12
|
import { createTestSSEStream } from './test-stream';
|
|
8
13
|
|
|
9
|
-
export class MockAIService implements
|
|
14
|
+
export class MockAIService implements AIServiceClient {
|
|
10
15
|
constructor(private readonly _responses: string[]) {}
|
|
11
16
|
|
|
12
17
|
// TODO(burdon): Match response to request.
|
package/src/ai-service/types.ts
CHANGED
package/src/bin/chat.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { ObjectId } from '@dxos/echo-schema';
|
|
|
10
10
|
import { SpaceId } from '@dxos/keys';
|
|
11
11
|
import { log } from '@dxos/log';
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { AIServiceEdgeClient, ToolTypes, DEFAULT_LLM_MODEL } from '../ai-service';
|
|
14
14
|
import { runLLM } from '../conversation';
|
|
15
15
|
import {
|
|
16
16
|
AI_SERVICE_ENDPOINT,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from '../testing';
|
|
26
26
|
|
|
27
27
|
// TOOD(burdon): Get from config.
|
|
28
|
-
const client = new
|
|
28
|
+
const client = new AIServiceEdgeClient({
|
|
29
29
|
endpoint: AI_SERVICE_ENDPOINT.LOCAL,
|
|
30
30
|
});
|
|
31
31
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Schema as S } from '
|
|
5
|
+
import { Schema as S } from 'effect';
|
|
6
6
|
import { describe, test } from 'vitest';
|
|
7
7
|
|
|
8
8
|
import { createUserMessage, defineTool, ToolResult } from '@dxos/artifact';
|
|
@@ -11,12 +11,12 @@ import { SpaceId } from '@dxos/keys';
|
|
|
11
11
|
import { log } from '@dxos/log';
|
|
12
12
|
|
|
13
13
|
import { runLLM, type ConversationEvent } from './conversation';
|
|
14
|
-
import {
|
|
14
|
+
import { AIServiceEdgeClient, DEFAULT_LLM_MODEL } from '../ai-service';
|
|
15
15
|
import { AI_SERVICE_ENDPOINT } from '../testing';
|
|
16
16
|
|
|
17
17
|
// TODO(burdon): Local live LLM test.
|
|
18
18
|
describe.skip('Conversation tests', () => {
|
|
19
|
-
const client = new
|
|
19
|
+
const client = new AIServiceEdgeClient({
|
|
20
20
|
endpoint: AI_SERVICE_ENDPOINT.LOCAL,
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -147,10 +147,10 @@ export const runLLM = async (params: CreateLLMConversationParams) => {
|
|
|
147
147
|
};
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
-
export const isToolUse = (message: Message) => {
|
|
150
|
+
export const isToolUse = (message: Message, { onlyToolNames }: { onlyToolNames?: string[] } = {}) => {
|
|
151
151
|
const block = message.content.at(-1);
|
|
152
152
|
invariant(block);
|
|
153
|
-
return block.type === 'tool_use';
|
|
153
|
+
return block.type === 'tool_use' && (!onlyToolNames || onlyToolNames.includes(block.name));
|
|
154
154
|
};
|
|
155
155
|
|
|
156
156
|
export type RunToolsOptions = {
|
|
@@ -220,6 +220,7 @@ export const runTools = async ({ message, tools, extensions }: RunToolsOptions):
|
|
|
220
220
|
content:
|
|
221
221
|
typeof toolResult.result === 'string' ? toolResult.result : JSON.stringify(toolResult.result) ?? '',
|
|
222
222
|
},
|
|
223
|
+
...(toolResult.extractContentBlocks ?? []),
|
|
223
224
|
],
|
|
224
225
|
});
|
|
225
226
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Schema as S } from '
|
|
5
|
+
import { Schema as S } from 'effect';
|
|
6
6
|
|
|
7
7
|
import { type Message, type MessageContentBlock } from '@dxos/artifact';
|
|
8
8
|
import { toJsonSchema, JsonSchemaType, ObjectId } from '@dxos/echo-schema';
|
|
@@ -11,7 +11,7 @@ import { invariant } from '@dxos/invariant';
|
|
|
11
11
|
import { SpaceId } from '@dxos/keys';
|
|
12
12
|
import { log } from '@dxos/log';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { AIServiceEdgeClient, Tool } from '../ai-service';
|
|
15
15
|
import { defineTool, ToolResult } from '../conversation';
|
|
16
16
|
|
|
17
17
|
// TODO(dmaretskyi): Effect schema.
|
|
@@ -108,7 +108,7 @@ If there's
|
|
|
108
108
|
|
|
109
109
|
describe('Artifacts', () => {
|
|
110
110
|
test('shopping list', async () => {
|
|
111
|
-
const client = new
|
|
111
|
+
const client = new AIServiceEdgeClient({
|
|
112
112
|
endpoint: ENDPOINT,
|
|
113
113
|
});
|
|
114
114
|
|