@jxsuite/studio 0.34.0 → 0.36.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 +9 -1
- package/dist/iframe-entry.js.map +3 -3
- package/dist/studio.js +18382 -3686
- package/dist/studio.js.map +265 -26
- package/package.json +9 -6
- package/src/files/files.ts +3 -0
- package/src/new-project/design-fields.ts +260 -0
- package/src/new-project/import-tab.ts +322 -0
- package/src/new-project/new-project-modal.ts +472 -89
- package/src/new-project/templates.ts +61 -0
- package/src/packages/ensure-deps.ts +6 -0
- package/src/packages/pull-package-sync.ts +339 -0
- package/src/panels/ai-chat/attached-context.ts +49 -0
- package/src/panels/ai-chat/chat-markdown.ts +38 -0
- package/src/panels/ai-chat/chat-view.ts +250 -0
- package/src/panels/ai-chat/composer.ts +315 -0
- package/src/panels/ai-chat/sessions-view.ts +98 -0
- package/src/panels/ai-panel.ts +214 -458
- package/src/panels/drag-ghost.ts +1 -1
- package/src/panels/git-panel.ts +16 -1
- package/src/panels/right-panel.ts +21 -5
- package/src/panels/toolbar.ts +2 -0
- package/src/panels/welcome-screen.ts +26 -0
- package/src/platforms/cloud.ts +774 -0
- package/src/platforms/devserver.ts +100 -1
- package/src/project-list.ts +38 -0
- package/src/publish/pages-service.ts +186 -0
- package/src/publish/publish-panel.ts +360 -0
- package/src/services/agent-seed.ts +91 -0
- package/src/services/ai-models.ts +93 -0
- package/src/services/ai-session-store.ts +250 -0
- package/src/services/ai-settings.ts +5 -0
- package/src/services/automation.ts +140 -0
- package/src/services/cf-settings.ts +58 -0
- package/src/services/document-assistant.ts +98 -38
- package/src/services/import-client.ts +134 -0
- package/src/services/settings-store.ts +99 -0
- package/src/studio.ts +33 -0
- package/src/types.ts +130 -133
- package/src/ui/ai-credentials-form.ts +205 -0
- package/src/ui/spectrum.ts +8 -0
package/src/panels/ai-panel.ts
CHANGED
|
@@ -2,73 +2,119 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Ai-panel.ts — AI assistant tab for the right panel (Stack B document assistant).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Native lit-html chat UI (VSCode-Copilot style) over the reactive document-assistant
|
|
6
|
+
* session: a view-state machine (key gate → sessions list ↔ chat), a message list with
|
|
7
|
+
* stick-to-bottom scrolling, and the sticky composer.
|
|
8
|
+
*
|
|
9
|
+
* Rendering: this panel owns a private rAF-coalesced render loop into the assistant
|
|
10
|
+
* `.panel-body` container (bound once via {@link bindAiPanelHost}). It deliberately
|
|
11
|
+
* bypasses the right-panel scheduler's focus guard — streaming must repaint while the
|
|
12
|
+
* composer is focused — which is safe because nothing here is value-bound (the composer
|
|
13
|
+
* textarea is uncontrolled). The right panel renders the same template into the same
|
|
14
|
+
* container on tab switches; lit reconciles both paths through one part cache.
|
|
15
|
+
*
|
|
16
|
+
* @license MIT
|
|
7
17
|
*/
|
|
8
18
|
|
|
9
|
-
import { html,
|
|
10
|
-
import {
|
|
11
|
-
import quikchat from "quikchat/md";
|
|
12
|
-
import { getPlatform } from "../platform";
|
|
19
|
+
import { html, render as litRender } from "lit-html";
|
|
20
|
+
import type { TemplateResult } from "lit-html";
|
|
13
21
|
import { effect, effectScope } from "../reactivity";
|
|
14
22
|
import { createDocumentAssistant } from "../services/document-assistant";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "../services/ai-settings";
|
|
24
|
-
|
|
25
|
-
import type { ChatState } from "@jxsuite/ai/chat-state";
|
|
23
|
+
import { hasOpenAiKey } from "../services/ai-settings";
|
|
24
|
+
import { fetchAvailableModels, isProxyConfigured } from "../services/ai-models";
|
|
25
|
+
import { createAiCredentialsForm } from "../ui/ai-credentials-form";
|
|
26
|
+
import { clearMarkdownCache } from "./ai-chat/chat-markdown";
|
|
27
|
+
import { renderChatHeader, renderMessageList } from "./ai-chat/chat-view";
|
|
28
|
+
import { createComposer } from "./ai-chat/composer";
|
|
29
|
+
import { renderSessionsList } from "./ai-chat/sessions-view";
|
|
30
|
+
|
|
26
31
|
import type { EffectScope } from "@vue/reactivity";
|
|
27
32
|
|
|
28
33
|
// ─── State (module-level, persists across tab switches) ─────────────────────
|
|
29
34
|
|
|
30
35
|
let mounted = false;
|
|
31
36
|
|
|
32
|
-
/**
|
|
33
|
-
interface QuikChatInstance {
|
|
34
|
-
messageAddNew: (text: string, sender: string, side: string, role?: string) => number;
|
|
35
|
-
messageAddTypingIndicator: (text: string) => number;
|
|
36
|
-
messageReplaceContent: (id: number, text: string) => void;
|
|
37
|
-
messageAppendContent: (id: number, text: string) => void;
|
|
38
|
-
inputAreaSetEnabled: (enabled: boolean) => void;
|
|
39
|
-
historyImport: (history: unknown[]) => void;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
type QuikChatCtor = new (
|
|
43
|
-
container: HTMLElement,
|
|
44
|
-
onSend: (chat: unknown, msg: string) => void,
|
|
45
|
-
opts: Record<string, unknown>,
|
|
46
|
-
) => QuikChatInstance;
|
|
47
|
-
|
|
48
|
-
const QuikChat = quikchat as unknown as QuikChatCtor;
|
|
49
|
-
|
|
50
|
-
let chatInstance: QuikChatInstance | null = null;
|
|
51
|
-
let chatContainerEl: Element | null = null;
|
|
52
|
-
let _quikChatEl: HTMLElement | null = null;
|
|
53
|
-
|
|
54
|
-
/** Whether the OpenAI key form is showing (gate when no key, or re-edit via the toolbar). */
|
|
37
|
+
/** Whether the OpenAI key form is showing (gate when no key, or re-edit via the gear). */
|
|
55
38
|
let keyEditing = false;
|
|
56
|
-
let keyDraft = "";
|
|
57
|
-
let baseUrlDraft = "";
|
|
58
|
-
let modelDraft = "";
|
|
59
39
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
40
|
+
/**
|
|
41
|
+
* One-time proxy probe: managed platforms (cloud Workers AI) and env-keyed dev servers report
|
|
42
|
+
* `configured` from /models, unlocking the assistant without a locally stored key. Fired lazily on
|
|
43
|
+
* the first gated render.
|
|
44
|
+
*/
|
|
45
|
+
let proxyProbe: Promise<void> | null = null;
|
|
46
|
+
|
|
47
|
+
function ensureProxyProbe() {
|
|
48
|
+
proxyProbe ??= fetchAvailableModels({ force: true })
|
|
49
|
+
.catch(() => {
|
|
50
|
+
// Unreachable proxy — the key gate stays up.
|
|
51
|
+
})
|
|
52
|
+
.then(() => {
|
|
53
|
+
scheduleAiRender();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Which pane the panel shows once the key gate is passed. */
|
|
58
|
+
let view: "chat" | "sessions" = "chat";
|
|
64
59
|
|
|
65
60
|
/** Document AST assistant session — created lazily, persists across tab switches. */
|
|
66
61
|
const assistant = createDocumentAssistant();
|
|
67
62
|
(globalThis as Record<string, unknown>).assistant = assistant;
|
|
68
63
|
let assistantScope: EffectScope | null = null;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
|
|
65
|
+
// ─── Render loop ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** The assistant tab's `.panel-body` container, bound once by the right panel. */
|
|
68
|
+
let hostEl: HTMLElement | null = null;
|
|
69
|
+
let renderQueued = false;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Bind the panel's render host and start the streaming watcher. Called once from the right panel's
|
|
73
|
+
* container setup; replaces the old global render-bridge.
|
|
74
|
+
*
|
|
75
|
+
* @param {HTMLElement} el
|
|
76
|
+
*/
|
|
77
|
+
export function bindAiPanelHost(el: HTMLElement) {
|
|
78
|
+
hostEl = el;
|
|
79
|
+
watchAssistant();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Coalesce a re-render of the whole panel onto the next animation frame. */
|
|
83
|
+
function scheduleAiRender() {
|
|
84
|
+
if (renderQueued || !hostEl) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
renderQueued = true;
|
|
88
|
+
requestAnimationFrame(() => {
|
|
89
|
+
renderQueued = false;
|
|
90
|
+
if (hostEl) {
|
|
91
|
+
litRender(renderAiPanelTemplate(), hostEl);
|
|
92
|
+
maintainScroll();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reactively repaint on chat-state changes. Tracks the message count, the tail message's growth
|
|
99
|
+
* (streaming deltas / tool calls), status, and errors; the actual DOM work happens in the
|
|
100
|
+
* rAF-coalesced render, so token rate never exceeds frame rate.
|
|
101
|
+
*/
|
|
102
|
+
function watchAssistant() {
|
|
103
|
+
assistantScope?.stop();
|
|
104
|
+
assistantScope = effectScope();
|
|
105
|
+
assistantScope.run(() => {
|
|
106
|
+
effect(() => {
|
|
107
|
+
const cs = assistant.chatState;
|
|
108
|
+
void cs.messages.length;
|
|
109
|
+
const last = cs.messages.at(-1);
|
|
110
|
+
void last?.content;
|
|
111
|
+
void last?.toolCalls?.length;
|
|
112
|
+
void cs.status;
|
|
113
|
+
void cs.error;
|
|
114
|
+
scheduleAiRender();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
72
118
|
|
|
73
119
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
74
120
|
|
|
@@ -79,474 +125,184 @@ export function mountAiPanel() {
|
|
|
79
125
|
mounted = true;
|
|
80
126
|
}
|
|
81
127
|
|
|
82
|
-
|
|
83
|
-
__jxRightPanelRender?: { render: () => void };
|
|
84
|
-
};
|
|
128
|
+
// ─── Auto-scroll (stick to bottom unless the user scrolled up) ──────────────
|
|
85
129
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (render) {
|
|
89
|
-
render();
|
|
90
|
-
}
|
|
91
|
-
requestAnimationFrame(() => mountQuikChat());
|
|
92
|
-
}
|
|
130
|
+
let messagesEl: HTMLElement | null = null;
|
|
131
|
+
let stickToBottom = true;
|
|
93
132
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
133
|
+
/** How close (px) to the bottom still counts as "at the bottom". */
|
|
134
|
+
const STICK_THRESHOLD = 48;
|
|
97
135
|
|
|
98
|
-
|
|
136
|
+
function onMessagesScroll(e: Event) {
|
|
137
|
+
const el = e.target as HTMLElement;
|
|
138
|
+
stickToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
139
|
+
}
|
|
99
140
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
if (chatInstance && chatContainerEl === container) {
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
141
|
+
function onMessagesListRef(el: Element | undefined) {
|
|
142
|
+
messagesEl = (el as HTMLElement | undefined) ?? null;
|
|
143
|
+
maintainScroll();
|
|
144
|
+
}
|
|
108
145
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
void handleAssistantSend(msg);
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
messagesArea: { alternating: false },
|
|
116
|
-
showTimestamps: false,
|
|
117
|
-
theme: "quikchat-theme-dark",
|
|
118
|
-
titleArea: { show: false },
|
|
119
|
-
},
|
|
120
|
-
);
|
|
121
|
-
chatContainerEl = container;
|
|
122
|
-
|
|
123
|
-
assistantRenderedCount = 0;
|
|
124
|
-
assistantStreamingMsgId = null;
|
|
125
|
-
assistantStreamedLen = 0;
|
|
126
|
-
replayAssistantMessages();
|
|
127
|
-
watchAssistant();
|
|
128
|
-
if (assistant.chatState.status === "streaming") {
|
|
129
|
-
chatInstance?.inputAreaSetEnabled(false);
|
|
146
|
+
function maintainScroll() {
|
|
147
|
+
if (messagesEl && stickToBottom) {
|
|
148
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
130
149
|
}
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
// ─── OpenAI key settings ─────────────────────────────────────────────────────
|
|
134
153
|
|
|
154
|
+
/** The panel's shared credentials form (draft/model state lives inside the form's closure). */
|
|
155
|
+
const credsForm = createAiCredentialsForm({
|
|
156
|
+
onCancel: () => {
|
|
157
|
+
keyEditing = false;
|
|
158
|
+
},
|
|
159
|
+
onSaved: () => {
|
|
160
|
+
keyEditing = false;
|
|
161
|
+
},
|
|
162
|
+
requestRender: scheduleAiRender,
|
|
163
|
+
});
|
|
164
|
+
|
|
135
165
|
/** Open the key form, pre-filled with the current settings. */
|
|
136
166
|
function startEditApiKey() {
|
|
137
|
-
keyDraft = getOpenAiKey();
|
|
138
|
-
baseUrlDraft = getBaseUrl();
|
|
139
|
-
modelDraft = getModel();
|
|
140
167
|
keyEditing = true;
|
|
141
|
-
|
|
142
|
-
// Auto-fetch available models if not already loaded.
|
|
143
|
-
if (availableModels.length === 0 && !modelsLoading) {
|
|
144
|
-
void fetchModels();
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
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 = [];
|
|
159
|
-
rerenderPanel();
|
|
160
|
-
}
|
|
161
|
-
|
|
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
|
-
}
|
|
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();
|
|
180
|
-
try {
|
|
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
|
-
}
|
|
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;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const resp = await fetch(modelsUrl, { headers });
|
|
198
|
-
if (!resp.ok) {
|
|
199
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
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;
|
|
210
|
-
rerenderPanel();
|
|
211
|
-
}
|
|
168
|
+
credsForm.startEdit();
|
|
212
169
|
}
|
|
213
170
|
|
|
214
171
|
/** The OpenAI (or compatible) key + endpoint settings form, shown as a gate when no key is set. */
|
|
215
172
|
function renderKeyGate() {
|
|
216
|
-
const haveKey = hasOpenAiKey();
|
|
217
173
|
return html`
|
|
218
174
|
<div class="ai-tab-body">
|
|
219
|
-
<div class="ai-status-center"
|
|
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>
|
|
175
|
+
<div class="ai-status-center">${credsForm.render()}</div>
|
|
294
176
|
</div>
|
|
295
177
|
`;
|
|
296
178
|
}
|
|
297
179
|
|
|
298
|
-
// ───
|
|
180
|
+
// ─── Sending ────────────────────────────────────────────────────────────────
|
|
299
181
|
|
|
300
182
|
/** Send a message through the document assistant agent loop. */
|
|
301
183
|
async function handleAssistantSend(text: string) {
|
|
302
184
|
if (!text.trim() || assistant.chatState.status === "streaming") {
|
|
303
185
|
return;
|
|
304
186
|
}
|
|
305
|
-
|
|
187
|
+
// A send always lands in the chat view, pinned to the newest message.
|
|
188
|
+
view = "chat";
|
|
189
|
+
stickToBottom = true;
|
|
190
|
+
scheduleAiRender();
|
|
306
191
|
try {
|
|
307
192
|
await assistant.sendMessage(text);
|
|
308
193
|
} catch {
|
|
309
194
|
// Synchronous failure (e.g. network unreachable) — the DocumentAssistant's
|
|
310
|
-
// Own try/catch calls chatState.setError(), which
|
|
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);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
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");
|
|
340
|
-
}
|
|
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
|
-
}
|
|
350
|
-
|
|
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;
|
|
364
|
-
}
|
|
365
|
-
renderAssistantMessage(m);
|
|
366
|
-
assistantRenderedCount = i + 1;
|
|
195
|
+
// Own try/catch calls chatState.setError(), which the watcher renders.
|
|
367
196
|
}
|
|
368
197
|
}
|
|
369
198
|
|
|
370
199
|
/**
|
|
371
|
-
*
|
|
372
|
-
* the
|
|
200
|
+
* Seed the assistant with a prompt programmatically (e.g. the New Project flow handing off a
|
|
201
|
+
* project brief). Delegates to the same send path as the composer. Safe to call right after the
|
|
202
|
+
* Assistant tab renders — the reactive watcher paints chat-state into the panel whenever it
|
|
203
|
+
* mounts.
|
|
373
204
|
*/
|
|
374
|
-
function
|
|
375
|
-
|
|
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;
|
|
384
|
-
}
|
|
385
|
-
|
|
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;
|
|
418
|
-
}
|
|
419
|
-
|
|
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
|
-
});
|
|
435
|
-
});
|
|
205
|
+
export async function seedAssistantPrompt(text: string): Promise<void> {
|
|
206
|
+
await handleAssistantSend(text);
|
|
436
207
|
}
|
|
437
208
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 };
|
|
452
|
-
}
|
|
453
|
-
} catch {
|
|
454
|
-
/* Not JSON — not a tool result */
|
|
455
|
-
}
|
|
456
|
-
return null;
|
|
209
|
+
// ─── Controls ─────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
function stop() {
|
|
212
|
+
assistant.stop();
|
|
457
213
|
}
|
|
458
214
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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 */
|
|
474
|
-
}
|
|
475
|
-
return `🔧 ${tc.name}${detail}`;
|
|
215
|
+
function newChat() {
|
|
216
|
+
assistant.newChat();
|
|
217
|
+
clearMarkdownCache();
|
|
218
|
+
view = "chat";
|
|
219
|
+
stickToBottom = true;
|
|
220
|
+
scheduleAiRender();
|
|
476
221
|
}
|
|
477
222
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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.";
|
|
489
|
-
}
|
|
490
|
-
if (lower.includes("network error") || lower.includes("fetch")) {
|
|
491
|
-
return "Check that the dev server is running and reachable.";
|
|
492
|
-
}
|
|
493
|
-
if (lower.includes("429") || lower.includes("rate limit")) {
|
|
494
|
-
return "The API rate limit was hit. Wait a moment and try again.";
|
|
495
|
-
}
|
|
496
|
-
if (lower.includes("500") || lower.includes("internal")) {
|
|
497
|
-
return "The upstream API returned a server error. Try again in a moment.";
|
|
498
|
-
}
|
|
499
|
-
return "";
|
|
223
|
+
function openSession(id: string) {
|
|
224
|
+
assistant.openSession(id);
|
|
225
|
+
clearMarkdownCache();
|
|
226
|
+
view = "chat";
|
|
227
|
+
stickToBottom = true;
|
|
228
|
+
scheduleAiRender();
|
|
500
229
|
}
|
|
501
230
|
|
|
502
|
-
|
|
231
|
+
function deleteSession(id: string) {
|
|
232
|
+
assistant.deleteSession(id);
|
|
233
|
+
scheduleAiRender();
|
|
234
|
+
}
|
|
503
235
|
|
|
504
|
-
function
|
|
505
|
-
|
|
236
|
+
function showSessions() {
|
|
237
|
+
view = "sessions";
|
|
238
|
+
scheduleAiRender();
|
|
506
239
|
}
|
|
507
240
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (chatInstance) {
|
|
514
|
-
chatInstance.historyImport([]);
|
|
515
|
-
chatInstance.inputAreaSetEnabled(true);
|
|
241
|
+
/** The open session's title for the chat header (null → "New chat"). */
|
|
242
|
+
function activeSessionTitle(): string | null {
|
|
243
|
+
const id = assistant.activeSessionId();
|
|
244
|
+
if (!id) {
|
|
245
|
+
return null;
|
|
516
246
|
}
|
|
517
|
-
|
|
247
|
+
return assistant.listSessions().find((s) => s.id === id)?.title ?? null;
|
|
518
248
|
}
|
|
519
249
|
|
|
250
|
+
// ─── Composer ───────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
const composer = createComposer({
|
|
253
|
+
isStreaming: () => assistant.chatState.status === "streaming",
|
|
254
|
+
onOpenSettings: startEditApiKey,
|
|
255
|
+
onSend: (text) => {
|
|
256
|
+
void handleAssistantSend(text);
|
|
257
|
+
},
|
|
258
|
+
onStop: stop,
|
|
259
|
+
requestRender: scheduleAiRender,
|
|
260
|
+
});
|
|
261
|
+
|
|
520
262
|
// ─── Template ───────────────────────────────────────────────────────────────
|
|
521
263
|
|
|
522
|
-
/** @returns {
|
|
523
|
-
export function renderAiPanelTemplate() {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
264
|
+
/** @returns {TemplateResult} */
|
|
265
|
+
export function renderAiPanelTemplate(): TemplateResult {
|
|
266
|
+
/* The document assistant authenticates via the AI proxy. Gate the chat
|
|
267
|
+
behind the key form until a key is stored locally OR the proxy reports
|
|
268
|
+
itself configured (managed platforms, env-keyed dev servers). */
|
|
269
|
+
if ((!hasOpenAiKey() && !isProxyConfigured()) || keyEditing) {
|
|
270
|
+
if (!keyEditing) {
|
|
271
|
+
ensureProxyProbe();
|
|
272
|
+
}
|
|
527
273
|
return renderKeyGate();
|
|
528
274
|
}
|
|
529
275
|
|
|
530
|
-
|
|
276
|
+
if (view === "sessions") {
|
|
277
|
+
return html`
|
|
278
|
+
<div class="ai-tab-body">
|
|
279
|
+
${renderSessionsList({
|
|
280
|
+
onDelete: deleteSession,
|
|
281
|
+
onNew: newChat,
|
|
282
|
+
onOpen: openSession,
|
|
283
|
+
sessions: assistant.listSessions(),
|
|
284
|
+
})}
|
|
285
|
+
</div>
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
531
288
|
|
|
289
|
+
const cs = assistant.chatState;
|
|
532
290
|
return html`
|
|
533
291
|
<div class="ai-tab-body">
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
})}
|
|
549
|
-
></div>
|
|
292
|
+
${renderChatHeader({
|
|
293
|
+
onNewChat: newChat,
|
|
294
|
+
onShowSessions: showSessions,
|
|
295
|
+
streaming: cs.status === "streaming",
|
|
296
|
+
title: activeSessionTitle(),
|
|
297
|
+
})}
|
|
298
|
+
${renderMessageList({
|
|
299
|
+
error: cs.error,
|
|
300
|
+
listRef: onMessagesListRef,
|
|
301
|
+
messages: cs.messages,
|
|
302
|
+
onScroll: onMessagesScroll,
|
|
303
|
+
status: cs.status,
|
|
304
|
+
})}
|
|
305
|
+
${composer.render()}
|
|
550
306
|
</div>
|
|
551
307
|
`;
|
|
552
308
|
}
|