@jxsuite/ai 0.1.0

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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Chat-state.js — Provider-agnostic reactive chat state management
3
+ *
4
+ * Manages the lifecycle of a chat conversation: messages, streaming status,
5
+ * tool calls, and errors. Built on @vue/reactivity for fine-grained updates.
6
+ * No Studio or Jx dependencies — reusable in any chat UI context.
7
+ *
8
+ * @license MIT
9
+ * @module @jxsuite/ai/chat-state
10
+ */
11
+
12
+ import { reactive } from "@vue/reactivity";
13
+
14
+ import type { ToolResult } from "./tools.ts";
15
+
16
+ export type ChatState = "idle" | "streaming" | "error";
17
+
18
+ export type MessageRole = "user" | "assistant" | "system" | "tool";
19
+
20
+ export interface Message {
21
+ id: string;
22
+ role: MessageRole;
23
+ content: string;
24
+ toolCalls?: ToolCallRecord[];
25
+ toolCallId?: string;
26
+ timestamp: number;
27
+ }
28
+
29
+ export interface ToolCallRecord {
30
+ id: string;
31
+ name: string;
32
+ arguments: string;
33
+ result?: ToolResult | null;
34
+ }
35
+
36
+ export interface ChatStore {
37
+ messages: Message[];
38
+ status: ChatState;
39
+ streamingContent: string;
40
+ pendingToolCalls: ToolCallRecord[];
41
+ error: string | null;
42
+ model: string;
43
+ tokenCount: number;
44
+ contextWarning: boolean;
45
+ }
46
+
47
+ // ─── Types ───────────────────────────────────────────────────────────────────
48
+
49
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
50
+
51
+ let _idCounter = 0;
52
+
53
+ /** @returns {string} */
54
+ function uid() {
55
+ _idCounter += 1;
56
+ return `msg_${Date.now()}_${_idCounter}`;
57
+ }
58
+
59
+ // ─── Factory ─────────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Create a reactive chat state store.
63
+ *
64
+ * @param {object} [opts]
65
+ * @param {string} [opts.model] - Default model name
66
+ * @returns {ChatStore & {
67
+ * sendMessage: (text: string) => void;
68
+ * beginAssistantTurn: () => void;
69
+ * appendDelta: (content: string) => void;
70
+ * appendToolCallStart: (id: string, name: string) => void;
71
+ * appendToolCallDelta: (id: string, args: string) => void;
72
+ * appendToolCallEnd: (id: string) => void;
73
+ * appendToolResult: (id: string, result: ToolResult) => void;
74
+ * pushToolResultMessage: (toolCallId: string, content: string) => void;
75
+ * finishStream: (stopReason: string) => void;
76
+ * setError: (message: string) => void;
77
+ * cancelStream: () => void;
78
+ * clearChat: () => void;
79
+ * retryLast: () => void;
80
+ * setModel: (model: string) => void;
81
+ * setTokenCount: (count: number) => void;
82
+ * setContextWarning: (warning: boolean) => void;
83
+ * toMessagesArray: () => object[];
84
+ * }}
85
+ */
86
+ export function createChatState(opts: { model?: string } = {}) {
87
+ const model = opts.model || "gpt-4o";
88
+
89
+ const store = reactive<ChatStore>({
90
+ messages: [],
91
+ status: "idle",
92
+ streamingContent: "",
93
+ pendingToolCalls: [],
94
+ error: null,
95
+ model,
96
+ tokenCount: 0,
97
+ contextWarning: false,
98
+ });
99
+
100
+ let _streamingMessage: Message | null = null;
101
+
102
+ /**
103
+ * Add a user message and prepare for streaming response.
104
+ *
105
+ * @param {string} text
106
+ */
107
+ function sendMessage(text: string) {
108
+ if (store.status === "streaming") {
109
+ return;
110
+ }
111
+
112
+ const userMsg = {
113
+ id: uid(),
114
+ role: "user",
115
+ content: text,
116
+ timestamp: Date.now(),
117
+ } as Message;
118
+ store.messages.push(userMsg);
119
+
120
+ beginAssistantTurn();
121
+ }
122
+
123
+ /**
124
+ * Prepare for a new streaming assistant response without adding a user message. Used by the agent
125
+ * loop to start the next round after appending tool result messages.
126
+ */
127
+ function beginAssistantTurn() {
128
+ if (store.status === "streaming") {
129
+ return;
130
+ }
131
+
132
+ store.error = null;
133
+ store.streamingContent = "";
134
+ store.pendingToolCalls = [];
135
+
136
+ // Set status BEFORE pushing the placeholder so reactive effects see "streaming" when the
137
+ // Push triggers them — otherwise the effect renders the empty placeholder as a finalized msg.
138
+ store.status = "streaming";
139
+
140
+ // Create placeholder assistant message for streaming content
141
+ _streamingMessage = {
142
+ id: uid(),
143
+ role: "assistant",
144
+ content: "",
145
+ timestamp: Date.now(),
146
+ } as Message;
147
+ store.messages.push(_streamingMessage);
148
+ /*
149
+ * Re-read through the reactive proxy so appendDelta / appendToolCallStart mutations notify
150
+ * effects (e.g. watchAssistant in ai-panel.ts). _streamingMessage now holds the proxied
151
+ * version, not the raw object pushed above.
152
+ */
153
+ _streamingMessage = store.messages.at(-1) ?? null;
154
+ }
155
+
156
+ /**
157
+ * Append a `tool` role message carrying a tool call's result, ready to be sent back to the LLM on
158
+ * the next round.
159
+ *
160
+ * @param {string} toolCallId
161
+ * @param {string} content
162
+ */
163
+ function pushToolResultMessage(toolCallId: string, content: string) {
164
+ store.messages.push({
165
+ id: uid(),
166
+ role: "tool",
167
+ toolCallId,
168
+ content,
169
+ timestamp: Date.now(),
170
+ } as Message);
171
+ }
172
+
173
+ /**
174
+ * Append a text delta to the streaming assistant message.
175
+ *
176
+ * @param {string} content
177
+ */
178
+ function appendDelta(content: string) {
179
+ if (store.status !== "streaming" || !_streamingMessage) {
180
+ return;
181
+ }
182
+ store.streamingContent += content;
183
+ _streamingMessage.content = store.streamingContent;
184
+ }
185
+
186
+ /**
187
+ * Start tracking a tool call within the stream.
188
+ *
189
+ * @param {string} id
190
+ * @param {string} name
191
+ */
192
+ function appendToolCallStart(id: string, name: string) {
193
+ if (store.status !== "streaming") {
194
+ return;
195
+ }
196
+ const tc = {
197
+ id,
198
+ name,
199
+ arguments: "",
200
+ result: null,
201
+ } as ToolCallRecord;
202
+ store.pendingToolCalls.push(tc);
203
+
204
+ if (_streamingMessage) {
205
+ if (!_streamingMessage.toolCalls) {
206
+ _streamingMessage.toolCalls = [];
207
+ }
208
+ _streamingMessage.toolCalls.push(tc);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Append a partial argument fragment to a pending tool call.
214
+ *
215
+ * @param {string} id
216
+ * @param {string} args
217
+ */
218
+ function appendToolCallDelta(id: string, args: string) {
219
+ const tc = store.pendingToolCalls.find((t) => t.id === id);
220
+ if (tc) {
221
+ tc.arguments += args;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Mark a tool call as complete (all arguments received).
227
+ *
228
+ * @param {string} _id
229
+ */
230
+ function appendToolCallEnd(_id: string) {
231
+ // Tool call is complete — arguments are fully accumulated.
232
+ // The caller should parse the arguments JSON and execute the tool.
233
+ // Tool results are attached via appendToolResult().
234
+ }
235
+
236
+ /**
237
+ * Attach a result to a pending tool call.
238
+ *
239
+ * @param {string} id
240
+ * @param {ToolResult} result
241
+ */
242
+ function appendToolResult(id: string, result: ToolResult) {
243
+ const tc = store.pendingToolCalls.find((t) => t.id === id);
244
+ if (tc) {
245
+ tc.result = result;
246
+ }
247
+ // Also update in the message record
248
+ if (_streamingMessage?.toolCalls) {
249
+ const mtc = _streamingMessage.toolCalls.find((t) => t.id === id);
250
+ if (mtc) {
251
+ mtc.result = result;
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Finalize the streaming response.
258
+ *
259
+ * @param {string} _stopReason
260
+ */
261
+ function finishStream(_stopReason: string) {
262
+ store.status = "idle";
263
+ store.streamingContent = "";
264
+ store.pendingToolCalls = [];
265
+ _streamingMessage = null;
266
+ }
267
+
268
+ /**
269
+ * Set the chat state to error with a message.
270
+ *
271
+ * @param {string} message
272
+ */
273
+ function setError(message: string) {
274
+ store.status = "error";
275
+ store.error = message;
276
+ store.streamingContent = "";
277
+ // Remove the partial streaming message — it may contain incomplete tool_calls
278
+ // That would poison the conversation history on the next send.
279
+ if (_streamingMessage) {
280
+ const idx = store.messages.lastIndexOf(_streamingMessage);
281
+ if (idx !== -1) {
282
+ store.messages.splice(idx, 1);
283
+ }
284
+ }
285
+ _streamingMessage = null;
286
+ }
287
+
288
+ /** Cancel the current stream. Resets streaming state. */
289
+ function cancelStream() {
290
+ // Always remove the partial streaming message — it may contain
291
+ // Incomplete tool_calls that would poison the conversation history on retry.
292
+ if (_streamingMessage) {
293
+ const idx = store.messages.lastIndexOf(_streamingMessage);
294
+ if (idx !== -1) {
295
+ store.messages.splice(idx, 1);
296
+ }
297
+ }
298
+ store.status = "idle";
299
+ store.streamingContent = "";
300
+ store.pendingToolCalls = [];
301
+ _streamingMessage = null;
302
+ store.error = null;
303
+ }
304
+
305
+ /** Clear the entire chat history. */
306
+ function clearChat() {
307
+ store.messages.length = 0;
308
+ store.status = "idle";
309
+ store.streamingContent = "";
310
+ store.pendingToolCalls = [];
311
+ store.error = null;
312
+ store.contextWarning = false;
313
+ _streamingMessage = null;
314
+ }
315
+
316
+ /** Retry the last user message (remove last assistant + user, then the caller re-sends). */
317
+ function retryLast() {
318
+ // Remove the last assistant message
319
+ while (store.messages.length > 0 && store.messages.at(-1)!.role !== "user") {
320
+ store.messages.pop();
321
+ }
322
+ // Remove the last user message (caller will re-send)
323
+ if (store.messages.length > 0 && store.messages.at(-1)!.role === "user") {
324
+ store.messages.pop();
325
+ }
326
+ store.status = "idle";
327
+ store.error = null;
328
+ }
329
+
330
+ /**
331
+ * Set the model name.
332
+ *
333
+ * @param {string} m
334
+ */
335
+ function setModel(m: string) {
336
+ store.model = m;
337
+ }
338
+
339
+ /**
340
+ * Set the approximate token count for context management.
341
+ *
342
+ * @param {number} count
343
+ */
344
+ function setTokenCount(count: number) {
345
+ store.tokenCount = count;
346
+ }
347
+
348
+ /**
349
+ * Set the context overflow warning flag.
350
+ *
351
+ * @param {boolean} warning
352
+ */
353
+ function setContextWarning(warning: boolean) {
354
+ store.contextWarning = warning;
355
+ }
356
+
357
+ /**
358
+ * Convert the current chat state to an array suitable for sending to an LLM API. Includes the
359
+ * system prompt as the first message and all conversation turns.
360
+ *
361
+ * @returns {object[]}
362
+ */
363
+ function toMessagesArray() {
364
+ const out = [];
365
+
366
+ for (const msg of store.messages) {
367
+ if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) {
368
+ out.push({
369
+ role: "assistant",
370
+ content: msg.content || null,
371
+ tool_calls: msg.toolCalls.map((tc) => ({
372
+ id: tc.id,
373
+ type: "function",
374
+ function: {
375
+ name: tc.name,
376
+ arguments: tc.arguments,
377
+ },
378
+ })),
379
+ });
380
+ } else if (msg.role === "tool") {
381
+ out.push({
382
+ role: "tool",
383
+ tool_call_id: msg.toolCallId,
384
+ content: msg.content,
385
+ });
386
+ } else {
387
+ out.push({
388
+ role: msg.role,
389
+ content: msg.content,
390
+ });
391
+ }
392
+ }
393
+
394
+ return out;
395
+ }
396
+
397
+ return Object.assign(store, {
398
+ sendMessage,
399
+ beginAssistantTurn,
400
+ appendDelta,
401
+ appendToolCallStart,
402
+ appendToolCallDelta,
403
+ appendToolCallEnd,
404
+ appendToolResult,
405
+ pushToolResultMessage,
406
+ finishStream,
407
+ setError,
408
+ cancelStream,
409
+ clearChat,
410
+ retryLast,
411
+ setModel,
412
+ setTokenCount,
413
+ setContextWarning,
414
+ toMessagesArray,
415
+ });
416
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Index.js — AI infrastructure for Jx Suite (`@jxsuite/ai`)
3
+ *
4
+ * Re-exports the core modules: streaming client abstraction, tool registry, and
5
+ * reactive chat state management. Provider-agnostic; no Studio or Jx dependencies.
6
+ *
7
+ * @license MIT
8
+ */
9
+
10
+ // Re-export from sub-modules so consumers can `import { ... } from "@jxsuite/ai"`.
11
+ // Importing individual files instead enables tree-shaking.
12
+ export { createToolDefinition, createToolRegistry } from "./tools.js";
13
+
14
+ export { createChatState } from "./chat-state.js";
15
+
16
+ export {
17
+ STREAM_EVENT_TYPES,
18
+ createOpenAIStreamingClient,
19
+ createAnthropicStreamingClient,
20
+ createProxyStreamingClient,
21
+ } from "./streaming-client.js";