@jxsuite/studio 0.31.1 → 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 +10033 -450
- package/dist/studio.js.map +113 -17
- package/package.json +10 -5
- package/src/files/files.ts +6 -1
- package/src/panels/ai-panel.ts +388 -328
- package/src/panels/quick-search.ts +116 -31
- package/src/panels/toolbar.ts +101 -58
- package/src/panels/welcome-screen.ts +34 -10
- package/src/platforms/devserver.ts +3 -47
- package/src/recent-projects.ts +119 -21
- 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/studio.ts +15 -2
- package/src/tabs/transact.ts +37 -1
- package/src/types.ts +18 -6
- package/src/ui/media-picker.ts +5 -1
- package/src/utils/studio-utils.ts +5 -2
|
@@ -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/studio.ts
CHANGED
|
@@ -106,7 +106,7 @@ import { initCssData } from "./panels/style-utils";
|
|
|
106
106
|
import { updateForcedPseudoPreview } from "./panels/pseudo-preview";
|
|
107
107
|
import { initPanelEvents } from "./panels/panel-events";
|
|
108
108
|
import { initQuickSearch } from "./panels/quick-search";
|
|
109
|
-
import { addRecentProject } from "./recent-projects";
|
|
109
|
+
import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
|
|
110
110
|
import { initWelcome } from "./panels/welcome-screen";
|
|
111
111
|
import { openNewProjectModal } from "./new-project/new-project-modal";
|
|
112
112
|
import type { DocumentStackEntry, GitDiffState } from "./types";
|
|
@@ -338,7 +338,7 @@ toolbarPanel.mount(toolbarEl, {
|
|
|
338
338
|
});
|
|
339
339
|
|
|
340
340
|
initLayers();
|
|
341
|
-
initQuickSearch();
|
|
341
|
+
initQuickSearch({ openRecentProject: (root: string) => openRecentProject(root) });
|
|
342
342
|
|
|
343
343
|
tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
|
|
344
344
|
|
|
@@ -662,6 +662,14 @@ if (_projectParam) {
|
|
|
662
662
|
ensureFsSync();
|
|
663
663
|
}
|
|
664
664
|
|
|
665
|
+
// Hydrate the recent-projects list from the backend store (desktop/chromium), then refresh the
|
|
666
|
+
// Toolbar dropdown + welcome screen, both of which read it synchronously.
|
|
667
|
+
// oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
|
|
668
|
+
void hydrateRecentProjects().then(() => {
|
|
669
|
+
toolbarPanel.render();
|
|
670
|
+
render();
|
|
671
|
+
});
|
|
672
|
+
|
|
665
673
|
// ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
|
|
666
674
|
|
|
667
675
|
function renderLeftPanel() {
|
|
@@ -748,6 +756,11 @@ async function openRecentProject(root: string) {
|
|
|
748
756
|
ensureFsSync();
|
|
749
757
|
void maybePromptJxsuiteUpdate(root);
|
|
750
758
|
} catch (error) {
|
|
759
|
+
// The project likely moved or was deleted — drop the stale entry so it stops cluttering the
|
|
760
|
+
// List, and refresh the dropdown + welcome screen.
|
|
761
|
+
removeRecentProject(root);
|
|
762
|
+
toolbarPanel.render();
|
|
763
|
+
render();
|
|
751
764
|
statusMessage(`Error: ${errorMessage(error)}`);
|
|
752
765
|
}
|
|
753
766
|
}
|
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>;
|
|
@@ -232,6 +228,22 @@ export interface StudioPlatform {
|
|
|
232
228
|
setWindowProject?: (root: string) => Promise<{ deduped: boolean; config: ProjectConfig | null }>;
|
|
233
229
|
/** The project root this window's backend is currently bound to. */
|
|
234
230
|
getProjectRoot?: () => Promise<{ root: string | null }>;
|
|
231
|
+
// ─── Recent projects (backend-persisted; undefined on dev-server) ───────────
|
|
232
|
+
/**
|
|
233
|
+
* Read the user-level recent-projects list from a backend store shared across all
|
|
234
|
+
* projects/windows. Platforms without a native backend (dev server) omit it and the studio falls
|
|
235
|
+
* back to localStorage.
|
|
236
|
+
*/
|
|
237
|
+
getRecentProjects?: () => Promise<RecentProjectEntry[]>;
|
|
238
|
+
/** Persist the full recent-projects list to the backend store. */
|
|
239
|
+
saveRecentProjects?: (projects: RecentProjectEntry[]) => Promise<void>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A recently-opened project, keyed by its re-openable `root` (platform-specific). */
|
|
243
|
+
export interface RecentProjectEntry {
|
|
244
|
+
name: string;
|
|
245
|
+
root: string;
|
|
246
|
+
timestamp: number;
|
|
235
247
|
}
|
|
236
248
|
|
|
237
249
|
// ─── Studio Types ───────────────────────────────────────────────────────────
|
package/src/ui/media-picker.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { html, render as litRender, nothing } from "lit-html";
|
|
|
10
10
|
import { live } from "lit-html/directives/live.js";
|
|
11
11
|
import { ref } from "lit-html/directives/ref.js";
|
|
12
12
|
import { getPlatform } from "../platform";
|
|
13
|
-
import { debouncedStyleCommit } from "../store";
|
|
13
|
+
import { debouncedStyleCommit, renderOnly } from "../store";
|
|
14
14
|
import { getLayerSlot } from "./layers";
|
|
15
15
|
|
|
16
16
|
// ─── Media file cache ────────────────────────────────────────────────────────
|
|
@@ -88,6 +88,10 @@ async function loadMediaCache() {
|
|
|
88
88
|
m.path = m.path.replace(/^\/public\//, "/");
|
|
89
89
|
}
|
|
90
90
|
mediaCacheLoaded = true;
|
|
91
|
+
// Re-render the host panels so the Browse button (gated on mediaCache.length) appears once the
|
|
92
|
+
// Async listing resolves — including when an image value is already set, so the current image can
|
|
93
|
+
// Be replaced. Mirrors loadLayoutEntries()'s renderOnly() in head-panel.
|
|
94
|
+
renderOnly("leftPanel", "rightPanel");
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
/** Force media cache reload (e.g. after upload). */
|
|
@@ -168,6 +168,9 @@ export function findContentTypeSchema(
|
|
|
168
168
|
if (!documentPath || !projectConfig?.contentTypes) {
|
|
169
169
|
return null;
|
|
170
170
|
}
|
|
171
|
+
// Content-type `source` prefixes are always forward-slash. The desktop platform can hand us
|
|
172
|
+
// OS-native backslash paths on Windows, so normalize before prefix matching.
|
|
173
|
+
const docPath = documentPath.replaceAll("\\", "/");
|
|
171
174
|
for (const [name, def] of Object.entries(
|
|
172
175
|
projectConfig.contentTypes as Record<string, ContentTypeDef>,
|
|
173
176
|
)) {
|
|
@@ -177,7 +180,7 @@ export function findContentTypeSchema(
|
|
|
177
180
|
const src = def.source.replace(/^\.\//, "").replace(/\/$/, "");
|
|
178
181
|
const hasExt = src.includes(".") && !src.endsWith("/");
|
|
179
182
|
if (hasExt) {
|
|
180
|
-
if (
|
|
183
|
+
if (docPath === src || docPath.endsWith(`/${src}`)) {
|
|
181
184
|
return { name, schema: def.schema };
|
|
182
185
|
}
|
|
183
186
|
} else {
|
|
@@ -187,7 +190,7 @@ export function findContentTypeSchema(
|
|
|
187
190
|
: (formatByName(def.format)?.extensions[0] ??
|
|
188
191
|
defaultContentFormat()?.extensions[0] ??
|
|
189
192
|
".json");
|
|
190
|
-
if (
|
|
193
|
+
if (docPath.startsWith(`${src}/`) && docPath.endsWith(ext)) {
|
|
191
194
|
return { name, schema: def.schema };
|
|
192
195
|
}
|
|
193
196
|
}
|