@jxsuite/studio 0.33.0 → 0.35.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 +6238 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +30972 -17222
- package/dist/studio.js.map +328 -83
- package/package.json +9 -9
- 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 +5 -1
- 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/page-params.ts +383 -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 +189 -457
- 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/git-panel.ts +16 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +21 -7
- 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 +45 -1
- package/src/services/agent-seed.ts +91 -0
- package/src/services/ai-models.ts +64 -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/document-assistant.ts +98 -38
- package/src/services/import-client.ts +134 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +9 -9
- package/src/services/settings-store.ts +93 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/store.ts +4 -62
- package/src/studio.ts +115 -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 +2 -74
- package/src/types.ts +98 -18
- package/src/ui/ai-credentials-form.ts +205 -0
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +13 -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
|
@@ -2,73 +2,101 @@
|
|
|
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
|
-
setOpenAiKey,
|
|
23
|
-
} from "../services/ai-settings";
|
|
24
|
-
|
|
25
|
-
import type { ChatState } from "@jxsuite/ai/chat-state";
|
|
23
|
+
import { hasOpenAiKey } from "../services/ai-settings";
|
|
24
|
+
import { createAiCredentialsForm } from "../ui/ai-credentials-form";
|
|
25
|
+
import { clearMarkdownCache } from "./ai-chat/chat-markdown";
|
|
26
|
+
import { renderChatHeader, renderMessageList } from "./ai-chat/chat-view";
|
|
27
|
+
import { createComposer } from "./ai-chat/composer";
|
|
28
|
+
import { renderSessionsList } from "./ai-chat/sessions-view";
|
|
29
|
+
|
|
26
30
|
import type { EffectScope } from "@vue/reactivity";
|
|
27
31
|
|
|
28
32
|
// ─── State (module-level, persists across tab switches) ─────────────────────
|
|
29
33
|
|
|
30
34
|
let mounted = false;
|
|
31
35
|
|
|
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). */
|
|
36
|
+
/** Whether the OpenAI key form is showing (gate when no key, or re-edit via the gear). */
|
|
55
37
|
let keyEditing = false;
|
|
56
|
-
let keyDraft = "";
|
|
57
|
-
let baseUrlDraft = "";
|
|
58
|
-
let modelDraft = "";
|
|
59
38
|
|
|
60
|
-
/**
|
|
61
|
-
let
|
|
62
|
-
let modelsLoading = false;
|
|
63
|
-
let modelsError = "";
|
|
39
|
+
/** Which pane the panel shows once the key gate is passed. */
|
|
40
|
+
let view: "chat" | "sessions" = "chat";
|
|
64
41
|
|
|
65
42
|
/** Document AST assistant session — created lazily, persists across tab switches. */
|
|
66
43
|
const assistant = createDocumentAssistant();
|
|
67
44
|
(globalThis as Record<string, unknown>).assistant = assistant;
|
|
68
45
|
let assistantScope: EffectScope | null = null;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
46
|
+
|
|
47
|
+
// ─── Render loop ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** The assistant tab's `.panel-body` container, bound once by the right panel. */
|
|
50
|
+
let hostEl: HTMLElement | null = null;
|
|
51
|
+
let renderQueued = false;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Bind the panel's render host and start the streaming watcher. Called once from the right panel's
|
|
55
|
+
* container setup; replaces the old global render-bridge.
|
|
56
|
+
*
|
|
57
|
+
* @param {HTMLElement} el
|
|
58
|
+
*/
|
|
59
|
+
export function bindAiPanelHost(el: HTMLElement) {
|
|
60
|
+
hostEl = el;
|
|
61
|
+
watchAssistant();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Coalesce a re-render of the whole panel onto the next animation frame. */
|
|
65
|
+
function scheduleAiRender() {
|
|
66
|
+
if (renderQueued || !hostEl) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
renderQueued = true;
|
|
70
|
+
requestAnimationFrame(() => {
|
|
71
|
+
renderQueued = false;
|
|
72
|
+
if (hostEl) {
|
|
73
|
+
litRender(renderAiPanelTemplate(), hostEl);
|
|
74
|
+
maintainScroll();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reactively repaint on chat-state changes. Tracks the message count, the tail message's growth
|
|
81
|
+
* (streaming deltas / tool calls), status, and errors; the actual DOM work happens in the
|
|
82
|
+
* rAF-coalesced render, so token rate never exceeds frame rate.
|
|
83
|
+
*/
|
|
84
|
+
function watchAssistant() {
|
|
85
|
+
assistantScope?.stop();
|
|
86
|
+
assistantScope = effectScope();
|
|
87
|
+
assistantScope.run(() => {
|
|
88
|
+
effect(() => {
|
|
89
|
+
const cs = assistant.chatState;
|
|
90
|
+
void cs.messages.length;
|
|
91
|
+
const last = cs.messages.at(-1);
|
|
92
|
+
void last?.content;
|
|
93
|
+
void last?.toolCalls?.length;
|
|
94
|
+
void cs.status;
|
|
95
|
+
void cs.error;
|
|
96
|
+
scheduleAiRender();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
72
100
|
|
|
73
101
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
74
102
|
|
|
@@ -79,476 +107,180 @@ export function mountAiPanel() {
|
|
|
79
107
|
mounted = true;
|
|
80
108
|
}
|
|
81
109
|
|
|
82
|
-
|
|
83
|
-
__jxRightPanelRender?: { render: () => void };
|
|
84
|
-
};
|
|
110
|
+
// ─── Auto-scroll (stick to bottom unless the user scrolled up) ──────────────
|
|
85
111
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (render) {
|
|
89
|
-
render();
|
|
90
|
-
}
|
|
91
|
-
requestAnimationFrame(() => mountQuikChat());
|
|
92
|
-
}
|
|
112
|
+
let messagesEl: HTMLElement | null = null;
|
|
113
|
+
let stickToBottom = true;
|
|
93
114
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
115
|
+
/** How close (px) to the bottom still counts as "at the bottom". */
|
|
116
|
+
const STICK_THRESHOLD = 48;
|
|
97
117
|
|
|
98
|
-
|
|
118
|
+
function onMessagesScroll(e: Event) {
|
|
119
|
+
const el = e.target as HTMLElement;
|
|
120
|
+
stickToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
121
|
+
}
|
|
99
122
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
if (chatInstance && chatContainerEl === container) {
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
123
|
+
function onMessagesListRef(el: Element | undefined) {
|
|
124
|
+
messagesEl = (el as HTMLElement | undefined) ?? null;
|
|
125
|
+
maintainScroll();
|
|
126
|
+
}
|
|
108
127
|
|
|
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);
|
|
128
|
+
function maintainScroll() {
|
|
129
|
+
if (messagesEl && stickToBottom) {
|
|
130
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
// ─── OpenAI key settings ─────────────────────────────────────────────────────
|
|
134
135
|
|
|
136
|
+
/** The panel's shared credentials form (draft/model state lives inside the form's closure). */
|
|
137
|
+
const credsForm = createAiCredentialsForm({
|
|
138
|
+
onCancel: () => {
|
|
139
|
+
keyEditing = false;
|
|
140
|
+
},
|
|
141
|
+
onSaved: () => {
|
|
142
|
+
keyEditing = false;
|
|
143
|
+
},
|
|
144
|
+
requestRender: scheduleAiRender,
|
|
145
|
+
});
|
|
146
|
+
|
|
135
147
|
/** Open the key form, pre-filled with the current settings. */
|
|
136
148
|
function startEditApiKey() {
|
|
137
|
-
keyDraft = getOpenAiKey();
|
|
138
|
-
baseUrlDraft = getBaseUrl();
|
|
139
|
-
modelDraft = getModel();
|
|
140
149
|
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
|
-
}
|
|
150
|
+
credsForm.startEdit();
|
|
212
151
|
}
|
|
213
152
|
|
|
214
153
|
/** The OpenAI (or compatible) key + endpoint settings form, shown as a gate when no key is set. */
|
|
215
154
|
function renderKeyGate() {
|
|
216
|
-
const haveKey = hasOpenAiKey();
|
|
217
155
|
return html`
|
|
218
156
|
<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(--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>
|
|
157
|
+
<div class="ai-status-center">${credsForm.render()}</div>
|
|
296
158
|
</div>
|
|
297
159
|
`;
|
|
298
160
|
}
|
|
299
161
|
|
|
300
|
-
// ───
|
|
162
|
+
// ─── Sending ────────────────────────────────────────────────────────────────
|
|
301
163
|
|
|
302
164
|
/** Send a message through the document assistant agent loop. */
|
|
303
165
|
async function handleAssistantSend(text: string) {
|
|
304
166
|
if (!text.trim() || assistant.chatState.status === "streaming") {
|
|
305
167
|
return;
|
|
306
168
|
}
|
|
307
|
-
|
|
169
|
+
// A send always lands in the chat view, pinned to the newest message.
|
|
170
|
+
view = "chat";
|
|
171
|
+
stickToBottom = true;
|
|
172
|
+
scheduleAiRender();
|
|
308
173
|
try {
|
|
309
174
|
await assistant.sendMessage(text);
|
|
310
175
|
} catch {
|
|
311
176
|
// Synchronous failure (e.g. network unreachable) — the DocumentAssistant's
|
|
312
|
-
// Own try/catch calls chatState.setError(), which
|
|
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);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
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");
|
|
342
|
-
}
|
|
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
|
-
}
|
|
352
|
-
|
|
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;
|
|
366
|
-
}
|
|
367
|
-
renderAssistantMessage(m);
|
|
368
|
-
assistantRenderedCount = i + 1;
|
|
177
|
+
// Own try/catch calls chatState.setError(), which the watcher renders.
|
|
369
178
|
}
|
|
370
179
|
}
|
|
371
180
|
|
|
372
181
|
/**
|
|
373
|
-
*
|
|
374
|
-
* the
|
|
182
|
+
* Seed the assistant with a prompt programmatically (e.g. the New Project flow handing off a
|
|
183
|
+
* project brief). Delegates to the same send path as the composer. Safe to call right after the
|
|
184
|
+
* Assistant tab renders — the reactive watcher paints chat-state into the panel whenever it
|
|
185
|
+
* mounts.
|
|
375
186
|
*/
|
|
376
|
-
function
|
|
377
|
-
|
|
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;
|
|
386
|
-
}
|
|
387
|
-
|
|
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;
|
|
420
|
-
}
|
|
421
|
-
|
|
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
|
-
});
|
|
437
|
-
});
|
|
187
|
+
export async function seedAssistantPrompt(text: string): Promise<void> {
|
|
188
|
+
await handleAssistantSend(text);
|
|
438
189
|
}
|
|
439
190
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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 };
|
|
454
|
-
}
|
|
455
|
-
} catch {
|
|
456
|
-
/* Not JSON — not a tool result */
|
|
457
|
-
}
|
|
458
|
-
return null;
|
|
191
|
+
// ─── Controls ─────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function stop() {
|
|
194
|
+
assistant.stop();
|
|
459
195
|
}
|
|
460
196
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 */
|
|
476
|
-
}
|
|
477
|
-
return `🔧 ${tc.name}${detail}`;
|
|
197
|
+
function newChat() {
|
|
198
|
+
assistant.newChat();
|
|
199
|
+
clearMarkdownCache();
|
|
200
|
+
view = "chat";
|
|
201
|
+
stickToBottom = true;
|
|
202
|
+
scheduleAiRender();
|
|
478
203
|
}
|
|
479
204
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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.";
|
|
491
|
-
}
|
|
492
|
-
if (lower.includes("network error") || lower.includes("fetch")) {
|
|
493
|
-
return "Check that the dev server is running and reachable.";
|
|
494
|
-
}
|
|
495
|
-
if (lower.includes("429") || lower.includes("rate limit")) {
|
|
496
|
-
return "The API rate limit was hit. Wait a moment and try again.";
|
|
497
|
-
}
|
|
498
|
-
if (lower.includes("500") || lower.includes("internal")) {
|
|
499
|
-
return "The upstream API returned a server error. Try again in a moment.";
|
|
500
|
-
}
|
|
501
|
-
return "";
|
|
205
|
+
function openSession(id: string) {
|
|
206
|
+
assistant.openSession(id);
|
|
207
|
+
clearMarkdownCache();
|
|
208
|
+
view = "chat";
|
|
209
|
+
stickToBottom = true;
|
|
210
|
+
scheduleAiRender();
|
|
502
211
|
}
|
|
503
212
|
|
|
504
|
-
|
|
213
|
+
function deleteSession(id: string) {
|
|
214
|
+
assistant.deleteSession(id);
|
|
215
|
+
scheduleAiRender();
|
|
216
|
+
}
|
|
505
217
|
|
|
506
|
-
function
|
|
507
|
-
|
|
218
|
+
function showSessions() {
|
|
219
|
+
view = "sessions";
|
|
220
|
+
scheduleAiRender();
|
|
508
221
|
}
|
|
509
222
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (chatInstance) {
|
|
516
|
-
chatInstance.historyImport([]);
|
|
517
|
-
chatInstance.inputAreaSetEnabled(true);
|
|
223
|
+
/** The open session's title for the chat header (null → "New chat"). */
|
|
224
|
+
function activeSessionTitle(): string | null {
|
|
225
|
+
const id = assistant.activeSessionId();
|
|
226
|
+
if (!id) {
|
|
227
|
+
return null;
|
|
518
228
|
}
|
|
519
|
-
|
|
229
|
+
return assistant.listSessions().find((s) => s.id === id)?.title ?? null;
|
|
520
230
|
}
|
|
521
231
|
|
|
232
|
+
// ─── Composer ───────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
const composer = createComposer({
|
|
235
|
+
isStreaming: () => assistant.chatState.status === "streaming",
|
|
236
|
+
onOpenSettings: startEditApiKey,
|
|
237
|
+
onSend: (text) => {
|
|
238
|
+
void handleAssistantSend(text);
|
|
239
|
+
},
|
|
240
|
+
onStop: stop,
|
|
241
|
+
requestRender: scheduleAiRender,
|
|
242
|
+
});
|
|
243
|
+
|
|
522
244
|
// ─── Template ───────────────────────────────────────────────────────────────
|
|
523
245
|
|
|
524
|
-
/** @returns {
|
|
525
|
-
export function renderAiPanelTemplate() {
|
|
246
|
+
/** @returns {TemplateResult} */
|
|
247
|
+
export function renderAiPanelTemplate(): TemplateResult {
|
|
526
248
|
// The document assistant authenticates via the AI proxy (an OpenAI-compatible key). Gate the chat
|
|
527
249
|
// Behind the key form until one is stored locally.
|
|
528
250
|
if (!hasOpenAiKey() || keyEditing) {
|
|
529
251
|
return renderKeyGate();
|
|
530
252
|
}
|
|
531
253
|
|
|
532
|
-
|
|
254
|
+
if (view === "sessions") {
|
|
255
|
+
return html`
|
|
256
|
+
<div class="ai-tab-body">
|
|
257
|
+
${renderSessionsList({
|
|
258
|
+
onDelete: deleteSession,
|
|
259
|
+
onNew: newChat,
|
|
260
|
+
onOpen: openSession,
|
|
261
|
+
sessions: assistant.listSessions(),
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
533
266
|
|
|
267
|
+
const cs = assistant.chatState;
|
|
534
268
|
return html`
|
|
535
269
|
<div class="ai-tab-body">
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
})}
|
|
551
|
-
></div>
|
|
270
|
+
${renderChatHeader({
|
|
271
|
+
onNewChat: newChat,
|
|
272
|
+
onShowSessions: showSessions,
|
|
273
|
+
streaming: cs.status === "streaming",
|
|
274
|
+
title: activeSessionTitle(),
|
|
275
|
+
})}
|
|
276
|
+
${renderMessageList({
|
|
277
|
+
error: cs.error,
|
|
278
|
+
listRef: onMessagesListRef,
|
|
279
|
+
messages: cs.messages,
|
|
280
|
+
onScroll: onMessagesScroll,
|
|
281
|
+
status: cs.status,
|
|
282
|
+
})}
|
|
283
|
+
${composer.render()}
|
|
552
284
|
</div>
|
|
553
285
|
`;
|
|
554
286
|
}
|