@ai-setting/roy-plugin-task-show 2.4.2 → 2.5.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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @fileoverview Home chat panel — client controller.
3
+ *
4
+ * v2.5.0+ REQ-2: lives on the index page (top of body). Hooks the
5
+ * following DOM contract (pinned by `test/home-chat-v25.test.ts`):
6
+ *
7
+ * [data-chat-shell] <section> wrapper
8
+ * [data-chat-messages] <div> message log (role="log")
9
+ * [data-chat-form] <form> submit handler
10
+ * [data-chat-input] <input> text input
11
+ * [data-chat-send] <button> send button (type="submit")
12
+ *
13
+ * sessionId is persisted in `localStorage["taskShow.chat.sessionId"]`
14
+ * across page loads so the conversation survives reloads. The first
15
+ * run (no stored id) POSTs `/api/chat/start` to allocate one.
16
+ *
17
+ * The server speaks SSE for streaming chunks. We use `fetch` + an
18
+ * `ReadableStream` reader instead of `EventSource` because the chat
19
+ * endpoint is POST (EventSource can't POST) and we need to send the
20
+ * `message` in the request body.
21
+ *
22
+ * If the chat feature is disabled server-side (503), the panel hides
23
+ * itself with a banner instead of throwing.
24
+ */
25
+
26
+ (function () {
27
+ const STORAGE_KEY = "taskShow.chat.sessionId";
28
+ const SHELL_SEL = "[data-chat-shell]";
29
+ const MESSAGES_SEL = "[data-chat-messages]";
30
+ const FORM_SEL = "[data-chat-form]";
31
+ const INPUT_SEL = "[data-chat-input]";
32
+ const SEND_SEL = "[data-chat-send]";
33
+
34
+ function $(sel) { return document.querySelector(sel); }
35
+
36
+ function readSessionId() {
37
+ try { return window.localStorage.getItem(STORAGE_KEY) || ""; }
38
+ catch { return ""; }
39
+ }
40
+
41
+ function writeSessionId(id) {
42
+ try { window.localStorage.setItem(STORAGE_KEY, id); } catch { /* ignore */ }
43
+ }
44
+
45
+ function clearMessages(container) {
46
+ while (container.firstChild) container.removeChild(container.firstChild);
47
+ }
48
+
49
+ function appendMessage(container, role, text) {
50
+ const empty = container.querySelector(".chat-empty");
51
+ if (empty) empty.remove();
52
+ const el = document.createElement("div");
53
+ el.className = "chat-message chat-message--" + role;
54
+ el.dataset.role = role;
55
+ const label = role === "user" ? "You" : role === "assistant" ? "Assistant" : "Error";
56
+ el.innerHTML =
57
+ `<div class="chat-message-meta">${label}</div>` +
58
+ `<div class="chat-message-text"></div>`;
59
+ el.querySelector(".chat-message-text").textContent = text;
60
+ container.appendChild(el);
61
+ container.scrollTop = container.scrollHeight;
62
+ return el;
63
+ }
64
+
65
+ /**
66
+ * Append a streaming chunk to the last assistant message. If no
67
+ * assistant message exists yet (first chunk of a turn), create one.
68
+ */
69
+ function appendChunk(container, text) {
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");
73
+ textEl.textContent += text;
74
+ container.scrollTop = container.scrollHeight;
75
+ }
76
+
77
+ function setBusy(form, busy) {
78
+ const input = form.querySelector(INPUT_SEL);
79
+ const send = form.querySelector(SEND_SEL);
80
+ if (input) input.disabled = busy;
81
+ if (send) {
82
+ send.disabled = busy;
83
+ send.textContent = busy ? "Sending…" : "Send";
84
+ }
85
+ }
86
+
87
+ function showDisabledBanner(shell, msg) {
88
+ const banner = document.createElement("p");
89
+ banner.className = "chat-banner";
90
+ banner.dataset.role = "banner";
91
+ banner.textContent = msg;
92
+ shell.appendChild(banner);
93
+ const form = shell.querySelector(FORM_SEL);
94
+ if (form) form.remove();
95
+ }
96
+
97
+ async function ensureSessionId() {
98
+ let id = readSessionId();
99
+ if (id) return id;
100
+ const res = await fetch("/api/chat/start", {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: "{}",
104
+ });
105
+ if (!res.ok) throw new Error("chat_start_failed: " + res.status);
106
+ const j = await res.json();
107
+ id = j.sessionId;
108
+ writeSessionId(id);
109
+ return id;
110
+ }
111
+
112
+ async function sendMessage(form, container, message) {
113
+ let sessionId;
114
+ try {
115
+ sessionId = await ensureSessionId();
116
+ } catch (e) {
117
+ appendMessage(container, "error", String(e && e.message || e));
118
+ return;
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
+ }
138
+
139
+ if (!res.ok || !res.body) {
140
+ let detail = "";
141
+ try { detail = await res.text(); } catch { /* ignore */ }
142
+ appendMessage(container, "error", `server_error: ${res.status} ${detail.slice(0, 200)}`);
143
+ setBusy(form, false);
144
+ return;
145
+ }
146
+
147
+ const reader = res.body.getReader();
148
+ const decoder = new TextDecoder("utf-8");
149
+ let buf = "";
150
+ let sawError = false;
151
+
152
+ try {
153
+ while (true) {
154
+ const { value, done } = await reader.read();
155
+ if (done) break;
156
+ buf += decoder.decode(value, { stream: true });
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
+ }
163
+ }
164
+ // Flush trailing frame if any.
165
+ if (buf.trim().length > 0) handleFrame(buf, container, () => {});
166
+ } catch (e) {
167
+ appendMessage(container, "error", "stream_error: " + (e && e.message || e));
168
+ } finally {
169
+ setBusy(form, false);
170
+ // Suppress the final empty-message that may have been left if the
171
+ // assistant produced no text. We intentionally leave error messages
172
+ // alone so the user can see what went wrong.
173
+ if (!sawError) {
174
+ const last = container.querySelector(".chat-message--assistant:last-of-type");
175
+ if (last && !last.querySelector(".chat-message-text").textContent.trim()) {
176
+ last.remove();
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Parse one SSE frame (event:/data:/id: lines). Returns the parsed frame or null. */
183
+ function handleFrame(raw, container, onError) {
184
+ let event = "";
185
+ let data = "";
186
+ let id = "";
187
+ for (const line of raw.split(/\r?\n/)) {
188
+ if (line.startsWith(":")) continue;
189
+ if (line.startsWith("event:")) event = line.slice(6).trim();
190
+ else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
191
+ else if (line.startsWith("id:")) id = line.slice(3).trim();
192
+ }
193
+ if (!event && !data) return null;
194
+ let parsed = null;
195
+ if (data) {
196
+ try { parsed = JSON.parse(data); } catch { parsed = data; }
197
+ }
198
+ if (event === "chunk" && parsed && typeof parsed === "object") {
199
+ if (parsed.type === "text" && typeof parsed.text === "string") {
200
+ appendChunk(container, parsed.text);
201
+ } else if (parsed.type === "reasoning" && typeof parsed.text === "string") {
202
+ // Reasoning is hidden in the chat UI by design; we still log it
203
+ // so a developer can inspect via DevTools.
204
+ if (window.console && window.console.debug) {
205
+ window.console.debug("[chat] reasoning:", parsed.text);
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");
212
+ }
213
+ } else if (event === "done") {
214
+ // Terminal marker — nothing to render.
215
+ } else if (event === "error") {
216
+ onError(parsed);
217
+ const msg = parsed && parsed.error && parsed.error.message
218
+ ? parsed.error.message
219
+ : "stream error";
220
+ appendMessage(container, "error", msg);
221
+ } else if (event === "ready") {
222
+ // SSE handshake — nothing to render.
223
+ }
224
+ return { event, data, id, parsed };
225
+ }
226
+
227
+ function init() {
228
+ const shell = document.querySelector(SHELL_SEL);
229
+ if (!shell) return;
230
+ const form = shell.querySelector(FORM_SEL);
231
+ const container = shell.querySelector(MESSAGES_SEL);
232
+ const input = shell.querySelector(INPUT_SEL);
233
+ if (!form || !container || !input) return;
234
+
235
+ // Probe feature availability with a HEAD request. If 503, hide UI.
236
+ fetch("/api/chat/start", { method: "HEAD" }).then((res) => {
237
+ if (res.status === 503) {
238
+ showDisabledBanner(shell, "Chat disabled (server returned 503 — chatOptions not configured).");
239
+ }
240
+ }).catch(() => { /* offline — leave UI alone */ });
241
+
242
+ form.addEventListener("submit", (e) => {
243
+ e.preventDefault();
244
+ const message = (input.value || "").trim();
245
+ if (!message) return;
246
+ input.value = "";
247
+ sendMessage(form, container, message);
248
+ });
249
+ }
250
+
251
+ if (document.readyState === "loading") {
252
+ document.addEventListener("DOMContentLoaded", init);
253
+ } else {
254
+ init();
255
+ }
256
+
257
+ // Expose a tiny test hook so headless harness can drive the panel
258
+ // without firing real events.
259
+ window.taskShowChatPanel = { init, sendMessage, STORAGE_KEY };
260
+ })();
package/public/style.css CHANGED
@@ -1987,3 +1987,144 @@ details summary {
1987
1987
  font-size: 11px;
1988
1988
  color: var(--fg-muted, #6b7280);
1989
1989
  }
1990
+
1991
+ /* ============================================================
1992
+ * v2.5.0+ REQ-2: home-page chat panel
1993
+ * ============================================================ */
1994
+
1995
+ .chat-panel {
1996
+ display: flex;
1997
+ flex-direction: column;
1998
+ gap: 12px;
1999
+ margin-top: 12px;
2000
+ }
2001
+
2002
+ .chat-panel-header h2 {
2003
+ margin: 0 0 4px;
2004
+ font-size: 18px;
2005
+ }
2006
+
2007
+ .chat-panel-meta {
2008
+ margin: 0;
2009
+ font-size: 12px;
2010
+ color: var(--fg-muted, #6b7280);
2011
+ }
2012
+
2013
+ .chat-messages {
2014
+ display: flex;
2015
+ flex-direction: column;
2016
+ gap: 8px;
2017
+ min-height: 80px;
2018
+ max-height: 360px;
2019
+ overflow-y: auto;
2020
+ padding: 12px;
2021
+ border: 1px solid var(--border, #e5e7eb);
2022
+ border-radius: 8px;
2023
+ background: #fff;
2024
+ font-size: 14px;
2025
+ }
2026
+
2027
+ .chat-empty {
2028
+ margin: 0;
2029
+ color: var(--fg-muted, #9ca3af);
2030
+ font-style: italic;
2031
+ }
2032
+
2033
+ .chat-message {
2034
+ padding: 8px 10px;
2035
+ border-radius: 6px;
2036
+ border: 1px solid transparent;
2037
+ }
2038
+
2039
+ .chat-message-meta {
2040
+ font-size: 11px;
2041
+ color: var(--fg-muted, #6b7280);
2042
+ margin-bottom: 2px;
2043
+ }
2044
+
2045
+ .chat-message-text {
2046
+ white-space: pre-wrap;
2047
+ word-break: break-word;
2048
+ line-height: 1.4;
2049
+ }
2050
+
2051
+ .chat-message--user {
2052
+ align-self: flex-end;
2053
+ background: #dbeafe;
2054
+ border-color: #93c5fd;
2055
+ max-width: 80%;
2056
+ }
2057
+
2058
+ .chat-message--assistant {
2059
+ align-self: flex-start;
2060
+ background: #f3f4f6;
2061
+ border-color: #d1d5db;
2062
+ max-width: 80%;
2063
+ }
2064
+
2065
+ .chat-message--error {
2066
+ align-self: stretch;
2067
+ background: #fee2e2;
2068
+ border-color: #fca5a5;
2069
+ color: #991b1b;
2070
+ }
2071
+
2072
+ .chat-form {
2073
+ display: flex;
2074
+ gap: 8px;
2075
+ }
2076
+
2077
+ .chat-input {
2078
+ flex: 1 1 auto;
2079
+ padding: 8px 10px;
2080
+ border: 1px solid var(--border, #d1d5db);
2081
+ border-radius: 6px;
2082
+ font-size: 14px;
2083
+ font-family: inherit;
2084
+ }
2085
+
2086
+ .chat-input:focus {
2087
+ outline: 2px solid #3b82f6;
2088
+ outline-offset: -1px;
2089
+ border-color: #3b82f6;
2090
+ }
2091
+
2092
+ .chat-send {
2093
+ padding: 8px 16px;
2094
+ background: #2563eb;
2095
+ color: #fff;
2096
+ border: 1px solid #1d4ed8;
2097
+ border-radius: 6px;
2098
+ cursor: pointer;
2099
+ font-weight: 500;
2100
+ }
2101
+
2102
+ .chat-send:hover:not(:disabled) {
2103
+ background: #1d4ed8;
2104
+ }
2105
+
2106
+ .chat-send:disabled {
2107
+ opacity: 0.6;
2108
+ cursor: not-allowed;
2109
+ }
2110
+
2111
+ .chat-banner {
2112
+ padding: 8px 12px;
2113
+ margin: 0;
2114
+ background: #fef3c7;
2115
+ border: 1px solid #fcd34d;
2116
+ border-radius: 6px;
2117
+ color: #78350f;
2118
+ font-size: 13px;
2119
+ }
2120
+
2121
+ .visually-hidden {
2122
+ position: absolute;
2123
+ width: 1px;
2124
+ height: 1px;
2125
+ padding: 0;
2126
+ margin: -1px;
2127
+ overflow: hidden;
2128
+ clip: rect(0, 0, 0, 0);
2129
+ border: 0;
2130
+ }