@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
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,428 @@ 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(--spectrum-global-color-gray-600)">
|
|
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(--spectrum-global-color-gray-400);background:var(--spectrum-global-color-gray-50);color:var(--spectrum-global-color-gray-900);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(--spectrum-global-color-gray-400);background:var(--spectrum-global-color-gray-50);color:var(--spectrum-global-color-gray-900);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(--spectrum-global-color-red-600)"
|
|
274
|
+
>${modelsError}</span
|
|
275
|
+
>`
|
|
276
|
+
: nothing}
|
|
277
|
+
</div>
|
|
278
|
+
<input
|
|
279
|
+
type="text"
|
|
280
|
+
style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--spectrum-global-color-gray-400);background:var(--spectrum-global-color-gray-50);color:var(--spectrum-global-color-gray-900);font-size:12px"
|
|
281
|
+
placeholder="Endpoint (optional, e.g. http://localhost:11434/v1)"
|
|
282
|
+
.value=${baseUrlDraft}
|
|
283
|
+
@input=${(e: Event) => {
|
|
284
|
+
baseUrlDraft = (e.target as HTMLInputElement).value;
|
|
285
|
+
}}
|
|
286
|
+
/>
|
|
287
|
+
<div style="display:flex;gap:8px;align-self:flex-end">
|
|
288
|
+
${haveKey
|
|
289
|
+
? html`<sp-button size="s" variant="secondary" @click=${cancelEditApiKey}
|
|
290
|
+
>Cancel</sp-button
|
|
291
|
+
>`
|
|
292
|
+
: nothing}
|
|
293
|
+
<sp-button size="s" variant="primary" @click=${saveApiKey}>Save</sp-button>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
`;
|
|
243
298
|
}
|
|
244
299
|
|
|
245
|
-
|
|
246
|
-
interface ErrorData {
|
|
247
|
-
error?: string;
|
|
248
|
-
}
|
|
300
|
+
// ─── Document assistant rendering ────────────────────────────────────────────
|
|
249
301
|
|
|
250
|
-
/**
|
|
251
|
-
function
|
|
302
|
+
/** Send a message through the document assistant agent loop. */
|
|
303
|
+
async function handleAssistantSend(text: string) {
|
|
304
|
+
if (!text.trim() || assistant.chatState.status === "streaming") {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
chatInstance?.inputAreaSetEnabled(false);
|
|
252
308
|
try {
|
|
253
|
-
|
|
309
|
+
await assistant.sendMessage(text);
|
|
254
310
|
} catch {
|
|
255
|
-
|
|
311
|
+
// Synchronous failure (e.g. network unreachable) — the DocumentAssistant's
|
|
312
|
+
// Own try/catch calls chatState.setError(), which watchAssistant() displays.
|
|
313
|
+
// Re-enable input here as a safety net.
|
|
314
|
+
}
|
|
315
|
+
// Always re-enable input after the send attempt completes (or fails).
|
|
316
|
+
// WatchAssistant() also re-enables it when status !== "streaming", but if
|
|
317
|
+
// The assistant threw before chatState entered "streaming", we need this.
|
|
318
|
+
if ((assistant.chatState.status as ChatState) !== "streaming") {
|
|
319
|
+
chatInstance?.inputAreaSetEnabled(true);
|
|
256
320
|
}
|
|
257
321
|
}
|
|
258
322
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
323
|
+
/** Render a single finalized chat-state message into QuikChat. */
|
|
324
|
+
function renderAssistantMessage(msg: {
|
|
325
|
+
role: string;
|
|
326
|
+
content: string;
|
|
327
|
+
toolCalls?: { name: string; arguments: string }[];
|
|
328
|
+
}) {
|
|
329
|
+
if (!chatInstance) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (msg.role === "user") {
|
|
333
|
+
chatInstance.messageAddNew(msg.content, "You", "right", "user");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (msg.role === "tool") {
|
|
337
|
+
// Show only failed tool results so the user knows why an edit didn't land
|
|
338
|
+
// (ADR §11.3). Successful tool results stay hidden to reduce noise.
|
|
339
|
+
const parsed = tryParseToolResult(msg.content);
|
|
340
|
+
if (parsed && !parsed.success) {
|
|
341
|
+
chatInstance.messageAddNew(`⚠️ ${parsed.error || "Tool call failed"}`, "", "left", "tool");
|
|
279
342
|
}
|
|
280
|
-
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (msg.content) {
|
|
346
|
+
chatInstance.messageAddNew(msg.content, "", "left", "assistant");
|
|
347
|
+
}
|
|
348
|
+
for (const tc of msg.toolCalls ?? []) {
|
|
349
|
+
chatInstance.messageAddNew(formatAssistantToolLabel(tc), "", "left", "tool");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
281
352
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
353
|
+
/** Replay the assistant's existing history into a freshly mounted QuikChat instance. */
|
|
354
|
+
function replayAssistantMessages() {
|
|
355
|
+
assistantRenderedCount = 0;
|
|
356
|
+
assistantStreamingMsgId = null;
|
|
357
|
+
assistantStreamedLen = 0;
|
|
358
|
+
const msgs = assistant.chatState.messages;
|
|
359
|
+
const isStreaming = assistant.chatState.status === "streaming";
|
|
360
|
+
// Render every message except a still-streaming trailing assistant message.
|
|
361
|
+
const lastIdx = msgs.length - 1;
|
|
362
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
363
|
+
const m = msgs[i]!;
|
|
364
|
+
if (isStreaming && i === lastIdx && m.role === "assistant") {
|
|
365
|
+
break;
|
|
286
366
|
}
|
|
287
|
-
|
|
367
|
+
renderAssistantMessage(m);
|
|
368
|
+
assistantRenderedCount = i + 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
288
371
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Reactively sync the assistant's chat-state into QuikChat. Newly-finalized messages are appended;
|
|
374
|
+
* the in-progress streaming message is updated incrementally so text flows token-by-token.
|
|
375
|
+
*/
|
|
376
|
+
function watchAssistant() {
|
|
377
|
+
assistantScope?.stop();
|
|
378
|
+
assistantScope = effectScope();
|
|
379
|
+
assistantScope.run(() => {
|
|
380
|
+
effect(() => {
|
|
381
|
+
const cs = assistant.chatState;
|
|
382
|
+
const msgs = cs.messages;
|
|
383
|
+
const { status } = cs;
|
|
384
|
+
if (!chatInstance) {
|
|
385
|
+
return;
|
|
294
386
|
}
|
|
295
|
-
currentAssistantText = `Error: ${data.result}`;
|
|
296
|
-
}
|
|
297
|
-
finishStream();
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
eventSource.addEventListener("done", () => {
|
|
301
|
-
finishStream();
|
|
302
|
-
});
|
|
303
387
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
388
|
+
const lastIdx = msgs.length - 1;
|
|
389
|
+
for (let i = assistantRenderedCount; i < msgs.length; i++) {
|
|
390
|
+
const m = msgs[i]!;
|
|
391
|
+
const isStreamingTail = status === "streaming" && i === lastIdx && m.role === "assistant";
|
|
392
|
+
if (isStreamingTail) {
|
|
393
|
+
// Stream this message's text into a live bubble rather than finalizing it.
|
|
394
|
+
if (assistantStreamingMsgId == null) {
|
|
395
|
+
assistantStreamingMsgId = chatInstance.messageAddNew(
|
|
396
|
+
m.content || "",
|
|
397
|
+
"",
|
|
398
|
+
"left",
|
|
399
|
+
"assistant",
|
|
400
|
+
);
|
|
401
|
+
assistantStreamedLen = (m.content || "").length;
|
|
402
|
+
} else if ((m.content || "").length > assistantStreamedLen) {
|
|
403
|
+
chatInstance.messageAppendContent(
|
|
404
|
+
assistantStreamingMsgId,
|
|
405
|
+
m.content.slice(assistantStreamedLen),
|
|
406
|
+
);
|
|
407
|
+
assistantStreamedLen = m.content.length;
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
if (assistantStreamingMsgId != null && m.role === "assistant") {
|
|
412
|
+
// The streaming bubble already contains the full text — just finalize it in place.
|
|
413
|
+
chatInstance.messageReplaceContent(assistantStreamingMsgId, m.content || "");
|
|
414
|
+
} else {
|
|
415
|
+
renderAssistantMessage(m);
|
|
416
|
+
}
|
|
417
|
+
assistantRenderedCount = i + 1;
|
|
418
|
+
assistantStreamingMsgId = null;
|
|
419
|
+
assistantStreamedLen = 0;
|
|
309
420
|
}
|
|
310
|
-
currentAssistantText = `Error: ${data.error}`;
|
|
311
|
-
}
|
|
312
|
-
finishStream();
|
|
313
|
-
});
|
|
314
421
|
|
|
315
|
-
|
|
316
|
-
|
|
422
|
+
if (status !== "streaming") {
|
|
423
|
+
assistantStreamingMsgId = null;
|
|
424
|
+
assistantStreamedLen = 0;
|
|
425
|
+
chatInstance.inputAreaSetEnabled(true);
|
|
426
|
+
if (cs.error) {
|
|
427
|
+
const advice = formatErrorAdvice(cs.error);
|
|
428
|
+
chatInstance.messageAddNew(
|
|
429
|
+
`❌ ${cs.error}${advice ? `\n\n${advice}` : ""}`,
|
|
430
|
+
"",
|
|
431
|
+
"left",
|
|
432
|
+
"assistant",
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
});
|
|
317
437
|
});
|
|
318
438
|
}
|
|
319
439
|
|
|
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);
|
|
440
|
+
/**
|
|
441
|
+
* Parse a tool result message content (JSON string) into its success/error shape. Returns null if
|
|
442
|
+
* the content isn't a valid tool result.
|
|
443
|
+
*
|
|
444
|
+
* @param {string} content
|
|
445
|
+
* @returns {{ success: boolean; error?: string } | null}
|
|
446
|
+
*/
|
|
447
|
+
function tryParseToolResult(
|
|
448
|
+
content: string,
|
|
449
|
+
): { success: boolean; error?: string; summary?: string } | null {
|
|
450
|
+
try {
|
|
451
|
+
const parsed = JSON.parse(content) as { success?: unknown; error?: string; summary?: string };
|
|
452
|
+
if (parsed && typeof parsed.success === "boolean") {
|
|
453
|
+
return parsed as { success: boolean; error?: string; summary?: string };
|
|
338
454
|
}
|
|
339
|
-
|
|
455
|
+
} catch {
|
|
456
|
+
/* Not JSON — not a tool result */
|
|
340
457
|
}
|
|
341
|
-
|
|
342
|
-
rerenderPanel();
|
|
458
|
+
return null;
|
|
343
459
|
}
|
|
344
460
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
461
|
+
/** @param {{ name: string; arguments: string }} tc */
|
|
462
|
+
function formatAssistantToolLabel(tc: { name: string; arguments: string }) {
|
|
463
|
+
let detail = "";
|
|
464
|
+
try {
|
|
465
|
+
const args = (tc.arguments ? JSON.parse(tc.arguments) : {}) as {
|
|
466
|
+
path?: unknown;
|
|
467
|
+
parentPath?: unknown;
|
|
468
|
+
};
|
|
469
|
+
if (Array.isArray(args.path)) {
|
|
470
|
+
detail = `: ${JSON.stringify(args.path)}`;
|
|
471
|
+
} else if (Array.isArray(args.parentPath)) {
|
|
472
|
+
detail = `: ${JSON.stringify(args.parentPath)}`;
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
/* Partial/unparsed args — show name only */
|
|
349
476
|
}
|
|
477
|
+
return `🔧 ${tc.name}${detail}`;
|
|
350
478
|
}
|
|
351
479
|
|
|
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;
|
|
480
|
+
/**
|
|
481
|
+
* Return actionable advice for common AI assistant errors so the user knows how to recover instead
|
|
482
|
+
* of just seeing a raw error message.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} error
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
function formatErrorAdvice(error: string) {
|
|
488
|
+
const lower = error.toLowerCase();
|
|
489
|
+
if (lower.includes("no api key") || lower.includes("401")) {
|
|
490
|
+
return "Click the 🔑 button in the toolbar to add an OpenAI-compatible API key.";
|
|
371
491
|
}
|
|
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
|
-
}
|
|
492
|
+
if (lower.includes("network error") || lower.includes("fetch")) {
|
|
493
|
+
return "Check that the dev server is running and reachable.";
|
|
388
494
|
}
|
|
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
|
-
}
|
|
495
|
+
if (lower.includes("429") || lower.includes("rate limit")) {
|
|
496
|
+
return "The API rate limit was hit. Wait a moment and try again.";
|
|
400
497
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (chatInstance) {
|
|
404
|
-
currentStreamMsgId = chatInstance.messageAddNew(text || "", "", "left", "assistant");
|
|
405
|
-
streamStarted = Boolean(text);
|
|
498
|
+
if (lower.includes("500") || lower.includes("internal")) {
|
|
499
|
+
return "The upstream API returned a server error. Try again in a moment.";
|
|
406
500
|
}
|
|
501
|
+
return "";
|
|
407
502
|
}
|
|
408
503
|
|
|
409
|
-
// ───
|
|
504
|
+
// ─── Controls ─────────────────────────────────────────────────────────────────
|
|
410
505
|
|
|
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
|
-
}
|
|
506
|
+
function stop() {
|
|
507
|
+
assistant.stop();
|
|
437
508
|
}
|
|
438
509
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
510
|
+
function newChat() {
|
|
511
|
+
assistant.newChat();
|
|
512
|
+
assistantRenderedCount = 0;
|
|
513
|
+
assistantStreamingMsgId = null;
|
|
514
|
+
assistantStreamedLen = 0;
|
|
515
|
+
if (chatInstance) {
|
|
516
|
+
chatInstance.historyImport([]);
|
|
517
|
+
chatInstance.inputAreaSetEnabled(true);
|
|
518
|
+
}
|
|
519
|
+
rerenderPanel();
|
|
442
520
|
}
|
|
443
521
|
|
|
444
522
|
// ─── Template ───────────────────────────────────────────────────────────────
|
|
445
523
|
|
|
446
524
|
/** @returns {import("lit-html").TemplateResult} */
|
|
447
525
|
export function renderAiPanelTemplate() {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
526
|
+
// The document assistant authenticates via the AI proxy (an OpenAI-compatible key). Gate the chat
|
|
527
|
+
// Behind the key form until one is stored locally.
|
|
528
|
+
if (!hasOpenAiKey() || keyEditing) {
|
|
529
|
+
return renderKeyGate();
|
|
452
530
|
}
|
|
453
531
|
|
|
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
|
-
}
|
|
532
|
+
const busy = assistant.chatState.status === "streaming";
|
|
474
533
|
|
|
475
534
|
return html`
|
|
476
535
|
<div class="ai-tab-body">
|
|
477
536
|
<div class="ai-toolbar">
|
|
478
|
-
${
|
|
479
|
-
? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>`
|
|
480
|
-
: nothing}
|
|
537
|
+
${busy ? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>` : nothing}
|
|
481
538
|
<sp-action-button size="xs" quiet @click=${newChat}>
|
|
482
539
|
<sp-icon-add slot="icon"></sp-icon-add>
|
|
483
540
|
New Chat
|
|
484
541
|
</sp-action-button>
|
|
542
|
+
<sp-action-button size="xs" quiet title="API key & endpoint" @click=${startEditApiKey}>
|
|
543
|
+
🔑
|
|
544
|
+
</sp-action-button>
|
|
485
545
|
</div>
|
|
486
546
|
<div
|
|
487
547
|
id="ai-quikchat"
|