@jxsuite/studio 0.32.0 → 0.33.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.
- package/dist/studio.js +9694 -306
- package/dist/studio.js.map +105 -9
- package/package.json +10 -5
- package/src/panels/ai-panel.ts +388 -328
- package/src/platforms/devserver.ts +3 -47
- package/src/services/ai-settings.ts +107 -0
- package/src/services/ai-system-prompt.ts +617 -0
- package/src/services/ai-tools.ts +854 -0
- package/src/services/context-manager.ts +200 -0
- package/src/services/document-assistant.ts +183 -0
- package/src/services/jx-validate.ts +65 -0
- package/src/services/render-critic.ts +75 -0
- package/src/services/token-lint.ts +140 -0
- package/src/services/tool-executor.ts +156 -0
- package/src/state.ts +29 -0
- package/src/tabs/transact.ts +37 -1
- package/src/types.ts +2 -6
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-executor.js — Agentic loop driver for the document AI assistant
|
|
3
|
+
*
|
|
4
|
+
* Streams a chat round, executes any tool calls the model makes (via a ToolRegistry backed by
|
|
5
|
+
* `transactDoc()`), feeds the results back as `tool` messages, and re-streams — up to a capped
|
|
6
|
+
* number of rounds (spec §10.2, ADR docs/ai-assistant-decision.md §6a).
|
|
7
|
+
*
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { createChatState } from "@jxsuite/ai/chat-state";
|
|
12
|
+
import type { StreamingClient } from "@jxsuite/ai/streaming-client";
|
|
13
|
+
import type { ToolRegistry } from "@jxsuite/ai/tools";
|
|
14
|
+
|
|
15
|
+
import type { Tab } from "../tabs/tab";
|
|
16
|
+
import { beginBatch, endBatch } from "../tabs/transact";
|
|
17
|
+
|
|
18
|
+
const MAX_ROUNDS = 5;
|
|
19
|
+
|
|
20
|
+
interface RunAgentLoopOptions {
|
|
21
|
+
chatState: ReturnType<typeof createChatState>;
|
|
22
|
+
streamingClient: StreamingClient;
|
|
23
|
+
toolRegistry: ToolRegistry;
|
|
24
|
+
systemPrompt: string;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
getTab?: () => Tab | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run one user turn through the agent loop: stream the model's response, execute any tool calls,
|
|
31
|
+
* and repeat until the model stops calling tools or the round cap is hit.
|
|
32
|
+
*/
|
|
33
|
+
export async function runAgentLoop({
|
|
34
|
+
chatState,
|
|
35
|
+
streamingClient,
|
|
36
|
+
toolRegistry,
|
|
37
|
+
systemPrompt,
|
|
38
|
+
signal,
|
|
39
|
+
getTab,
|
|
40
|
+
}: RunAgentLoopOptions): Promise<void> {
|
|
41
|
+
const allErrors: string[] = [];
|
|
42
|
+
const appliedSummaries: string[] = [];
|
|
43
|
+
|
|
44
|
+
// Batch all tool-call mutations into a single undo step
|
|
45
|
+
if (getTab) {
|
|
46
|
+
beginBatch(getTab());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
|
51
|
+
const messages = chatState.toMessagesArray();
|
|
52
|
+
const tools = toolRegistry.listForLLM();
|
|
53
|
+
|
|
54
|
+
const toolCalls = new Map<string, { name: string; arguments: string }>();
|
|
55
|
+
let stopReason = "stop";
|
|
56
|
+
let streamError = null;
|
|
57
|
+
|
|
58
|
+
for await (const event of streamingClient.streamChat(
|
|
59
|
+
messages,
|
|
60
|
+
tools,
|
|
61
|
+
systemPrompt,
|
|
62
|
+
signal as AbortSignal,
|
|
63
|
+
)) {
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case "delta": {
|
|
66
|
+
chatState.appendDelta(event.content);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "tool_call_start": {
|
|
70
|
+
chatState.appendToolCallStart(event.id, event.name);
|
|
71
|
+
toolCalls.set(event.id, { name: event.name, arguments: "" });
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "tool_call_delta": {
|
|
75
|
+
chatState.appendToolCallDelta(event.id, event.args);
|
|
76
|
+
const tc = toolCalls.get(event.id);
|
|
77
|
+
if (tc) {
|
|
78
|
+
tc.arguments += event.args;
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "tool_call_end": {
|
|
83
|
+
chatState.appendToolCallEnd(event.id);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "done": {
|
|
87
|
+
({ stopReason } = event);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "error": {
|
|
91
|
+
streamError = event.message;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
default: {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
chatState.finishStream(stopReason);
|
|
101
|
+
|
|
102
|
+
if (streamError) {
|
|
103
|
+
chatState.setError(streamError);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (stopReason !== "tool_calls" || toolCalls.size === 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const [id, call] of toolCalls) {
|
|
112
|
+
let result;
|
|
113
|
+
try {
|
|
114
|
+
const args = call.arguments ? (JSON.parse(call.arguments) as object) : {};
|
|
115
|
+
result = await toolRegistry.execute(call.name, args);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
result = {
|
|
118
|
+
success: false,
|
|
119
|
+
error: `Failed to parse arguments: ${(error as Error).message}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!result.success && result.error) {
|
|
123
|
+
allErrors.push(result.error);
|
|
124
|
+
}
|
|
125
|
+
if (result.success && result.summary) {
|
|
126
|
+
appliedSummaries.push(result.summary);
|
|
127
|
+
}
|
|
128
|
+
chatState.appendToolResult(id, result);
|
|
129
|
+
chatState.pushToolResultMessage(id, JSON.stringify(result));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (round < MAX_ROUNDS) {
|
|
133
|
+
chatState.beginAssistantTurn();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* Surface the actual errors so the user knows what went wrong, not just a generic
|
|
139
|
+
* "I couldn't do it" message.
|
|
140
|
+
*/
|
|
141
|
+
const uniqueErrors = [...new Set(allErrors)];
|
|
142
|
+
const applied =
|
|
143
|
+
appliedSummaries.length > 0
|
|
144
|
+
? `\n\nChanges applied so far:\n${appliedSummaries.map((s) => `- ${s}`).join("\n")}`
|
|
145
|
+
: "";
|
|
146
|
+
const errors =
|
|
147
|
+
uniqueErrors.length > 0
|
|
148
|
+
? `\n\nErrors encountered:\n${uniqueErrors.map((e) => `- ${e}`).join("\n")}`
|
|
149
|
+
: "";
|
|
150
|
+
chatState.setError(
|
|
151
|
+
`I ran out of tool-call rounds (${MAX_ROUNDS}) before finishing.${applied}${errors}\n\nYou can continue by sending another message, or try a more specific request.`,
|
|
152
|
+
);
|
|
153
|
+
} finally {
|
|
154
|
+
endBatch();
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -73,6 +73,35 @@ export function getNodeAtPath(doc: JxMutableNode, path: JxPath) {
|
|
|
73
73
|
return node;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Shallow-clone every node along `path` from root to target, leaving non-path subtrees as shared
|
|
78
|
+
* references. Returns the new root and the mutable target node at the end of the path.
|
|
79
|
+
*
|
|
80
|
+
* Used by mutation functions for structural-sharing history: the old root becomes an immutable
|
|
81
|
+
* snapshot (shared subtrees are never mutated), and the new root is the live document.
|
|
82
|
+
*/
|
|
83
|
+
export function cloneAlongPath(
|
|
84
|
+
doc: JxMutableNode,
|
|
85
|
+
path: JxPath,
|
|
86
|
+
): { root: JxMutableNode; target: JxMutableNode } {
|
|
87
|
+
const root = Array.isArray(doc) ? [...doc] : { ...doc };
|
|
88
|
+
let node: Record<string | number, unknown> = root as Record<string | number, unknown>;
|
|
89
|
+
|
|
90
|
+
for (const key of path) {
|
|
91
|
+
const child = node[key];
|
|
92
|
+
if (child == null) {
|
|
93
|
+
return { root: root as JxMutableNode, target: node as JxMutableNode };
|
|
94
|
+
}
|
|
95
|
+
const cloned = Array.isArray(child)
|
|
96
|
+
? [...(child as unknown[])]
|
|
97
|
+
: { ...(child as Record<string, unknown>) };
|
|
98
|
+
node[key] = cloned;
|
|
99
|
+
node = cloned as Record<string | number, unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { root: root as JxMutableNode, target: node as JxMutableNode };
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
/**
|
|
77
106
|
* The node's children when they are a static array (the edit-mode invariant); an empty array for
|
|
78
107
|
* mapped-array or absent children.
|
package/src/tabs/transact.ts
CHANGED
|
@@ -86,6 +86,12 @@ export type JxNodeValue =
|
|
|
86
86
|
* "Array"`) cannot be index-mutated — fail loudly instead of corrupting.
|
|
87
87
|
*/
|
|
88
88
|
function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
|
|
89
|
+
// Defense-in-depth: a path that resolves to a children array (rather than a node) would
|
|
90
|
+
// Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
|
|
91
|
+
// Nothing renders. Callers must pass a node; fail loudly if they don't.
|
|
92
|
+
if (Array.isArray(node)) {
|
|
93
|
+
throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
|
|
94
|
+
}
|
|
89
95
|
if (!node.children) {
|
|
90
96
|
node.children = [];
|
|
91
97
|
}
|
|
@@ -154,7 +160,7 @@ export function transactDoc(
|
|
|
154
160
|
}
|
|
155
161
|
}
|
|
156
162
|
|
|
157
|
-
if (!skipHistory) {
|
|
163
|
+
if (!skipHistory && !_batchTab) {
|
|
158
164
|
pushHistoryEntry(tab, raw, record, selectionBefore);
|
|
159
165
|
}
|
|
160
166
|
|
|
@@ -351,6 +357,36 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
|
|
|
351
357
|
}
|
|
352
358
|
}
|
|
353
359
|
|
|
360
|
+
// ─── Batch (group multiple mutations into one undo step) ────────────────────
|
|
361
|
+
|
|
362
|
+
let _batchTab: Tab | null = null;
|
|
363
|
+
|
|
364
|
+
export function beginBatch(tab: Tab | null) {
|
|
365
|
+
_batchTab = tab;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function endBatch() {
|
|
369
|
+
if (_batchTab) {
|
|
370
|
+
const raw = toRaw(_batchTab.doc.document);
|
|
371
|
+
const snapshot = {
|
|
372
|
+
document: jsonClone(raw),
|
|
373
|
+
selection: _batchTab.session.selection ? [..._batchTab.session.selection] : null,
|
|
374
|
+
};
|
|
375
|
+
const truncated = _batchTab.history.snapshots.slice(0, _batchTab.history.index + 1);
|
|
376
|
+
truncated.push(snapshot);
|
|
377
|
+
if (truncated.length > HISTORY_LIMIT) {
|
|
378
|
+
truncated.shift();
|
|
379
|
+
}
|
|
380
|
+
_batchTab.history.snapshots = truncated;
|
|
381
|
+
_batchTab.history.index = truncated.length - 1;
|
|
382
|
+
}
|
|
383
|
+
_batchTab = null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function isBatching(): boolean {
|
|
387
|
+
return _batchTab !== null;
|
|
388
|
+
}
|
|
389
|
+
|
|
354
390
|
// ─── Undo / Redo ─────────────────────────────────────────────────────────────
|
|
355
391
|
|
|
356
392
|
/** Restore a materialized snapshot state (full-render path). */
|
package/src/types.ts
CHANGED
|
@@ -214,12 +214,8 @@ export interface StudioPlatform {
|
|
|
214
214
|
adapter?: string;
|
|
215
215
|
directory: string;
|
|
216
216
|
}) => Promise<{ root: string; config: ProjectConfig }>;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
aiSendMessage: (id: string, message: string) => Promise<void>;
|
|
220
|
-
aiStreamUrl: (id: string) => string | Promise<string>;
|
|
221
|
-
aiStopSession: (id: string) => Promise<void>;
|
|
222
|
-
aiDeleteSession: (id: string) => Promise<void>;
|
|
217
|
+
/** Stack B AI assistant: URL of the OpenAI-compatible SSE chat proxy (`/__studio/ai/chat`). */
|
|
218
|
+
aiChatUrl: () => string | Promise<string>;
|
|
223
219
|
// ─── Multi-window (desktop only; undefined on dev-server) ───────────────────
|
|
224
220
|
/** Open a project in a new window, focusing an existing window if it is already open. */
|
|
225
221
|
openProjectInNewWindow?: (root: string) => Promise<void>;
|