@ai-setting/roy-plugin-task-show 2.5.0 → 2.5.1
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/chat-panel-html.d.ts +76 -0
- package/dist/chat-panel-html.d.ts.map +1 -0
- package/dist/chat-panel-html.js +117 -0
- package/dist/chat-panel-html.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +223 -43
- package/dist/server.js.map +1 -1
- package/dist/task-chat-context-builder.d.ts +94 -0
- package/dist/task-chat-context-builder.d.ts.map +1 -0
- package/dist/task-chat-context-builder.js +130 -0
- package/dist/task-chat-context-builder.js.map +1 -0
- package/dist/task-session-store.d.ts.map +1 -1
- package/dist/task-session-store.js +5 -0
- package/dist/task-session-store.js.map +1 -1
- package/package.json +4 -2
- package/public/chat-panel.js +502 -197
- package/public/dompurify.min.js +3 -0
- package/public/index.html +11 -0
- package/public/markdown-renderer.js +176 -0
- package/public/marked.min.js +6 -0
- package/public/style.css +175 -0
package/public/chat-panel.js
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview
|
|
2
|
+
* @fileoverview Chat panel — client controller.
|
|
3
3
|
*
|
|
4
|
-
* v2.5.0+ REQ-2
|
|
5
|
-
*
|
|
4
|
+
* v2.5.0+ REQ-2 (home) + REQ-3 (task-aware) + REQ-4 (markdown).
|
|
5
|
+
*
|
|
6
|
+
* The module exposes a single `attachChatPanel(root, opts)` factory so
|
|
7
|
+
* the same JS file can drive both the home chat panel and the
|
|
8
|
+
* task-detail chat panel. The legacy `init()` entry-point is preserved
|
|
9
|
+
* for backward compat with any page that wires the home chat via
|
|
10
|
+
* `<script src="chat-panel.js">` alone (no opts needed).
|
|
11
|
+
*
|
|
12
|
+
* DOM contract (pinned by `test/home-chat-v25.test.ts` and
|
|
13
|
+
* `test/task-chat-v25.test.ts`):
|
|
6
14
|
*
|
|
7
15
|
* [data-chat-shell] <section> wrapper
|
|
16
|
+
* [data-chat-scope] "home" | "task" (read by factory)
|
|
17
|
+
* [data-task-id] task id (when scope=task) (read by factory)
|
|
8
18
|
* [data-chat-messages] <div> message log (role="log")
|
|
9
19
|
* [data-chat-form] <form> submit handler
|
|
10
20
|
* [data-chat-input] <input> text input
|
|
11
21
|
* [data-chat-send] <button> send button (type="submit")
|
|
12
22
|
*
|
|
13
|
-
* sessionId is persisted in
|
|
14
|
-
*
|
|
15
|
-
*
|
|
23
|
+
* sessionId is persisted in localStorage under a scope-aware key:
|
|
24
|
+
* - scope=home: `taskShow.chat.sessionId` (one-off uuid)
|
|
25
|
+
* - scope=task: `taskShow.chat.task.${taskId}.sid` (long-lived, per task)
|
|
26
|
+
* The task-detail key intentionally differs from the home key so the
|
|
27
|
+
* two scopes don't clobber each other.
|
|
16
28
|
*
|
|
17
29
|
* The server speaks SSE for streaming chunks. We use `fetch` + an
|
|
18
30
|
* `ReadableStream` reader instead of `EventSource` because the chat
|
|
@@ -21,240 +33,533 @@
|
|
|
21
33
|
*
|
|
22
34
|
* If the chat feature is disabled server-side (503), the panel hides
|
|
23
35
|
* itself with a banner instead of throwing.
|
|
36
|
+
*
|
|
37
|
+
* v2.5.x REQ-4: assistant messages are rendered through
|
|
38
|
+
* `markdownRenderer.renderMarkdown()` (vendored `marked` +
|
|
39
|
+
* `DOMPurify`). User messages deliberately stay `textContent` —
|
|
40
|
+
* users must not be able to inject markdown / HTML into their own
|
|
41
|
+
* bubbles.
|
|
24
42
|
*/
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Module dual-mode
|
|
46
|
+
//
|
|
47
|
+
// - In the browser this file is loaded as a classic <script>. The
|
|
48
|
+
// trailing bootstrap calls `init()` and the panel becomes live.
|
|
49
|
+
// - In tests (bun:test) it is imported as an ES module; the bootstrap
|
|
50
|
+
// branch is skipped (no `document`/`window` globals), and the named
|
|
51
|
+
// exports below are what the harness drives.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const HOME_STORAGE_KEY = "taskShow.chat.sessionId";
|
|
55
|
+
const SHELL_SEL = "[data-chat-shell]";
|
|
56
|
+
const MESSAGES_SEL = "[data-chat-messages]";
|
|
57
|
+
const FORM_SEL = "[data-chat-form]";
|
|
58
|
+
const INPUT_SEL = "[data-chat-input]";
|
|
59
|
+
const SEND_SEL = "[data-chat-send]";
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Portable DOM access. happy-dom tests give us a Window via
|
|
63
|
+
// `container.ownerDocument`; the browser gives us a global `document`.
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function getDoc(container) {
|
|
67
|
+
if (container && container.ownerDocument) return container.ownerDocument;
|
|
68
|
+
if (typeof document !== "undefined") return document;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getWindow(doc) {
|
|
73
|
+
if (doc && doc.defaultView) return doc.defaultView;
|
|
74
|
+
if (typeof window !== "undefined") return window;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function $(container, sel) {
|
|
79
|
+
const doc = getDoc(container);
|
|
80
|
+
if (doc) return doc.querySelector(sel);
|
|
81
|
+
// Fallback — should never happen for the production code path.
|
|
82
|
+
return typeof document !== "undefined" ? document.querySelector(sel) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Markdown renderer resolution. In production the browser script tag
|
|
87
|
+
// order is:
|
|
88
|
+
// marked.min.js → dompurify.min.js → markdown-renderer.js → chat-panel.js
|
|
89
|
+
// so by the time chat-panel.js runs, `window.markdownRenderer` is set.
|
|
90
|
+
//
|
|
91
|
+
// If a test harness imported chat-panel.js without loading
|
|
92
|
+
// markdown-renderer.js first, we lazily require it (Node-only path).
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function resolveRenderer() {
|
|
96
|
+
if (typeof window !== "undefined" && window.markdownRenderer) {
|
|
97
|
+
return window.markdownRenderer;
|
|
98
|
+
}
|
|
99
|
+
if (typeof globalThis !== "undefined" && globalThis.markdownRenderer) {
|
|
100
|
+
return globalThis.markdownRenderer;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
104
|
+
const mod = require("./markdown-renderer.js");
|
|
105
|
+
return { renderMarkdown: mod.renderMarkdown, attachStreamingRenderer: mod.attachStreamingRenderer };
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Scope-aware storage key helpers (REQ-3). The home key is the legacy
|
|
113
|
+
// single global; the task key is per-task-id so different tasks don't
|
|
114
|
+
// clobber each other across reloads.
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function storageKey(scope, taskId) {
|
|
118
|
+
if (scope === "task" && Number.isInteger(taskId)) {
|
|
119
|
+
return `taskShow.chat.task.${taskId}.sid`;
|
|
120
|
+
}
|
|
121
|
+
return HOME_STORAGE_KEY;
|
|
122
|
+
}
|
|
33
123
|
|
|
34
|
-
|
|
124
|
+
function readScopedSessionId(scope, taskId) {
|
|
125
|
+
try {
|
|
126
|
+
const w = typeof window !== "undefined" ? window : null;
|
|
127
|
+
if (!w || !w.localStorage) return "";
|
|
128
|
+
return w.localStorage.getItem(storageKey(scope, taskId)) || "";
|
|
129
|
+
} catch {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
35
133
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
134
|
+
function writeScopedSessionId(scope, taskId, id) {
|
|
135
|
+
try {
|
|
136
|
+
const w = typeof window !== "undefined" ? window : null;
|
|
137
|
+
if (!w || !w.localStorage) return;
|
|
138
|
+
w.localStorage.setItem(storageKey(scope, taskId), id);
|
|
139
|
+
} catch {
|
|
140
|
+
/* ignore */
|
|
39
141
|
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Persistence helpers (legacy single-key API; REQ-2 home path uses
|
|
146
|
+
// these directly). REQ-3 task scope goes through readScopedSessionId /
|
|
147
|
+
// writeScopedSessionId above. Guarded try/catch so headless test envs
|
|
148
|
+
// without localStorage don't blow up.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
40
150
|
|
|
41
|
-
|
|
42
|
-
|
|
151
|
+
export function readSessionId(win) {
|
|
152
|
+
try {
|
|
153
|
+
const w = win || (typeof window !== "undefined" ? window : null);
|
|
154
|
+
if (!w || !w.localStorage) return "";
|
|
155
|
+
return w.localStorage.getItem(HOME_STORAGE_KEY) || "";
|
|
156
|
+
} catch {
|
|
157
|
+
return "";
|
|
43
158
|
}
|
|
159
|
+
}
|
|
44
160
|
|
|
45
|
-
|
|
46
|
-
|
|
161
|
+
export function writeSessionId(id, win) {
|
|
162
|
+
try {
|
|
163
|
+
const w = win || (typeof window !== "undefined" ? window : null);
|
|
164
|
+
if (!w || !w.localStorage) return;
|
|
165
|
+
w.localStorage.setItem(HOME_STORAGE_KEY, id);
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore */
|
|
47
168
|
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// DOM mutation helpers
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export function clearMessages(container) {
|
|
176
|
+
while (container.firstChild) container.removeChild(container.firstChild);
|
|
177
|
+
}
|
|
48
178
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Append a message bubble to the chat log.
|
|
181
|
+
*
|
|
182
|
+
* - `role === "assistant"` → text is rendered via
|
|
183
|
+
* `markdownRenderer.renderMarkdown()` (marked + DOMPurify).
|
|
184
|
+
* - `role === "user"` → text stays `textContent`. Users must not
|
|
185
|
+
* be able to inject HTML / markdown into their own bubble.
|
|
186
|
+
* - `role === "error"` → also `textContent` — error text should
|
|
187
|
+
* be readable as-is, no formatting.
|
|
188
|
+
*
|
|
189
|
+
* Returns the created element so the test harness can inspect it.
|
|
190
|
+
*/
|
|
191
|
+
export function appendMessage(container, role, text) {
|
|
192
|
+
const doc = getDoc(container);
|
|
193
|
+
if (!doc) throw new Error("chat-panel.appendMessage: no document available");
|
|
194
|
+
const empty = container.querySelector(".chat-empty");
|
|
195
|
+
if (empty) empty.remove();
|
|
196
|
+
const el = doc.createElement("div");
|
|
197
|
+
el.className = "chat-message chat-message--" + role;
|
|
198
|
+
el.dataset.role = role;
|
|
199
|
+
const label = role === "user" ? "You" : role === "assistant" ? "Assistant" : "Error";
|
|
200
|
+
el.innerHTML =
|
|
201
|
+
`<div class="chat-message-meta">${label}</div>` +
|
|
202
|
+
`<div class="chat-message-text"></div>`;
|
|
203
|
+
const textEl = el.querySelector(".chat-message-text");
|
|
204
|
+
if (role === "assistant") {
|
|
205
|
+
const renderer = resolveRenderer();
|
|
206
|
+
if (renderer) {
|
|
207
|
+
textEl.innerHTML = renderer.renderMarkdown(text);
|
|
208
|
+
} else {
|
|
209
|
+
// Defensive fallback — should never hit in production.
|
|
210
|
+
textEl.textContent = text;
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
// user / error — textContent literal.
|
|
214
|
+
textEl.textContent = text;
|
|
215
|
+
}
|
|
216
|
+
container.appendChild(el);
|
|
217
|
+
if (container.scrollHeight != null) {
|
|
61
218
|
container.scrollTop = container.scrollHeight;
|
|
62
|
-
|
|
219
|
+
}
|
|
220
|
+
return el;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Streaming chunk → debounced markdown render. The first chunk of a
|
|
225
|
+
* turn creates the assistant bubble lazily; subsequent chunks feed
|
|
226
|
+
* the bubble's streaming renderer.
|
|
227
|
+
*
|
|
228
|
+
* Returns the controller (with `flush` / `dispose`) so the caller
|
|
229
|
+
* can force a final render on `event: done`.
|
|
230
|
+
*/
|
|
231
|
+
export function appendChunk(container, text) {
|
|
232
|
+
const win = getWindow(getDoc(container));
|
|
233
|
+
let last = container.querySelector(".chat-message--assistant:last-of-type");
|
|
234
|
+
if (!last) last = appendMessage(container, "assistant", "");
|
|
235
|
+
const textEl = last.querySelector(".chat-message-text");
|
|
236
|
+
|
|
237
|
+
// Cache the streaming controller on the element so successive
|
|
238
|
+
// chunks reuse the same buffer + timer.
|
|
239
|
+
let ctrl = last.__markdownStreamCtrl;
|
|
240
|
+
const renderer = resolveRenderer();
|
|
241
|
+
if (!ctrl && renderer) {
|
|
242
|
+
ctrl = renderer.attachStreamingRenderer(textEl, { debounceMs: 50 });
|
|
243
|
+
last.__markdownStreamCtrl = ctrl;
|
|
63
244
|
}
|
|
64
245
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
let last = container.querySelector(".chat-message--assistant:last-of-type");
|
|
71
|
-
if (!last) last = appendMessage(container, "assistant", "");
|
|
72
|
-
const textEl = last.querySelector(".chat-message-text");
|
|
246
|
+
if (ctrl) {
|
|
247
|
+
ctrl.append(text);
|
|
248
|
+
} else {
|
|
249
|
+
// No renderer available — fall back to textContent (textContent
|
|
250
|
+
// of a chunk is the safest path when DOMPurify isn't loaded).
|
|
73
251
|
textEl.textContent += text;
|
|
252
|
+
}
|
|
253
|
+
if (win && container.scrollHeight != null) {
|
|
74
254
|
container.scrollTop = container.scrollHeight;
|
|
75
255
|
}
|
|
256
|
+
}
|
|
76
257
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Form / network helpers (kept outside any IIFE so they can be reached
|
|
260
|
+
// in the browser; tests don't exercise these).
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
function setBusy(form, busy) {
|
|
264
|
+
const input = form.querySelector(INPUT_SEL);
|
|
265
|
+
const send = form.querySelector(SEND_SEL);
|
|
266
|
+
if (input) input.disabled = busy;
|
|
267
|
+
if (send) {
|
|
268
|
+
send.disabled = busy;
|
|
269
|
+
send.textContent = busy ? "Sending…" : "Send";
|
|
85
270
|
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function showDisabledBanner(shell, msg) {
|
|
274
|
+
const doc = getDoc(shell);
|
|
275
|
+
if (!doc) return;
|
|
276
|
+
const banner = doc.createElement("p");
|
|
277
|
+
banner.className = "chat-banner";
|
|
278
|
+
banner.dataset.role = "banner";
|
|
279
|
+
banner.textContent = msg;
|
|
280
|
+
shell.appendChild(banner);
|
|
281
|
+
const form = shell.querySelector(FORM_SEL);
|
|
282
|
+
if (form) form.remove();
|
|
283
|
+
}
|
|
86
284
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Resolve a fresh session id for the current scope.
|
|
287
|
+
* - scope=home: POST /api/chat/start → { sessionId }
|
|
288
|
+
* - scope=task: deterministic `task-${taskId}`; we don't need to
|
|
289
|
+
* round-trip the server because the task-detail endpoint keys
|
|
290
|
+
* off the taskId directly. This guarantees multi-turn
|
|
291
|
+
* conversations survive reloads even when the CLI never saw a
|
|
292
|
+
* /start call.
|
|
293
|
+
*/
|
|
294
|
+
async function ensureSessionId(scope, taskId) {
|
|
295
|
+
if (scope === "task" && Number.isInteger(taskId)) {
|
|
296
|
+
return `task-${taskId}`;
|
|
95
297
|
}
|
|
298
|
+
const cached = readScopedSessionId(scope, taskId);
|
|
299
|
+
if (cached) return cached;
|
|
300
|
+
const res = await fetch("/api/chat/start", {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { "Content-Type": "application/json" },
|
|
303
|
+
body: "{}",
|
|
304
|
+
});
|
|
305
|
+
if (!res.ok) throw new Error("chat_start_failed: " + res.status);
|
|
306
|
+
const j = await res.json();
|
|
307
|
+
writeScopedSessionId(scope, taskId, j.sessionId);
|
|
308
|
+
return j.sessionId;
|
|
309
|
+
}
|
|
96
310
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Compute the message-endpoint URL for the current scope.
|
|
313
|
+
* - scope=home: /api/chat/<sessionId>/message
|
|
314
|
+
* - scope=task: /api/task-chat/<taskId>/message (server derives
|
|
315
|
+
* sessionId from taskId, so we never need it on the client side)
|
|
316
|
+
*
|
|
317
|
+
* If `opts.endpoint` is supplied, it's used as the base prefix
|
|
318
|
+
* (mostly for tests / proxies); the resource sub-path is still
|
|
319
|
+
* computed from scope + (taskId|sessionId).
|
|
320
|
+
*/
|
|
321
|
+
function endpointFor(opts, sessionId) {
|
|
322
|
+
const base = (opts && opts.endpoint) || "";
|
|
323
|
+
const trimBase = base.replace(/\/+$/, "");
|
|
324
|
+
if (opts.scope === "task" && Number.isInteger(opts.taskId)) {
|
|
325
|
+
const url = `/api/task-chat/${opts.taskId}/message`;
|
|
326
|
+
return trimBase ? `${trimBase}${url}` : url;
|
|
327
|
+
}
|
|
328
|
+
const url = `/api/chat/${encodeURIComponent(sessionId)}/message`;
|
|
329
|
+
return trimBase ? `${trimBase}${url}` : url;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Public hook for the test harness — only used by the browser init
|
|
334
|
+
* flow in tests that simulate a full submit. Kept as a named export
|
|
335
|
+
* so home-chat-v25's fake ACT test can call into the same code path.
|
|
336
|
+
*
|
|
337
|
+
* Legacy signature: sendMessage(form, container, message) — used by
|
|
338
|
+
* `init()` for the home scope. REQ-3 goes through attachChatPanel()
|
|
339
|
+
* instead, which calls into streamSession() below.
|
|
340
|
+
*/
|
|
341
|
+
export async function sendMessage(form, container, message) {
|
|
342
|
+
return streamSession({ scope: "home", endpoint: "" }, form, container, message);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Stream one user message to the server, render chunks back into the
|
|
347
|
+
* container. Shared by both legacy `sendMessage` (home) and the
|
|
348
|
+
* REQ-3 `attachChatPanel` (home + task).
|
|
349
|
+
*/
|
|
350
|
+
async function streamSession(opts, form, container, message) {
|
|
351
|
+
let sessionId;
|
|
352
|
+
try {
|
|
353
|
+
sessionId = await ensureSessionId(opts.scope, opts.taskId);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
appendMessage(container, "error", String(e && e.message || e));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
appendMessage(container, "user", message);
|
|
359
|
+
setBusy(form, true);
|
|
360
|
+
|
|
361
|
+
const url = endpointFor(opts, sessionId);
|
|
362
|
+
let res;
|
|
363
|
+
try {
|
|
364
|
+
res = await fetch(url, {
|
|
101
365
|
method: "POST",
|
|
102
|
-
headers: {
|
|
103
|
-
|
|
366
|
+
headers: {
|
|
367
|
+
"Content-Type": "application/json",
|
|
368
|
+
Accept: "text/event-stream",
|
|
369
|
+
},
|
|
370
|
+
body: JSON.stringify({ message }),
|
|
104
371
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return id;
|
|
372
|
+
} catch (e) {
|
|
373
|
+
appendMessage(container, "error", "network_error: " + (e && e.message || e));
|
|
374
|
+
setBusy(form, false);
|
|
375
|
+
return;
|
|
110
376
|
}
|
|
111
377
|
|
|
112
|
-
|
|
113
|
-
let
|
|
114
|
-
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
appendMessage(container, "user", message);
|
|
121
|
-
setBusy(form, true);
|
|
122
|
-
|
|
123
|
-
let res;
|
|
124
|
-
try {
|
|
125
|
-
res = await fetch(`/api/chat/${encodeURIComponent(sessionId)}/message`, {
|
|
126
|
-
method: "POST",
|
|
127
|
-
headers: {
|
|
128
|
-
"Content-Type": "application/json",
|
|
129
|
-
Accept: "text/event-stream",
|
|
130
|
-
},
|
|
131
|
-
body: JSON.stringify({ message }),
|
|
132
|
-
});
|
|
133
|
-
} catch (e) {
|
|
134
|
-
appendMessage(container, "error", "network_error: " + (e && e.message || e));
|
|
135
|
-
setBusy(form, false);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
378
|
+
if (!res.ok || !res.body) {
|
|
379
|
+
let detail = "";
|
|
380
|
+
try { detail = await res.text(); } catch { /* ignore */ }
|
|
381
|
+
appendMessage(container, "error", `server_error: ${res.status} ${detail.slice(0, 200)}`);
|
|
382
|
+
setBusy(form, false);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
138
385
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
setBusy(form, false);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
386
|
+
const reader = res.body.getReader();
|
|
387
|
+
const decoder = new TextDecoder("utf-8");
|
|
388
|
+
let buf = "";
|
|
389
|
+
let sawError = false;
|
|
146
390
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
while (
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
let nl;
|
|
158
|
-
while ((nl = buf.indexOf("\n\n")) >= 0) {
|
|
159
|
-
const frame = buf.slice(0, nl);
|
|
160
|
-
buf = buf.slice(nl + 2);
|
|
161
|
-
handleFrame(frame, container, (err) => { sawError = sawError || !!err; });
|
|
162
|
-
}
|
|
391
|
+
try {
|
|
392
|
+
while (true) {
|
|
393
|
+
const { value, done } = await reader.read();
|
|
394
|
+
if (done) break;
|
|
395
|
+
buf += decoder.decode(value, { stream: true });
|
|
396
|
+
let nl;
|
|
397
|
+
while ((nl = buf.indexOf("\n\n")) >= 0) {
|
|
398
|
+
const frame = buf.slice(0, nl);
|
|
399
|
+
buf = buf.slice(nl + 2);
|
|
400
|
+
handleFrame(frame, container, (err) => { sawError = sawError || !!err; });
|
|
163
401
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
402
|
+
}
|
|
403
|
+
// Flush trailing frame if any.
|
|
404
|
+
if (buf.trim().length > 0) handleFrame(buf, container, () => {});
|
|
405
|
+
} catch (e) {
|
|
406
|
+
appendMessage(container, "error", "stream_error: " + (e && e.message || e));
|
|
407
|
+
} finally {
|
|
408
|
+
setBusy(form, false);
|
|
409
|
+
// Final markdown flush so the last debounced chunk renders before
|
|
410
|
+
// the busy spinner goes away.
|
|
411
|
+
const last = container.querySelector(".chat-message--assistant:last-of-type");
|
|
412
|
+
if (last && last.__markdownStreamCtrl && last.__markdownStreamCtrl.flush) {
|
|
413
|
+
last.__markdownStreamCtrl.flush();
|
|
414
|
+
}
|
|
415
|
+
// Suppress the final empty-message that may have been left if the
|
|
416
|
+
// assistant produced no text. We intentionally leave error messages
|
|
417
|
+
// alone so the user can see what went wrong.
|
|
418
|
+
if (!sawError) {
|
|
419
|
+
const last2 = container.querySelector(".chat-message--assistant:last-of-type");
|
|
420
|
+
if (last2 && !last2.querySelector(".chat-message-text").textContent.trim()) {
|
|
421
|
+
last2.remove();
|
|
178
422
|
}
|
|
179
423
|
}
|
|
180
424
|
}
|
|
425
|
+
}
|
|
181
426
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
} else if (parsed.type === "start") {
|
|
208
|
-
// No-op: the assistant bubble is created lazily on first text chunk.
|
|
209
|
-
} else if (parsed.type === "error" && parsed.error) {
|
|
210
|
-
onError(parsed);
|
|
211
|
-
appendMessage(container, "error", parsed.error.message || "unknown error");
|
|
427
|
+
/** Parse one SSE frame (event:/data:/id: lines). Returns the parsed frame or null. */
|
|
428
|
+
function handleFrame(raw, container, onError) {
|
|
429
|
+
let event = "";
|
|
430
|
+
let data = "";
|
|
431
|
+
let id = "";
|
|
432
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
433
|
+
if (line.startsWith(":")) continue;
|
|
434
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
435
|
+
else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
|
|
436
|
+
else if (line.startsWith("id:")) id = line.slice(3).trim();
|
|
437
|
+
}
|
|
438
|
+
if (!event && !data) return null;
|
|
439
|
+
let parsed = null;
|
|
440
|
+
if (data) {
|
|
441
|
+
try { parsed = JSON.parse(data); } catch { parsed = data; }
|
|
442
|
+
}
|
|
443
|
+
if (event === "chunk" && parsed && typeof parsed === "object") {
|
|
444
|
+
if (parsed.type === "text" && typeof parsed.text === "string") {
|
|
445
|
+
appendChunk(container, parsed.text);
|
|
446
|
+
} else if (parsed.type === "reasoning" && typeof parsed.text === "string") {
|
|
447
|
+
// Reasoning is hidden in the chat UI by design; we still log it
|
|
448
|
+
// so a developer can inspect via DevTools.
|
|
449
|
+
if (typeof console !== "undefined" && console.debug) {
|
|
450
|
+
console.debug("[chat] reasoning:", parsed.text);
|
|
212
451
|
}
|
|
213
|
-
} else if (
|
|
214
|
-
//
|
|
215
|
-
} else if (
|
|
452
|
+
} else if (parsed.type === "start") {
|
|
453
|
+
// No-op: the assistant bubble is created lazily on first text chunk.
|
|
454
|
+
} else if (parsed.type === "error" && parsed.error) {
|
|
216
455
|
onError(parsed);
|
|
217
|
-
|
|
218
|
-
? parsed.error.message
|
|
219
|
-
: "stream error";
|
|
220
|
-
appendMessage(container, "error", msg);
|
|
221
|
-
} else if (event === "ready") {
|
|
222
|
-
// SSE handshake — nothing to render.
|
|
456
|
+
appendMessage(container, "error", parsed.error.message || "unknown error");
|
|
223
457
|
}
|
|
224
|
-
|
|
458
|
+
} else if (event === "done") {
|
|
459
|
+
// Terminal marker — final flush is handled by the caller's finally.
|
|
460
|
+
} else if (event === "error") {
|
|
461
|
+
onError(parsed);
|
|
462
|
+
const msg = parsed && parsed.error && parsed.error.message
|
|
463
|
+
? parsed.error.message
|
|
464
|
+
: "stream error";
|
|
465
|
+
appendMessage(container, "error", msg);
|
|
466
|
+
} else if (event === "ready") {
|
|
467
|
+
// SSE handshake — nothing to render.
|
|
225
468
|
}
|
|
469
|
+
return { event, data, id, parsed };
|
|
470
|
+
}
|
|
226
471
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
e.preventDefault();
|
|
244
|
-
const message = (input.value || "").trim();
|
|
245
|
-
if (!message) return;
|
|
246
|
-
input.value = "";
|
|
247
|
-
sendMessage(form, container, message);
|
|
248
|
-
});
|
|
472
|
+
/**
|
|
473
|
+
* Resolve the chat options from DOM data-attrs when the caller
|
|
474
|
+
* didn't pass them explicitly. Lets the server emit a single
|
|
475
|
+
* `<section data-chat-scope="task" data-task-id="4242">` and have
|
|
476
|
+
* the JS pick up the right endpoint / storage key automatically.
|
|
477
|
+
*/
|
|
478
|
+
function readOptsFromRoot(root, opts) {
|
|
479
|
+
const merged = Object.assign({}, opts || {});
|
|
480
|
+
if (!merged.scope) {
|
|
481
|
+
const scopeAttr = root.getAttribute && root.getAttribute("data-chat-scope");
|
|
482
|
+
merged.scope = scopeAttr === "task" ? "task" : "home";
|
|
483
|
+
}
|
|
484
|
+
if (merged.scope === "task" && !Number.isInteger(merged.taskId)) {
|
|
485
|
+
const idAttr = root.getAttribute && root.getAttribute("data-task-id");
|
|
486
|
+
const n = Number(idAttr);
|
|
487
|
+
if (Number.isInteger(n)) merged.taskId = n;
|
|
249
488
|
}
|
|
489
|
+
return merged;
|
|
490
|
+
}
|
|
250
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Factory — wire a chat panel inside `root` using the supplied
|
|
494
|
+
* options. Returns the controller handle (for tests / programmatic
|
|
495
|
+
* sends). Backward-compatible with the legacy `init()` entry point
|
|
496
|
+
* which auto-discovers the home shell.
|
|
497
|
+
*
|
|
498
|
+
* @param {HTMLElement} root the [data-chat-shell] element
|
|
499
|
+
* @param {{
|
|
500
|
+
* endpoint?: string; // optional base prefix (e.g. "" or "https://proxy")
|
|
501
|
+
* scope?: "home"|"task"; // which API endpoint to use (default: from DOM data-chat-scope)
|
|
502
|
+
* taskId?: number; // required when scope === "task"
|
|
503
|
+
* }} [opts]
|
|
504
|
+
*/
|
|
505
|
+
export function attachChatPanel(root, opts) {
|
|
506
|
+
if (!root) return null;
|
|
507
|
+
const finalOpts = readOptsFromRoot(root, opts);
|
|
508
|
+
const form = root.querySelector(FORM_SEL);
|
|
509
|
+
const container = root.querySelector(MESSAGES_SEL);
|
|
510
|
+
const input = root.querySelector(INPUT_SEL);
|
|
511
|
+
if (!form || !container || !input) return null;
|
|
512
|
+
|
|
513
|
+
// Probe feature availability with a HEAD request. If 503, hide UI.
|
|
514
|
+
// For task scope we don't have a generic /api/chat/start — but
|
|
515
|
+
// the same server returns 503 on /api/task-chat/0/message when
|
|
516
|
+
// the chat is disabled (any numeric id triggers the chatManager
|
|
517
|
+
// check), so we use that as a feature probe.
|
|
518
|
+
const probeUrl = finalOpts.scope === "task"
|
|
519
|
+
? `/api/task-chat/0/message`
|
|
520
|
+
: "/api/chat/start";
|
|
521
|
+
fetch(probeUrl, { method: "HEAD" }).then((res) => {
|
|
522
|
+
if (res.status === 503) {
|
|
523
|
+
showDisabledBanner(root, "Chat disabled (server returned 503 — chatOptions not configured).");
|
|
524
|
+
}
|
|
525
|
+
}).catch(() => { /* offline — leave UI alone */ });
|
|
526
|
+
|
|
527
|
+
form.addEventListener("submit", (e) => {
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
const message = (input.value || "").trim();
|
|
530
|
+
if (!message) return;
|
|
531
|
+
input.value = "";
|
|
532
|
+
streamSession(finalOpts, form, container, message);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
return { root, opts: finalOpts, send: (msg) => streamSession(finalOpts, form, container, msg) };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function init() {
|
|
539
|
+
const shell = $(null, SHELL_SEL);
|
|
540
|
+
if (!shell) return null;
|
|
541
|
+
return attachChatPanel(shell, {});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// Browser-only bootstrap. Tests run this file under bun:test where
|
|
546
|
+
// `document`/`window` are not globals, so the if-branch is skipped and
|
|
547
|
+
// only the named exports are available.
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
if (typeof document !== "undefined" && typeof window !== "undefined") {
|
|
251
551
|
if (document.readyState === "loading") {
|
|
252
552
|
document.addEventListener("DOMContentLoaded", init);
|
|
253
553
|
} else {
|
|
254
554
|
init();
|
|
255
555
|
}
|
|
256
|
-
|
|
257
556
|
// Expose a tiny test hook so headless harness can drive the panel
|
|
258
557
|
// without firing real events.
|
|
259
|
-
window.taskShowChatPanel = {
|
|
260
|
-
|
|
558
|
+
window.taskShowChatPanel = {
|
|
559
|
+
init,
|
|
560
|
+
attachChatPanel,
|
|
561
|
+
sendMessage,
|
|
562
|
+
storageKey,
|
|
563
|
+
HOME_STORAGE_KEY,
|
|
564
|
+
};
|
|
565
|
+
}
|