@jxsuite/studio 0.32.0 → 0.34.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/iframe-entry.js +6230 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +22791 -13709
- package/dist/studio.js.map +181 -74
- package/package.json +12 -7
- package/src/browse/browse.ts +11 -4
- package/src/canvas/canvas-helpers.ts +2 -56
- package/src/canvas/canvas-live-render.ts +102 -435
- package/src/canvas/canvas-origin.ts +66 -0
- package/src/canvas/canvas-patcher.ts +63 -403
- package/src/canvas/canvas-render.ts +70 -212
- package/src/canvas/canvas-utils.ts +37 -65
- package/src/canvas/iframe-channel.ts +154 -0
- package/src/canvas/iframe-drop.ts +484 -0
- package/src/canvas/iframe-entry.ts +600 -0
- package/src/canvas/iframe-host.ts +1373 -0
- package/src/canvas/iframe-inline-edit.ts +367 -0
- package/src/canvas/iframe-insert.ts +164 -0
- package/src/canvas/iframe-interaction.ts +176 -0
- package/src/canvas/iframe-keys.ts +85 -0
- package/src/canvas/iframe-overlay.ts +218 -0
- package/src/canvas/iframe-patch.ts +363 -0
- package/src/canvas/iframe-protocol.ts +361 -0
- package/src/canvas/iframe-render.ts +458 -0
- package/src/canvas/iframe-slash.ts +114 -0
- package/src/canvas/iframe-subtree.ts +113 -0
- package/src/canvas/path-mapping.ts +86 -0
- package/src/canvas/serialize-scope.ts +65 -0
- package/src/editor/canvas-context-menu.ts +40 -0
- package/src/editor/canvas-slash-bridge.ts +21 -0
- package/src/editor/context-menu.ts +2 -1
- package/src/editor/inline-edit-apply.ts +183 -0
- package/src/editor/inline-edit.ts +99 -21
- package/src/editor/inline-link.ts +89 -0
- package/src/editor/insert-zone-action.ts +35 -0
- package/src/editor/merge-tags.ts +26 -2
- package/src/editor/repeater-scope.ts +144 -0
- package/src/editor/shortcuts.ts +14 -28
- package/src/editor/slash-menu.ts +73 -42
- package/src/files/files.ts +2 -1
- package/src/page-params.ts +383 -0
- package/src/panels/ai-panel.ts +386 -328
- package/src/panels/block-action-bar.ts +296 -138
- package/src/panels/canvas-dnd-bridge.ts +397 -0
- package/src/panels/component-preview.ts +56 -0
- package/src/panels/dnd.ts +41 -17
- package/src/panels/drag-ghost.ts +62 -0
- package/src/panels/editors.ts +1 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +0 -2
- package/src/panels/signals-panel.ts +136 -22
- package/src/panels/stylebook-doc.ts +373 -0
- package/src/panels/stylebook-panel.ts +46 -689
- package/src/panels/tab-bar.ts +159 -13
- package/src/panels/toolbar.ts +3 -2
- package/src/platforms/devserver.ts +18 -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/monaco-setup.ts +12 -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/settings/css-vars-editor.ts +2 -2
- package/src/state.ts +29 -0
- package/src/store.ts +4 -62
- package/src/studio.ts +90 -40
- package/src/tabs/doc-op-apply.ts +89 -0
- package/src/tabs/patch-ops.ts +6 -2
- package/src/tabs/tab.ts +23 -4
- package/src/tabs/transact.ts +33 -69
- package/src/types.ts +16 -24
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +5 -0
- package/src/utils/canvas-media.ts +0 -137
- package/src/utils/edit-display.ts +23 -3
- package/src/utils/geometry.ts +43 -0
- package/src/utils/link-target.ts +93 -0
- package/src/utils/strip-events.ts +54 -0
- package/src/view.ts +0 -23
- package/src/workspace/workspace.ts +14 -1
- package/src/canvas/canvas-subtree-render.ts +0 -113
- package/src/editor/component-inline-edit.ts +0 -349
- package/src/editor/content-inline-edit.ts +0 -207
- package/src/editor/insertion-helper.ts +0 -308
- package/src/panels/canvas-dnd.ts +0 -329
- package/src/panels/panel-events.ts +0 -306
- package/src/panels/preview-render.ts +0 -132
- package/src/panels/pseudo-preview.ts +0 -75
package/src/panels/ai-panel.ts
CHANGED
|
@@ -1,36 +1,32 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
/**
|
|
3
|
-
* Ai-panel.
|
|
3
|
+
* Ai-panel.ts — AI assistant tab for the right panel (Stack B document assistant).
|
|
4
4
|
*
|
|
5
|
-
* Uses QuikChat for the chat UI
|
|
5
|
+
* Uses QuikChat for the chat UI, driven by the reactive document-assistant session which streams
|
|
6
|
+
* from the OpenAI-compatible proxy and edits the live document via tools (with undo support).
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { html, nothing } from "lit-html";
|
|
9
10
|
import { ref } from "lit-html/directives/ref.js";
|
|
10
11
|
import quikchat from "quikchat/md";
|
|
11
12
|
import { getPlatform } from "../platform";
|
|
12
|
-
import {
|
|
13
|
+
import { effect, effectScope } from "../reactivity";
|
|
14
|
+
import { createDocumentAssistant } from "../services/document-assistant";
|
|
15
|
+
import {
|
|
16
|
+
getBaseUrl,
|
|
17
|
+
getModel,
|
|
18
|
+
getOpenAiKey,
|
|
19
|
+
hasOpenAiKey,
|
|
20
|
+
setBaseUrl,
|
|
21
|
+
setModel,
|
|
22
|
+
setOpenAiKey,
|
|
23
|
+
} from "../services/ai-settings";
|
|
24
|
+
|
|
25
|
+
import type { ChatState } from "@jxsuite/ai/chat-state";
|
|
26
|
+
import type { EffectScope } from "@vue/reactivity";
|
|
13
27
|
|
|
14
28
|
// ─── State (module-level, persists across tab switches) ─────────────────────
|
|
15
29
|
|
|
16
|
-
/**
|
|
17
|
-
* @type {{
|
|
18
|
-
* role: string;
|
|
19
|
-
* content: string;
|
|
20
|
-
* toolUse?: { tool: string; input?: Record<string, unknown> };
|
|
21
|
-
* }[]}
|
|
22
|
-
*/
|
|
23
|
-
let messages: {
|
|
24
|
-
role: string;
|
|
25
|
-
content: string;
|
|
26
|
-
toolUse?: { tool: string; input?: Record<string, unknown> };
|
|
27
|
-
}[] = [];
|
|
28
|
-
let streaming = false;
|
|
29
|
-
let sessionId = null as string | null;
|
|
30
|
-
let authStatus = "unknown" as "authenticated" | "unauthenticated" | "checking" | "unknown";
|
|
31
|
-
let authError = "";
|
|
32
|
-
let currentAssistantText = "";
|
|
33
|
-
let eventSource = null as EventSource | null;
|
|
34
30
|
let mounted = false;
|
|
35
31
|
|
|
36
32
|
/** Minimal surface of the untyped quikchat library that this panel uses. */
|
|
@@ -54,9 +50,25 @@ const QuikChat = quikchat as unknown as QuikChatCtor;
|
|
|
54
50
|
let chatInstance: QuikChatInstance | null = null;
|
|
55
51
|
let chatContainerEl: Element | null = null;
|
|
56
52
|
let _quikChatEl: HTMLElement | null = null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
|
|
54
|
+
/** Whether the OpenAI key form is showing (gate when no key, or re-edit via the toolbar). */
|
|
55
|
+
let keyEditing = false;
|
|
56
|
+
let keyDraft = "";
|
|
57
|
+
let baseUrlDraft = "";
|
|
58
|
+
let modelDraft = "";
|
|
59
|
+
|
|
60
|
+
/** Fetched from /__studio/ai/models (proxied to the upstream provider). */
|
|
61
|
+
let availableModels: { id: string; name: string }[] = [];
|
|
62
|
+
let modelsLoading = false;
|
|
63
|
+
let modelsError = "";
|
|
64
|
+
|
|
65
|
+
/** Document AST assistant session — created lazily, persists across tab switches. */
|
|
66
|
+
const assistant = createDocumentAssistant();
|
|
67
|
+
(globalThis as Record<string, unknown>).assistant = assistant;
|
|
68
|
+
let assistantScope: EffectScope | null = null;
|
|
69
|
+
let assistantRenderedCount = 0;
|
|
70
|
+
let assistantStreamingMsgId = null as number | null;
|
|
71
|
+
let assistantStreamedLen = 0;
|
|
60
72
|
|
|
61
73
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
62
74
|
|
|
@@ -65,7 +77,6 @@ export function mountAiPanel() {
|
|
|
65
77
|
return;
|
|
66
78
|
}
|
|
67
79
|
mounted = true;
|
|
68
|
-
void checkAuth();
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
const _g = globalThis as unknown as {
|
|
@@ -98,7 +109,7 @@ export function mountQuikChat() {
|
|
|
98
109
|
chatInstance = new QuikChat(
|
|
99
110
|
container,
|
|
100
111
|
(_chat: unknown, msg: string) => {
|
|
101
|
-
void
|
|
112
|
+
void handleAssistantSend(msg);
|
|
102
113
|
},
|
|
103
114
|
{
|
|
104
115
|
messagesArea: { alternating: false },
|
|
@@ -109,379 +120,426 @@ export function mountQuikChat() {
|
|
|
109
120
|
);
|
|
110
121
|
chatContainerEl = container;
|
|
111
122
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
assistantRenderedCount = 0;
|
|
124
|
+
assistantStreamingMsgId = null;
|
|
125
|
+
assistantStreamedLen = 0;
|
|
126
|
+
replayAssistantMessages();
|
|
127
|
+
watchAssistant();
|
|
128
|
+
if (assistant.chatState.status === "streaming") {
|
|
115
129
|
chatInstance?.inputAreaSetEnabled(false);
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
|
|
120
|
-
if (!chatInstance || messages.length === 0) {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
for (const msg of messages) {
|
|
124
|
-
if (msg.role === "user") {
|
|
125
|
-
chatInstance.messageAddNew(msg.content, "You", "right", "user");
|
|
126
|
-
} else if (msg.role === "tool" && msg.toolUse) {
|
|
127
|
-
chatInstance.messageAddNew(
|
|
128
|
-
formatToolLabel(msg.toolUse.tool, msg.toolUse.input),
|
|
129
|
-
"",
|
|
130
|
-
"left",
|
|
131
|
-
"tool",
|
|
132
|
-
);
|
|
133
|
-
} else {
|
|
134
|
-
chatInstance.messageAddNew(msg.content, "", "left", "assistant");
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
if (streaming && currentAssistantText) {
|
|
138
|
-
currentStreamMsgId = chatInstance.messageAddNew(currentAssistantText, "", "left", "assistant");
|
|
139
|
-
streamStarted = true;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ─── Auth ───────────────────────────────────────────────────────────────────
|
|
133
|
+
// ─── OpenAI key settings ─────────────────────────────────────────────────────
|
|
144
134
|
|
|
145
|
-
|
|
146
|
-
|
|
135
|
+
/** Open the key form, pre-filled with the current settings. */
|
|
136
|
+
function startEditApiKey() {
|
|
137
|
+
keyDraft = getOpenAiKey();
|
|
138
|
+
baseUrlDraft = getBaseUrl();
|
|
139
|
+
modelDraft = getModel();
|
|
140
|
+
keyEditing = true;
|
|
147
141
|
rerenderPanel();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
authStatus = result.authenticated ? "authenticated" : "unauthenticated";
|
|
152
|
-
authError = result.error || "";
|
|
153
|
-
} catch (error) {
|
|
154
|
-
authStatus = "unauthenticated";
|
|
155
|
-
authError = String(error);
|
|
142
|
+
// Auto-fetch available models if not already loaded.
|
|
143
|
+
if (availableModels.length === 0 && !modelsLoading) {
|
|
144
|
+
void fetchModels();
|
|
156
145
|
}
|
|
157
|
-
rerenderPanel();
|
|
158
146
|
}
|
|
159
147
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
streamStarted = false;
|
|
172
|
-
|
|
173
|
-
if (chatInstance) {
|
|
174
|
-
currentStreamMsgId = chatInstance.messageAddTypingIndicator("");
|
|
175
|
-
chatInstance.inputAreaSetEnabled(false);
|
|
176
|
-
}
|
|
177
|
-
|
|
148
|
+
/** Persist the drafted key + endpoint and return to the chat. */
|
|
149
|
+
function saveApiKey() {
|
|
150
|
+
setOpenAiKey(keyDraft);
|
|
151
|
+
setBaseUrl(baseUrlDraft);
|
|
152
|
+
setModel(modelDraft);
|
|
153
|
+
keyDraft = "";
|
|
154
|
+
baseUrlDraft = "";
|
|
155
|
+
modelDraft = "";
|
|
156
|
+
keyEditing = false;
|
|
157
|
+
// Clear fetched models so they're re-fetched with the new credentials next time.
|
|
158
|
+
availableModels = [];
|
|
178
159
|
rerenderPanel();
|
|
160
|
+
}
|
|
179
161
|
|
|
180
|
-
|
|
162
|
+
/** Dismiss the key form without saving (only offered when a key already exists). */
|
|
163
|
+
function cancelEditApiKey() {
|
|
164
|
+
keyDraft = "";
|
|
165
|
+
baseUrlDraft = "";
|
|
166
|
+
modelDraft = "";
|
|
167
|
+
keyEditing = false;
|
|
168
|
+
rerenderPanel();
|
|
169
|
+
}
|
|
181
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Fetch available models from the proxy's /__studio/ai/models endpoint. Sends the stored API key as
|
|
173
|
+
* X-Api-Key so the proxy can forward to the upstream provider. Falls back to the proxy's hardcoded
|
|
174
|
+
* default list when no key is configured.
|
|
175
|
+
*/
|
|
176
|
+
async function fetchModels() {
|
|
177
|
+
modelsLoading = true;
|
|
178
|
+
modelsError = "";
|
|
179
|
+
rerenderPanel();
|
|
182
180
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
181
|
+
const plat = getPlatform();
|
|
182
|
+
const chatUrl = await Promise.resolve(plat.aiChatUrl());
|
|
183
|
+
const modelsUrl = chatUrl.replace(/\/chat$/, "/models");
|
|
184
|
+
|
|
185
|
+
const headers: Record<string, string> = {};
|
|
186
|
+
const storedKey = getOpenAiKey() || keyDraft;
|
|
187
|
+
if (storedKey) {
|
|
188
|
+
headers["X-Api-Key"] = storedKey;
|
|
189
189
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
// Forward the chosen endpoint so the proxy lists models from THAT provider, not the
|
|
191
|
+
// Default OpenAI host — otherwise a non-OpenAI key only ever gets the hardcoded fallback.
|
|
192
|
+
const baseUrl = baseUrlDraft || getBaseUrl();
|
|
193
|
+
if (baseUrl) {
|
|
194
|
+
headers["X-Api-Base-URL"] = baseUrl;
|
|
193
195
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (
|
|
197
|
-
|
|
196
|
+
|
|
197
|
+
const resp = await fetch(modelsUrl, { headers });
|
|
198
|
+
if (!resp.ok) {
|
|
199
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
198
200
|
}
|
|
201
|
+
const data = (await resp.json()) as { models?: { id: string; name?: string }[] };
|
|
202
|
+
availableModels = (data.models || []).map((m: { id: string; name?: string }) => ({
|
|
203
|
+
id: m.id,
|
|
204
|
+
name: m.name || m.id,
|
|
205
|
+
}));
|
|
206
|
+
} catch (error: unknown) {
|
|
207
|
+
modelsError = (error as Error).message || "Failed to fetch models";
|
|
208
|
+
} finally {
|
|
209
|
+
modelsLoading = false;
|
|
199
210
|
rerenderPanel();
|
|
200
211
|
}
|
|
201
212
|
}
|
|
202
213
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
214
|
+
/** The OpenAI (or compatible) key + endpoint settings form, shown as a gate when no key is set. */
|
|
215
|
+
function renderKeyGate() {
|
|
216
|
+
const haveKey = hasOpenAiKey();
|
|
217
|
+
return html`
|
|
218
|
+
<div class="ai-tab-body">
|
|
219
|
+
<div class="ai-status-center" style="gap:10px;max-width:320px;text-align:left">
|
|
220
|
+
<div style="font-weight:600;align-self:center">AI provider key</div>
|
|
221
|
+
<div style="font-size:11px;color:var(--fg-dim)">
|
|
222
|
+
Any OpenAI-compatible key works. Stored locally in this browser; sent only to the Studio
|
|
223
|
+
proxy (never to a third party except your chosen endpoint).
|
|
224
|
+
</div>
|
|
225
|
+
<input
|
|
226
|
+
type="password"
|
|
227
|
+
style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
|
|
228
|
+
placeholder="sk-… or any compatible key"
|
|
229
|
+
.value=${keyDraft}
|
|
230
|
+
@input=${(e: Event) => {
|
|
231
|
+
keyDraft = (e.target as HTMLInputElement).value;
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
234
|
+
<div style="font-weight:500;font-size:11px;margin-top:4px">Model</div>
|
|
235
|
+
${availableModels.length > 0
|
|
236
|
+
? html`
|
|
237
|
+
<sp-combobox
|
|
238
|
+
size="s"
|
|
239
|
+
allows-custom-value
|
|
240
|
+
.value=${modelDraft}
|
|
241
|
+
@change=${(e: Event) => {
|
|
242
|
+
modelDraft = (e.target as HTMLInputElement).value;
|
|
243
|
+
}}
|
|
244
|
+
@input=${(e: Event) => {
|
|
245
|
+
modelDraft = (e.target as HTMLInputElement).value;
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
${availableModels.map(
|
|
249
|
+
(m) => html`<sp-menu-item value=${m.id}>${m.name}</sp-menu-item>`,
|
|
250
|
+
)}
|
|
251
|
+
</sp-combobox>
|
|
252
|
+
`
|
|
253
|
+
: html`
|
|
254
|
+
<input
|
|
255
|
+
type="text"
|
|
256
|
+
style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
|
|
257
|
+
placeholder="Model ID (e.g. gpt-4o, claude-sonnet-4-20250514, etc.)"
|
|
258
|
+
.value=${modelDraft}
|
|
259
|
+
@input=${(e: Event) => {
|
|
260
|
+
modelDraft = (e.target as HTMLInputElement).value;
|
|
261
|
+
}}
|
|
262
|
+
/>
|
|
263
|
+
`}
|
|
264
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
265
|
+
<sp-button size="s" variant="secondary" ?disabled=${modelsLoading} @click=${fetchModels}>
|
|
266
|
+
${modelsLoading
|
|
267
|
+
? "Fetching…"
|
|
268
|
+
: availableModels.length > 0
|
|
269
|
+
? "Refresh models"
|
|
270
|
+
: "Fetch models"}
|
|
271
|
+
</sp-button>
|
|
272
|
+
${modelsError
|
|
273
|
+
? html`<span style="font-size:10px;color:var(--danger)">${modelsError}</span>`
|
|
274
|
+
: nothing}
|
|
275
|
+
</div>
|
|
276
|
+
<input
|
|
277
|
+
type="text"
|
|
278
|
+
style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
|
|
279
|
+
placeholder="Endpoint (optional, e.g. http://localhost:11434/v1)"
|
|
280
|
+
.value=${baseUrlDraft}
|
|
281
|
+
@input=${(e: Event) => {
|
|
282
|
+
baseUrlDraft = (e.target as HTMLInputElement).value;
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
<div style="display:flex;gap:8px;align-self:flex-end">
|
|
286
|
+
${haveKey
|
|
287
|
+
? html`<sp-button size="s" variant="secondary" @click=${cancelEditApiKey}
|
|
288
|
+
>Cancel</sp-button
|
|
289
|
+
>`
|
|
290
|
+
: nothing}
|
|
291
|
+
<sp-button size="s" variant="primary" @click=${saveApiKey}>Save</sp-button>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
`;
|
|
243
296
|
}
|
|
244
297
|
|
|
245
|
-
|
|
246
|
-
interface ErrorData {
|
|
247
|
-
error?: string;
|
|
248
|
-
}
|
|
298
|
+
// ─── Document assistant rendering ────────────────────────────────────────────
|
|
249
299
|
|
|
250
|
-
/**
|
|
251
|
-
function
|
|
300
|
+
/** Send a message through the document assistant agent loop. */
|
|
301
|
+
async function handleAssistantSend(text: string) {
|
|
302
|
+
if (!text.trim() || assistant.chatState.status === "streaming") {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
chatInstance?.inputAreaSetEnabled(false);
|
|
252
306
|
try {
|
|
253
|
-
|
|
307
|
+
await assistant.sendMessage(text);
|
|
254
308
|
} catch {
|
|
255
|
-
|
|
309
|
+
// Synchronous failure (e.g. network unreachable) — the DocumentAssistant's
|
|
310
|
+
// Own try/catch calls chatState.setError(), which watchAssistant() displays.
|
|
311
|
+
// Re-enable input here as a safety net.
|
|
312
|
+
}
|
|
313
|
+
// Always re-enable input after the send attempt completes (or fails).
|
|
314
|
+
// WatchAssistant() also re-enables it when status !== "streaming", but if
|
|
315
|
+
// The assistant threw before chatState entered "streaming", we need this.
|
|
316
|
+
if ((assistant.chatState.status as ChatState) !== "streaming") {
|
|
317
|
+
chatInstance?.inputAreaSetEnabled(true);
|
|
256
318
|
}
|
|
257
319
|
}
|
|
258
320
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
321
|
+
/** Render a single finalized chat-state message into QuikChat. */
|
|
322
|
+
function renderAssistantMessage(msg: {
|
|
323
|
+
role: string;
|
|
324
|
+
content: string;
|
|
325
|
+
toolCalls?: { name: string; arguments: string }[];
|
|
326
|
+
}) {
|
|
327
|
+
if (!chatInstance) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (msg.role === "user") {
|
|
331
|
+
chatInstance.messageAddNew(msg.content, "You", "right", "user");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (msg.role === "tool") {
|
|
335
|
+
// Show only failed tool results so the user knows why an edit didn't land
|
|
336
|
+
// (ADR §11.3). Successful tool results stay hidden to reduce noise.
|
|
337
|
+
const parsed = tryParseToolResult(msg.content);
|
|
338
|
+
if (parsed && !parsed.success) {
|
|
339
|
+
chatInstance.messageAddNew(`⚠️ ${parsed.error || "Tool call failed"}`, "", "left", "tool");
|
|
279
340
|
}
|
|
280
|
-
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (msg.content) {
|
|
344
|
+
chatInstance.messageAddNew(msg.content, "", "left", "assistant");
|
|
345
|
+
}
|
|
346
|
+
for (const tc of msg.toolCalls ?? []) {
|
|
347
|
+
chatInstance.messageAddNew(formatAssistantToolLabel(tc), "", "left", "tool");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
281
350
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
351
|
+
/** Replay the assistant's existing history into a freshly mounted QuikChat instance. */
|
|
352
|
+
function replayAssistantMessages() {
|
|
353
|
+
assistantRenderedCount = 0;
|
|
354
|
+
assistantStreamingMsgId = null;
|
|
355
|
+
assistantStreamedLen = 0;
|
|
356
|
+
const msgs = assistant.chatState.messages;
|
|
357
|
+
const isStreaming = assistant.chatState.status === "streaming";
|
|
358
|
+
// Render every message except a still-streaming trailing assistant message.
|
|
359
|
+
const lastIdx = msgs.length - 1;
|
|
360
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
361
|
+
const m = msgs[i]!;
|
|
362
|
+
if (isStreaming && i === lastIdx && m.role === "assistant") {
|
|
363
|
+
break;
|
|
286
364
|
}
|
|
287
|
-
|
|
365
|
+
renderAssistantMessage(m);
|
|
366
|
+
assistantRenderedCount = i + 1;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
288
369
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
370
|
+
/**
|
|
371
|
+
* Reactively sync the assistant's chat-state into QuikChat. Newly-finalized messages are appended;
|
|
372
|
+
* the in-progress streaming message is updated incrementally so text flows token-by-token.
|
|
373
|
+
*/
|
|
374
|
+
function watchAssistant() {
|
|
375
|
+
assistantScope?.stop();
|
|
376
|
+
assistantScope = effectScope();
|
|
377
|
+
assistantScope.run(() => {
|
|
378
|
+
effect(() => {
|
|
379
|
+
const cs = assistant.chatState;
|
|
380
|
+
const msgs = cs.messages;
|
|
381
|
+
const { status } = cs;
|
|
382
|
+
if (!chatInstance) {
|
|
383
|
+
return;
|
|
294
384
|
}
|
|
295
|
-
currentAssistantText = `Error: ${data.result}`;
|
|
296
|
-
}
|
|
297
|
-
finishStream();
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
eventSource.addEventListener("done", () => {
|
|
301
|
-
finishStream();
|
|
302
|
-
});
|
|
303
385
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
386
|
+
const lastIdx = msgs.length - 1;
|
|
387
|
+
for (let i = assistantRenderedCount; i < msgs.length; i++) {
|
|
388
|
+
const m = msgs[i]!;
|
|
389
|
+
const isStreamingTail = status === "streaming" && i === lastIdx && m.role === "assistant";
|
|
390
|
+
if (isStreamingTail) {
|
|
391
|
+
// Stream this message's text into a live bubble rather than finalizing it.
|
|
392
|
+
if (assistantStreamingMsgId == null) {
|
|
393
|
+
assistantStreamingMsgId = chatInstance.messageAddNew(
|
|
394
|
+
m.content || "",
|
|
395
|
+
"",
|
|
396
|
+
"left",
|
|
397
|
+
"assistant",
|
|
398
|
+
);
|
|
399
|
+
assistantStreamedLen = (m.content || "").length;
|
|
400
|
+
} else if ((m.content || "").length > assistantStreamedLen) {
|
|
401
|
+
chatInstance.messageAppendContent(
|
|
402
|
+
assistantStreamingMsgId,
|
|
403
|
+
m.content.slice(assistantStreamedLen),
|
|
404
|
+
);
|
|
405
|
+
assistantStreamedLen = m.content.length;
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
if (assistantStreamingMsgId != null && m.role === "assistant") {
|
|
410
|
+
// The streaming bubble already contains the full text — just finalize it in place.
|
|
411
|
+
chatInstance.messageReplaceContent(assistantStreamingMsgId, m.content || "");
|
|
412
|
+
} else {
|
|
413
|
+
renderAssistantMessage(m);
|
|
414
|
+
}
|
|
415
|
+
assistantRenderedCount = i + 1;
|
|
416
|
+
assistantStreamingMsgId = null;
|
|
417
|
+
assistantStreamedLen = 0;
|
|
309
418
|
}
|
|
310
|
-
currentAssistantText = `Error: ${data.error}`;
|
|
311
|
-
}
|
|
312
|
-
finishStream();
|
|
313
|
-
});
|
|
314
419
|
|
|
315
|
-
|
|
316
|
-
|
|
420
|
+
if (status !== "streaming") {
|
|
421
|
+
assistantStreamingMsgId = null;
|
|
422
|
+
assistantStreamedLen = 0;
|
|
423
|
+
chatInstance.inputAreaSetEnabled(true);
|
|
424
|
+
if (cs.error) {
|
|
425
|
+
const advice = formatErrorAdvice(cs.error);
|
|
426
|
+
chatInstance.messageAddNew(
|
|
427
|
+
`❌ ${cs.error}${advice ? `\n\n${advice}` : ""}`,
|
|
428
|
+
"",
|
|
429
|
+
"left",
|
|
430
|
+
"assistant",
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
});
|
|
317
435
|
});
|
|
318
436
|
}
|
|
319
437
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (pendingFileReloads.size > 0) {
|
|
336
|
-
for (const fp of pendingFileReloads) {
|
|
337
|
-
void reloadFileInTab(fp);
|
|
438
|
+
/**
|
|
439
|
+
* Parse a tool result message content (JSON string) into its success/error shape. Returns null if
|
|
440
|
+
* the content isn't a valid tool result.
|
|
441
|
+
*
|
|
442
|
+
* @param {string} content
|
|
443
|
+
* @returns {{ success: boolean; error?: string } | null}
|
|
444
|
+
*/
|
|
445
|
+
function tryParseToolResult(
|
|
446
|
+
content: string,
|
|
447
|
+
): { success: boolean; error?: string; summary?: string } | null {
|
|
448
|
+
try {
|
|
449
|
+
const parsed = JSON.parse(content) as { success?: unknown; error?: string; summary?: string };
|
|
450
|
+
if (parsed && typeof parsed.success === "boolean") {
|
|
451
|
+
return parsed as { success: boolean; error?: string; summary?: string };
|
|
338
452
|
}
|
|
339
|
-
|
|
453
|
+
} catch {
|
|
454
|
+
/* Not JSON — not a tool result */
|
|
340
455
|
}
|
|
341
|
-
|
|
342
|
-
rerenderPanel();
|
|
456
|
+
return null;
|
|
343
457
|
}
|
|
344
458
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
459
|
+
/** @param {{ name: string; arguments: string }} tc */
|
|
460
|
+
function formatAssistantToolLabel(tc: { name: string; arguments: string }) {
|
|
461
|
+
let detail = "";
|
|
462
|
+
try {
|
|
463
|
+
const args = (tc.arguments ? JSON.parse(tc.arguments) : {}) as {
|
|
464
|
+
path?: unknown;
|
|
465
|
+
parentPath?: unknown;
|
|
466
|
+
};
|
|
467
|
+
if (Array.isArray(args.path)) {
|
|
468
|
+
detail = `: ${JSON.stringify(args.path)}`;
|
|
469
|
+
} else if (Array.isArray(args.parentPath)) {
|
|
470
|
+
detail = `: ${JSON.stringify(args.parentPath)}`;
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
/* Partial/unparsed args — show name only */
|
|
349
474
|
}
|
|
475
|
+
return `🔧 ${tc.name}${detail}`;
|
|
350
476
|
}
|
|
351
477
|
|
|
352
|
-
/**
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
[key: string]: unknown;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** @param {AssistantMessageData} data */
|
|
367
|
-
function handleAssistantMessage(data: AssistantMessageData) {
|
|
368
|
-
const content = data.message?.content || data.content;
|
|
369
|
-
if (!content) {
|
|
370
|
-
return;
|
|
478
|
+
/**
|
|
479
|
+
* Return actionable advice for common AI assistant errors so the user knows how to recover instead
|
|
480
|
+
* of just seeing a raw error message.
|
|
481
|
+
*
|
|
482
|
+
* @param {string} error
|
|
483
|
+
* @returns {string}
|
|
484
|
+
*/
|
|
485
|
+
function formatErrorAdvice(error: string) {
|
|
486
|
+
const lower = error.toLowerCase();
|
|
487
|
+
if (lower.includes("no api key") || lower.includes("401")) {
|
|
488
|
+
return "Click the 🔑 button in the toolbar to add an OpenAI-compatible API key.";
|
|
371
489
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const toolBlocks = [];
|
|
375
|
-
|
|
376
|
-
for (const block of content) {
|
|
377
|
-
if (block.type === "text") {
|
|
378
|
-
text += block.text;
|
|
379
|
-
} else if (block.type === "tool_use" && typeof block.name === "string") {
|
|
380
|
-
toolBlocks.push({ tool: block.name, ...(block.input ? { input: block.input } : {}) });
|
|
381
|
-
if ((block.name === "Edit" || block.name === "Write") && block.input) {
|
|
382
|
-
const fp = block.input.file_path || block.input.path;
|
|
383
|
-
if (fp) {
|
|
384
|
-
pendingFileReloads.add(String(fp));
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
490
|
+
if (lower.includes("network error") || lower.includes("fetch")) {
|
|
491
|
+
return "Check that the dev server is running and reachable.";
|
|
388
492
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (currentAssistantText) {
|
|
392
|
-
messages.push({ content: currentAssistantText, role: "assistant" });
|
|
393
|
-
}
|
|
394
|
-
for (const t of toolBlocks) {
|
|
395
|
-
messages.push({ content: "", role: "tool", toolUse: t });
|
|
396
|
-
if (chatInstance) {
|
|
397
|
-
chatInstance.messageAddNew(formatToolLabel(t.tool, t.input), "", "left", "tool");
|
|
398
|
-
}
|
|
399
|
-
}
|
|
493
|
+
if (lower.includes("429") || lower.includes("rate limit")) {
|
|
494
|
+
return "The API rate limit was hit. Wait a moment and try again.";
|
|
400
495
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (chatInstance) {
|
|
404
|
-
currentStreamMsgId = chatInstance.messageAddNew(text || "", "", "left", "assistant");
|
|
405
|
-
streamStarted = Boolean(text);
|
|
496
|
+
if (lower.includes("500") || lower.includes("internal")) {
|
|
497
|
+
return "The upstream API returned a server error. Try again in a moment.";
|
|
406
498
|
}
|
|
499
|
+
return "";
|
|
407
500
|
}
|
|
408
501
|
|
|
409
|
-
// ───
|
|
502
|
+
// ─── Controls ─────────────────────────────────────────────────────────────────
|
|
410
503
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
* @param {Record<string, unknown>} [input]
|
|
414
|
-
*/
|
|
415
|
-
function formatToolLabel(tool: string, input?: Record<string, unknown>) {
|
|
416
|
-
switch (tool) {
|
|
417
|
-
case "Edit":
|
|
418
|
-
case "Write": {
|
|
419
|
-
return `📝 ${tool}: ${input?.file_path || input?.path || "file"}`;
|
|
420
|
-
}
|
|
421
|
-
case "Read": {
|
|
422
|
-
return `📖 Read: ${input?.file_path || input?.path || "file"}`;
|
|
423
|
-
}
|
|
424
|
-
case "Bash": {
|
|
425
|
-
return `⚡ Run: ${truncate(String(input?.command || ""), 50)}`;
|
|
426
|
-
}
|
|
427
|
-
case "Glob": {
|
|
428
|
-
return `🔍 Glob: ${input?.pattern || ""}`;
|
|
429
|
-
}
|
|
430
|
-
case "Grep": {
|
|
431
|
-
return `🔍 Grep: ${truncate(String(input?.pattern || ""), 40)}`;
|
|
432
|
-
}
|
|
433
|
-
default: {
|
|
434
|
-
return `🔧 ${tool}`;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
504
|
+
function stop() {
|
|
505
|
+
assistant.stop();
|
|
437
506
|
}
|
|
438
507
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
508
|
+
function newChat() {
|
|
509
|
+
assistant.newChat();
|
|
510
|
+
assistantRenderedCount = 0;
|
|
511
|
+
assistantStreamingMsgId = null;
|
|
512
|
+
assistantStreamedLen = 0;
|
|
513
|
+
if (chatInstance) {
|
|
514
|
+
chatInstance.historyImport([]);
|
|
515
|
+
chatInstance.inputAreaSetEnabled(true);
|
|
516
|
+
}
|
|
517
|
+
rerenderPanel();
|
|
442
518
|
}
|
|
443
519
|
|
|
444
520
|
// ─── Template ───────────────────────────────────────────────────────────────
|
|
445
521
|
|
|
446
522
|
/** @returns {import("lit-html").TemplateResult} */
|
|
447
523
|
export function renderAiPanelTemplate() {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
524
|
+
// The document assistant authenticates via the AI proxy (an OpenAI-compatible key). Gate the chat
|
|
525
|
+
// Behind the key form until one is stored locally.
|
|
526
|
+
if (!hasOpenAiKey() || keyEditing) {
|
|
527
|
+
return renderKeyGate();
|
|
452
528
|
}
|
|
453
529
|
|
|
454
|
-
|
|
455
|
-
return html`
|
|
456
|
-
<div class="ai-tab-body">
|
|
457
|
-
<div class="ai-status-center">
|
|
458
|
-
<sp-icon-artboard style="font-size:32px"></sp-icon-artboard>
|
|
459
|
-
<div>Claude authentication required</div>
|
|
460
|
-
<div
|
|
461
|
-
style="font-size:var(--spectrum-font-size-50, 11px);color:var(--spectrum-gray-600, #808080)"
|
|
462
|
-
>
|
|
463
|
-
Run the following in your terminal:
|
|
464
|
-
</div>
|
|
465
|
-
<code class="ai-code-snippet">npx @anthropic-ai/claude-code login</code>
|
|
466
|
-
${authError
|
|
467
|
-
? html`<sp-help-text variant="negative">${authError}</sp-help-text>`
|
|
468
|
-
: nothing}
|
|
469
|
-
<sp-button size="s" variant="primary" @click=${checkAuth}>Retry</sp-button>
|
|
470
|
-
</div>
|
|
471
|
-
</div>
|
|
472
|
-
`;
|
|
473
|
-
}
|
|
530
|
+
const busy = assistant.chatState.status === "streaming";
|
|
474
531
|
|
|
475
532
|
return html`
|
|
476
533
|
<div class="ai-tab-body">
|
|
477
534
|
<div class="ai-toolbar">
|
|
478
|
-
${
|
|
479
|
-
? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>`
|
|
480
|
-
: nothing}
|
|
535
|
+
${busy ? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>` : nothing}
|
|
481
536
|
<sp-action-button size="xs" quiet @click=${newChat}>
|
|
482
537
|
<sp-icon-add slot="icon"></sp-icon-add>
|
|
483
538
|
New Chat
|
|
484
539
|
</sp-action-button>
|
|
540
|
+
<sp-action-button size="xs" quiet title="API key & endpoint" @click=${startEditApiKey}>
|
|
541
|
+
🔑
|
|
542
|
+
</sp-action-button>
|
|
485
543
|
</div>
|
|
486
544
|
<div
|
|
487
545
|
id="ai-quikchat"
|