@orangeworks/orangetree 0.4.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/CHANGELOG.md +44 -0
- package/LICENSE +8 -0
- package/README.md +55 -0
- package/dist/bin/orangetree.js +89 -0
- package/dist/public/api.js +33 -0
- package/dist/public/app.js +650 -0
- package/dist/public/canvas.js +362 -0
- package/dist/public/chat.js +890 -0
- package/dist/public/connection.js +102 -0
- package/dist/public/dirpicker.js +93 -0
- package/dist/public/explorer.js +299 -0
- package/dist/public/guidelines.js +35 -0
- package/dist/public/i18n.js +959 -0
- package/dist/public/index.html +75 -0
- package/dist/public/insight.js +40 -0
- package/dist/public/logo.svg +5 -0
- package/dist/public/md.js +103 -0
- package/dist/public/pairing.js +114 -0
- package/dist/public/projectSettings.js +150 -0
- package/dist/public/slash.js +95 -0
- package/dist/public/style.css +649 -0
- package/dist/public/ui.js +372 -0
- package/dist/public/viewer.html +156 -0
- package/dist/server.js +18573 -0
- package/package.json +45 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
import { esc, md } from "./md.js";
|
|
2
|
+
import { toast } from "./api.js";
|
|
3
|
+
import { copyText } from "./ui.js";
|
|
4
|
+
import { t, t as tr, currentLocale } from "./i18n.js";
|
|
5
|
+
import { nodeOwnDocs, baseName } from "./insight.js";
|
|
6
|
+
import { SlashMenu } from "./slash.js";
|
|
7
|
+
const STATUS_VIEW = {
|
|
8
|
+
preparing: { icon: "\u23F3", labelKey: "chat.st.preparing" },
|
|
9
|
+
in_progress: { icon: "\u{1F343}", labelKey: "chat.st.in_progress" },
|
|
10
|
+
blocked: { icon: "\u{1FAA8}", labelKey: "chat.st.blocked" },
|
|
11
|
+
waiting_handoff: { icon: "\u{1F338}", labelKey: "chat.st.waiting_handoff" },
|
|
12
|
+
review: { icon: "\u{1F33A}", labelKey: "chat.st.review" },
|
|
13
|
+
done: { icon: "\u{1F34A}", labelKey: "chat.st.done" }
|
|
14
|
+
};
|
|
15
|
+
function relTime(ms) {
|
|
16
|
+
const diff = Date.now() - ms;
|
|
17
|
+
if (diff < 6e4) return t("chat.time.now");
|
|
18
|
+
if (diff < 36e5) return t("chat.time.min", { n: Math.floor(diff / 6e4) });
|
|
19
|
+
if (diff < 864e5) return t("chat.time.hour", { n: Math.floor(diff / 36e5) });
|
|
20
|
+
return new Date(ms).toLocaleDateString(currentLocale(), { month: "numeric", day: "numeric" });
|
|
21
|
+
}
|
|
22
|
+
class Chat {
|
|
23
|
+
api;
|
|
24
|
+
onNewConversation;
|
|
25
|
+
onFork;
|
|
26
|
+
onDone;
|
|
27
|
+
onGoto;
|
|
28
|
+
onOpenDoc;
|
|
29
|
+
purposeOpen = false;
|
|
30
|
+
loadSeq = 0;
|
|
31
|
+
// setNode load-epoch guard (out-of-order history resolution)
|
|
32
|
+
node = null;
|
|
33
|
+
turn = null;
|
|
34
|
+
lastAssistant = "";
|
|
35
|
+
// most recent rendered assistant answer text (bug-report prefill)
|
|
36
|
+
busyNodes = /* @__PURE__ */ new Set();
|
|
37
|
+
lastPrompt = "";
|
|
38
|
+
$log;
|
|
39
|
+
$form;
|
|
40
|
+
$input;
|
|
41
|
+
$send;
|
|
42
|
+
$stop;
|
|
43
|
+
$title;
|
|
44
|
+
$meta;
|
|
45
|
+
$purpose;
|
|
46
|
+
$done;
|
|
47
|
+
$busyNotice = null;
|
|
48
|
+
commands = [];
|
|
49
|
+
slash;
|
|
50
|
+
$att;
|
|
51
|
+
$file;
|
|
52
|
+
attachments = [];
|
|
53
|
+
uploads = [];
|
|
54
|
+
// in-flight uploads (awaited on send so none is dropped)
|
|
55
|
+
constructor(pane, { api, onNewConversation, onFork, onDone, onGoto, onOpenDoc }) {
|
|
56
|
+
this.api = api;
|
|
57
|
+
this.onNewConversation = onNewConversation;
|
|
58
|
+
this.onFork = onFork;
|
|
59
|
+
this.onDone = onDone;
|
|
60
|
+
this.onGoto = onGoto;
|
|
61
|
+
this.onOpenDoc = onOpenDoc;
|
|
62
|
+
pane.innerHTML = `
|
|
63
|
+
<div class="pane-title"><span id="chat-title" data-i18n="chat.paneTitle">\uB300\uD654</span></div>
|
|
64
|
+
<div id="chat-meta" hidden></div>
|
|
65
|
+
<div id="chat-purpose" hidden></div>
|
|
66
|
+
<div id="chat-log"><div class="pane-empty" data-i18n="chat.selectNode">\uB178\uB4DC\uB97C \uC120\uD0DD\uD558\uC138\uC694</div></div>
|
|
67
|
+
<form id="chat-form" hidden>
|
|
68
|
+
<div id="chat-att" hidden></div>
|
|
69
|
+
<textarea id="chat-input" rows="2" data-i18n-ph="chat.input.ph" placeholder="\uBA54\uC2DC\uC9C0\u2026 (Enter \uC804\uC1A1, Shift+Enter \uC904\uBC14\uAFC8)"></textarea>
|
|
70
|
+
<div id="chat-bar">
|
|
71
|
+
<button type="button" id="chat-attach" data-i18n-title="chat.attach.titleFull" title="\uD30C\uC77C \uCCA8\uBD80 (\uC774\uBBF8\uC9C0\uB294 \uBD99\uC5EC\uB123\uAE30 \uAC00\uB2A5)">\u{1F4CE}</button>
|
|
72
|
+
<select id="chat-model" data-i18n-title="chat.model.title" title="\uBAA8\uB378 (\uC804\uC5ED \uC124\uC815)"><option value="" data-i18n="chat.model.default">\uBAA8\uB378 \uAE30\uBCF8</option><option value="opus">Opus</option><option value="sonnet">Sonnet</option><option value="haiku">Haiku</option></select>
|
|
73
|
+
<select id="chat-effort" data-i18n-title="chat.effort.title" title="\uB178\uB825\uAC15\uB3C4 (\uC804\uC5ED \uC124\uC815)"><option value="" data-i18n="chat.effort.default">\uB178\uB825 \uAE30\uBCF8</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="xhigh">XHigh</option><option value="max">Max</option></select>
|
|
74
|
+
<select id="chat-perm" data-i18n-title="chat.perm.title" title="\uAD8C\uD55C \uBAA8\uB4DC (\uC804\uC5ED \uC124\uC815)"><option value="bypass" data-i18n="chat.perm.bypass">\uC790\uB3D9 \uD5C8\uC6A9</option><option value="ask" data-i18n="chat.perm.askOpt">\uC2B9\uC778 \uC694\uCCAD</option></select>
|
|
75
|
+
<span class="bar-space"></span>
|
|
76
|
+
<button type="button" id="chat-stop" hidden data-i18n-title="chat.stop.title" title="\uC9C4\uD589 \uC911\uC778 \uD134 \uC911\uB2E8" data-i18n="chat.stopBtn">\u23F9 \uC911\uB2E8</button>
|
|
77
|
+
<button type="button" id="chat-done" hidden data-i18n-title="chat.done.title" title="\uC774 \uB178\uB4DC\uB97C \uC644\uB8CC \uCC98\uB9AC" data-i18n="chat.doneBtn">\u{1F34A} \uC644\uB8CC</button>
|
|
78
|
+
<button type="submit" id="chat-send" data-i18n="chat.send">\uC804\uC1A1</button>
|
|
79
|
+
</div>
|
|
80
|
+
<input type="file" id="chat-file" multiple hidden>
|
|
81
|
+
</form>`;
|
|
82
|
+
this.$log = pane.querySelector("#chat-log");
|
|
83
|
+
this.$form = pane.querySelector("#chat-form");
|
|
84
|
+
this.$input = pane.querySelector("#chat-input");
|
|
85
|
+
this.$send = pane.querySelector("#chat-send");
|
|
86
|
+
this.$stop = pane.querySelector("#chat-stop");
|
|
87
|
+
this.$title = pane.querySelector("#chat-title");
|
|
88
|
+
this.$meta = pane.querySelector("#chat-meta");
|
|
89
|
+
this.$purpose = pane.querySelector("#chat-purpose");
|
|
90
|
+
this.$done = pane.querySelector("#chat-done");
|
|
91
|
+
this.$done.onclick = () => {
|
|
92
|
+
if (this.node) void this.onDone?.(this.node.id);
|
|
93
|
+
};
|
|
94
|
+
this.slash = new SlashMenu(this.$input, { getCommands: () => this.commands });
|
|
95
|
+
this.$form.onsubmit = (e) => {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
void this.send();
|
|
98
|
+
};
|
|
99
|
+
this.$input.onkeydown = (e) => {
|
|
100
|
+
if (this.slash.handleKeydown(e)) return;
|
|
101
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
void this.send();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
this.$input.addEventListener("input", () => {
|
|
107
|
+
this.autosize();
|
|
108
|
+
this.slash.update();
|
|
109
|
+
});
|
|
110
|
+
this.$stop.onclick = async () => {
|
|
111
|
+
if (!this.node) return;
|
|
112
|
+
try {
|
|
113
|
+
await this.api("POST", `/nodes/${this.node.id}/interrupt`);
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
this.$att = pane.querySelector("#chat-att");
|
|
118
|
+
this.$file = pane.querySelector("#chat-file");
|
|
119
|
+
pane.querySelector("#chat-attach").onclick = () => this.$file.click();
|
|
120
|
+
this.$file.onchange = () => {
|
|
121
|
+
for (const f of Array.from(this.$file.files ?? [])) this.uploads.push(this.upload(f));
|
|
122
|
+
this.$file.value = "";
|
|
123
|
+
};
|
|
124
|
+
this.$input.addEventListener("paste", (e) => {
|
|
125
|
+
const files = [...e.clipboardData?.items ?? []].filter((i) => i.kind === "file").map((i) => i.getAsFile()).filter((f) => !!f);
|
|
126
|
+
if (!files.length) return;
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
for (const f of files) this.uploads.push(this.upload(f));
|
|
129
|
+
});
|
|
130
|
+
const patchSetting = (key) => (e) => {
|
|
131
|
+
void this.api("PATCH", "/settings", { [key]: e.target.value });
|
|
132
|
+
};
|
|
133
|
+
pane.querySelector("#chat-model").onchange = patchSetting("model");
|
|
134
|
+
pane.querySelector("#chat-effort").onchange = patchSetting("effort");
|
|
135
|
+
pane.querySelector("#chat-perm").onchange = patchSetting("permission");
|
|
136
|
+
void this.setNode(null);
|
|
137
|
+
}
|
|
138
|
+
async setNode(node) {
|
|
139
|
+
this.node = node;
|
|
140
|
+
this.turn = null;
|
|
141
|
+
this.purposeOpen = false;
|
|
142
|
+
this.clearAttachments();
|
|
143
|
+
if (!node) {
|
|
144
|
+
this.$title.textContent = t("chat.newConversation");
|
|
145
|
+
this.$form.hidden = false;
|
|
146
|
+
this.$log.innerHTML = `<div class="pane-empty">${esc(t("chat.empty.title"))}<br>${esc(t("chat.empty.sub"))}</div>`;
|
|
147
|
+
this.updateButtons();
|
|
148
|
+
this.renderMeta();
|
|
149
|
+
this.renderPurpose(null);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.$title.textContent = `${node.id} \xB7 ${node.title}`;
|
|
153
|
+
this.$form.hidden = false;
|
|
154
|
+
this.updateButtons();
|
|
155
|
+
this.renderMeta();
|
|
156
|
+
this.renderPurpose(node);
|
|
157
|
+
const seq = ++this.loadSeq;
|
|
158
|
+
this.$log.innerHTML = `<div class="pane-empty">${esc(t("chat.historyLoading"))}</div>`;
|
|
159
|
+
try {
|
|
160
|
+
const history = await this.api("GET", `/nodes/${node.id}/history`);
|
|
161
|
+
if (seq !== this.loadSeq) return;
|
|
162
|
+
const liveCards = [...this.$log.querySelectorAll(".fork-card, .perm-card, .return-card, .notice-line")];
|
|
163
|
+
this.$log.replaceChildren();
|
|
164
|
+
if (!history.length) this.$log.innerHTML = `<div class="pane-empty">${esc(t("chat.firstMsg"))}</div>`;
|
|
165
|
+
else this.renderHistory(history);
|
|
166
|
+
this.renderNotices(node);
|
|
167
|
+
this.renderConflict(node);
|
|
168
|
+
for (const c of liveCards) this.$log.append(c);
|
|
169
|
+
if (this.busyNodes.has(node.id) && !this.turn) this.renderBusyNotice();
|
|
170
|
+
this.scroll();
|
|
171
|
+
} catch {
|
|
172
|
+
if (seq === this.loadSeq) this.$log.innerHTML = `<div class="pane-empty">${esc(t("chat.historyFail"))}</div>`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// SSE tree refresh: keep the live node reference current (title etc.) without disturbing the log.
|
|
176
|
+
updateNode(node) {
|
|
177
|
+
if (!node || !this.node || node.id !== this.node.id) return;
|
|
178
|
+
const enteredConflict = node.mergeState === "conflict" && this.node.mergeState !== "conflict";
|
|
179
|
+
this.node = node;
|
|
180
|
+
this.$title.textContent = `${node.id} \xB7 ${node.title}`;
|
|
181
|
+
this.renderMeta();
|
|
182
|
+
this.renderPurpose(node);
|
|
183
|
+
if (enteredConflict && !this.$log.querySelector(".conflict-card")) this.renderConflict(node);
|
|
184
|
+
}
|
|
185
|
+
// History render: group by turn — user bubble, then assistant run (proc chevron + final answer).
|
|
186
|
+
renderHistory(history) {
|
|
187
|
+
let i = 0;
|
|
188
|
+
while (i < history.length) {
|
|
189
|
+
if (history[i].role === "user") {
|
|
190
|
+
this.bubble("user", history[i].text);
|
|
191
|
+
i++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const run = [];
|
|
195
|
+
while (i < history.length && history[i].role === "assistant") {
|
|
196
|
+
run.push(history[i]);
|
|
197
|
+
i++;
|
|
198
|
+
}
|
|
199
|
+
this.renderAssistantRun(run);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
renderAssistantRun(run) {
|
|
203
|
+
const parts = [];
|
|
204
|
+
for (const m of run) {
|
|
205
|
+
const th = (m.thinking || "").trim();
|
|
206
|
+
if (th) parts.push({ type: "thinking", text: th });
|
|
207
|
+
const tx = (m.text || "").trim();
|
|
208
|
+
if (tx) parts.push({ type: "text", text: tx });
|
|
209
|
+
for (const name of m.tools ?? []) parts.push({ type: "tool", name });
|
|
210
|
+
}
|
|
211
|
+
let lastTextIdx = -1;
|
|
212
|
+
parts.forEach((p, idx) => {
|
|
213
|
+
if (p.type === "text" && p.text.trim()) lastTextIdx = idx;
|
|
214
|
+
});
|
|
215
|
+
const procParts = parts.filter((_, idx) => idx !== lastTextIdx);
|
|
216
|
+
const answerPart = lastTextIdx >= 0 ? parts[lastTextIdx] : null;
|
|
217
|
+
const answer = answerPart && answerPart.type === "text" ? answerPart.text : "";
|
|
218
|
+
const el = document.createElement("div");
|
|
219
|
+
el.className = "turn";
|
|
220
|
+
if (procParts.length) el.append(this.renderProcSteps(procParts, false));
|
|
221
|
+
if (answer) this.renderAnswerSegments(answer, el);
|
|
222
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
223
|
+
this.$log.append(el);
|
|
224
|
+
}
|
|
225
|
+
// Strip <fork-proposal> blocks (and an unterminated mid-stream opener) from raw delta text.
|
|
226
|
+
stripProposal(text) {
|
|
227
|
+
return text.replace(/<fork-proposal>[\s\S]*?<\/fork-proposal>/g, "").replace(/<fork-proposal>[\s\S]*$/, "").trim();
|
|
228
|
+
}
|
|
229
|
+
// Split a final answer at md section boundaries only (H2/H3 kept; ---/***/___ dropped). No paragraph split.
|
|
230
|
+
segment(text) {
|
|
231
|
+
const segs = [];
|
|
232
|
+
let cur = [];
|
|
233
|
+
let inCode = false;
|
|
234
|
+
const flush = () => {
|
|
235
|
+
const s = cur.join("\n").trim();
|
|
236
|
+
if (s) segs.push(s);
|
|
237
|
+
cur = [];
|
|
238
|
+
};
|
|
239
|
+
for (const line of String(text).split("\n")) {
|
|
240
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
241
|
+
inCode = !inCode;
|
|
242
|
+
cur.push(line);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (!inCode && /^#{2,3}\s+/.test(line)) {
|
|
246
|
+
flush();
|
|
247
|
+
cur.push(line);
|
|
248
|
+
} else if (!inCode && /^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
249
|
+
flush();
|
|
250
|
+
} else cur.push(line);
|
|
251
|
+
}
|
|
252
|
+
flush();
|
|
253
|
+
return segs.length ? segs : [String(text).trim()];
|
|
254
|
+
}
|
|
255
|
+
// One .msg.assistant per answer segment; each gets a hover ⑂ fork (seeding that segment's text)
|
|
256
|
+
// and a 📋 copy button (copies the segment text, e.g. to seed a bug report).
|
|
257
|
+
renderAnswerSegments(text, container) {
|
|
258
|
+
this.lastAssistant = String(text).trim();
|
|
259
|
+
for (const seg of this.segment(text)) {
|
|
260
|
+
if (!seg) continue;
|
|
261
|
+
const b = document.createElement("div");
|
|
262
|
+
b.className = "msg assistant";
|
|
263
|
+
b.innerHTML = md(seg);
|
|
264
|
+
const c = document.createElement("button");
|
|
265
|
+
c.type = "button";
|
|
266
|
+
c.className = "seg-copy";
|
|
267
|
+
c.title = t("chat.segCopy.title");
|
|
268
|
+
c.textContent = "\u{1F4CB}";
|
|
269
|
+
c.onclick = () => {
|
|
270
|
+
void copyText(seg);
|
|
271
|
+
};
|
|
272
|
+
b.append(c);
|
|
273
|
+
if (this.node && this.onFork) {
|
|
274
|
+
const f = document.createElement("button");
|
|
275
|
+
f.type = "button";
|
|
276
|
+
f.className = "seg-fork";
|
|
277
|
+
f.title = t("chat.segFork.title");
|
|
278
|
+
f.textContent = "\u2442";
|
|
279
|
+
f.onclick = () => {
|
|
280
|
+
void this.onFork(this.node.id, { seedText: seg });
|
|
281
|
+
};
|
|
282
|
+
b.append(f);
|
|
283
|
+
}
|
|
284
|
+
container.append(b);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Most recent rendered assistant answer text (bug-report prefill source). Empty if none. */
|
|
288
|
+
lastAssistantText() {
|
|
289
|
+
return this.lastAssistant;
|
|
290
|
+
}
|
|
291
|
+
// Time-ordered process chevron: thinking | text | tool rows in occurrence order. open=true => live block.
|
|
292
|
+
renderProcSteps(parts, open = false) {
|
|
293
|
+
const det = document.createElement("details");
|
|
294
|
+
det.className = open ? "proc-steps live" : "proc-steps";
|
|
295
|
+
det.open = open;
|
|
296
|
+
const sum = document.createElement("summary");
|
|
297
|
+
sum.textContent = t("chat.procSteps", { n: parts.length });
|
|
298
|
+
const body = document.createElement("div");
|
|
299
|
+
body.className = "proc-body";
|
|
300
|
+
for (const p of parts) {
|
|
301
|
+
const row = document.createElement("div");
|
|
302
|
+
row.className = "proc-step";
|
|
303
|
+
if (p.type === "thinking") {
|
|
304
|
+
row.classList.add("step-think");
|
|
305
|
+
const lines = p.text.split("\n").filter((l) => l.trim());
|
|
306
|
+
const first = lines[0] ?? "";
|
|
307
|
+
if (lines.length > 1) {
|
|
308
|
+
const d = document.createElement("details");
|
|
309
|
+
d.className = "proc-think";
|
|
310
|
+
const s = document.createElement("summary");
|
|
311
|
+
s.textContent = first;
|
|
312
|
+
const full = document.createElement("div");
|
|
313
|
+
full.textContent = p.text;
|
|
314
|
+
d.append(s, full);
|
|
315
|
+
row.append(d);
|
|
316
|
+
} else {
|
|
317
|
+
row.textContent = first;
|
|
318
|
+
}
|
|
319
|
+
} else if (p.type === "text") {
|
|
320
|
+
row.classList.add("step-text");
|
|
321
|
+
row.innerHTML = md(p.text);
|
|
322
|
+
} else {
|
|
323
|
+
row.classList.add("step-tool");
|
|
324
|
+
const arg = (p.detail ?? "").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
325
|
+
const label = `\u{1F527} ${p.name}(${arg})`;
|
|
326
|
+
if (p.detail) {
|
|
327
|
+
const d = document.createElement("details");
|
|
328
|
+
d.className = "proc-tool-detail";
|
|
329
|
+
const s = document.createElement("summary");
|
|
330
|
+
s.textContent = label;
|
|
331
|
+
const full = document.createElement("div");
|
|
332
|
+
full.textContent = p.detail;
|
|
333
|
+
d.append(s, full);
|
|
334
|
+
row.append(d);
|
|
335
|
+
} else {
|
|
336
|
+
row.textContent = label;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
body.append(row);
|
|
340
|
+
}
|
|
341
|
+
det.append(sum, body);
|
|
342
|
+
return det;
|
|
343
|
+
}
|
|
344
|
+
bubble(role, text, tools = []) {
|
|
345
|
+
const div = document.createElement("div");
|
|
346
|
+
div.className = `msg ${role}`;
|
|
347
|
+
div.innerHTML = (text ? md(text) : "") + (tools.length ? `<div class="tools">\u{1F527} ${tools.map(esc).join(", ")}</div>` : "");
|
|
348
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
349
|
+
this.$log.append(div);
|
|
350
|
+
return div;
|
|
351
|
+
}
|
|
352
|
+
scroll() {
|
|
353
|
+
this.$log.scrollTop = this.$log.scrollHeight;
|
|
354
|
+
}
|
|
355
|
+
// Grow the textarea with content (CSS caps it at 15 lines then scrolls).
|
|
356
|
+
autosize() {
|
|
357
|
+
const el = this.$input;
|
|
358
|
+
el.style.height = "auto";
|
|
359
|
+
const max = parseFloat(getComputedStyle(el).maxHeight) || Infinity;
|
|
360
|
+
el.style.height = `${Math.min(el.scrollHeight, max)}px`;
|
|
361
|
+
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
|
|
362
|
+
}
|
|
363
|
+
async send() {
|
|
364
|
+
const text = this.$input.value.trim();
|
|
365
|
+
if (this.uploads.length) await Promise.all(this.uploads.splice(0));
|
|
366
|
+
const atts = this.attachments;
|
|
367
|
+
if (!text && !atts.length) return;
|
|
368
|
+
this.slash.close();
|
|
369
|
+
if (!this.node) {
|
|
370
|
+
const seed = text || (atts.length > 1 ? t("chat.attachSeed", { name: atts[0].name, n: atts.length - 1 }) : atts[0]?.name ?? "");
|
|
371
|
+
const node = await this.onNewConversation(seed);
|
|
372
|
+
if (!node) return;
|
|
373
|
+
await this.setNode(node);
|
|
374
|
+
}
|
|
375
|
+
if (!this.node) return;
|
|
376
|
+
if (this.busyNodes.has(this.node.id)) return;
|
|
377
|
+
let prompt = text;
|
|
378
|
+
if (atts.length) prompt += `${prompt ? "\n\n" : ""}${t("chat.attachPrompt")}
|
|
379
|
+
${atts.map((a) => `- ${a.path}`).join("\n")}`;
|
|
380
|
+
for (const a of atts) if (a.url) URL.revokeObjectURL(a.url);
|
|
381
|
+
this.attachments = [];
|
|
382
|
+
this.renderAttachments();
|
|
383
|
+
this.$input.value = "";
|
|
384
|
+
this.autosize();
|
|
385
|
+
this.lastPrompt = prompt;
|
|
386
|
+
this.bubble("user", text + (atts.length ? `
|
|
387
|
+
\u{1F4CE} ${atts.map((a) => a.name).join(", ")}` : ""));
|
|
388
|
+
this.openStream();
|
|
389
|
+
try {
|
|
390
|
+
await this.api("POST", `/nodes/${this.node.id}/messages`, { text: prompt });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
this.closeStream(`\u26A0 ${err.message}`);
|
|
393
|
+
}
|
|
394
|
+
this.$input.focus();
|
|
395
|
+
}
|
|
396
|
+
// Upload one file as a raw body to /api/uploads (bypasses api(), which forces JSON), then chip it.
|
|
397
|
+
async upload(file) {
|
|
398
|
+
try {
|
|
399
|
+
const res = await fetch(`/api/uploads?nodeId=${encodeURIComponent(this.node?.id ?? "")}&name=${encodeURIComponent(file.name || "clipboard.png")}`, { method: "POST", body: file });
|
|
400
|
+
const data = await res.json();
|
|
401
|
+
if (!res.ok) throw new Error(data.error || "upload failed");
|
|
402
|
+
const isImage = /^image\//.test(file.type) || /\.(png|jpe?g|gif|webp|svg)$/i.test(data.name);
|
|
403
|
+
this.attachments.push({ path: data.path, name: data.name, size: data.size, isImage, url: isImage ? URL.createObjectURL(file) : null });
|
|
404
|
+
this.renderAttachments();
|
|
405
|
+
} catch (err) {
|
|
406
|
+
toast(t("chat.uploadFail", { msg: err.message }), "error");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Drop all pending attachments + revoke their object URLs (called on send-clear and node switch).
|
|
410
|
+
clearAttachments() {
|
|
411
|
+
for (const a of this.attachments) if (a.url) URL.revokeObjectURL(a.url);
|
|
412
|
+
this.attachments = [];
|
|
413
|
+
if (this.$att) this.renderAttachments();
|
|
414
|
+
}
|
|
415
|
+
renderAttachments() {
|
|
416
|
+
this.$att.replaceChildren();
|
|
417
|
+
this.$att.hidden = !this.attachments.length;
|
|
418
|
+
this.attachments.forEach((a, i) => {
|
|
419
|
+
const chip = document.createElement("div");
|
|
420
|
+
chip.className = "att-chip";
|
|
421
|
+
if (a.isImage && a.url) {
|
|
422
|
+
const img = document.createElement("img");
|
|
423
|
+
img.src = a.url;
|
|
424
|
+
chip.append(img);
|
|
425
|
+
}
|
|
426
|
+
const name = document.createElement("span");
|
|
427
|
+
name.className = "att-name";
|
|
428
|
+
name.textContent = a.name;
|
|
429
|
+
const x = document.createElement("button");
|
|
430
|
+
x.type = "button";
|
|
431
|
+
x.className = "att-x";
|
|
432
|
+
x.textContent = "\u2715";
|
|
433
|
+
x.onclick = () => {
|
|
434
|
+
if (a.url) URL.revokeObjectURL(a.url);
|
|
435
|
+
this.attachments.splice(i, 1);
|
|
436
|
+
this.renderAttachments();
|
|
437
|
+
};
|
|
438
|
+
chip.append(name, x);
|
|
439
|
+
this.$att.append(chip);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
renderBusyNotice() {
|
|
443
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
444
|
+
this.$busyNotice?.remove();
|
|
445
|
+
const div = document.createElement("div");
|
|
446
|
+
div.className = "busy-notice";
|
|
447
|
+
div.textContent = t("chat.busyNotice");
|
|
448
|
+
this.$log.append(div);
|
|
449
|
+
this.$busyNotice = div;
|
|
450
|
+
}
|
|
451
|
+
openStream() {
|
|
452
|
+
this.$busyNotice?.remove();
|
|
453
|
+
this.$busyNotice = null;
|
|
454
|
+
const el = document.createElement("div");
|
|
455
|
+
el.className = "turn streaming";
|
|
456
|
+
this.turn = { parts: [], live: "", el };
|
|
457
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
458
|
+
this.$log.append(el);
|
|
459
|
+
this.scroll();
|
|
460
|
+
}
|
|
461
|
+
ensureTurn() {
|
|
462
|
+
if (!this.turn) this.openStream();
|
|
463
|
+
}
|
|
464
|
+
closeStream(errText = null) {
|
|
465
|
+
if (!this.turn) return;
|
|
466
|
+
if (errText) {
|
|
467
|
+
this.turn.el.replaceChildren();
|
|
468
|
+
const b = document.createElement("div");
|
|
469
|
+
b.className = "msg assistant";
|
|
470
|
+
b.innerHTML = `<span class="err">${esc(errText)}</span>`;
|
|
471
|
+
this.turn.el.append(b);
|
|
472
|
+
}
|
|
473
|
+
this.turn.el.classList.remove("streaming");
|
|
474
|
+
this.turn = null;
|
|
475
|
+
}
|
|
476
|
+
// Streaming: ONLY an open proc block (parts + transient live). Done: closed proc block + segment bubbles (once).
|
|
477
|
+
renderTurn(final = false) {
|
|
478
|
+
const t2 = this.turn;
|
|
479
|
+
if (!t2) return;
|
|
480
|
+
if (!final) {
|
|
481
|
+
const live = t2.parts.slice();
|
|
482
|
+
const liveText = this.stripProposal(t2.live);
|
|
483
|
+
if (liveText) live.push({ type: "text", text: liveText });
|
|
484
|
+
t2.el.replaceChildren();
|
|
485
|
+
if (live.length) t2.el.append(this.renderProcSteps(live, true));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
let lastTextIdx = -1;
|
|
489
|
+
t2.parts.forEach((p, idx) => {
|
|
490
|
+
if (p.type === "text" && p.text.trim()) lastTextIdx = idx;
|
|
491
|
+
});
|
|
492
|
+
let procParts = t2.parts.slice();
|
|
493
|
+
let answer = "";
|
|
494
|
+
if (lastTextIdx >= 0) {
|
|
495
|
+
const ap = t2.parts[lastTextIdx];
|
|
496
|
+
answer = ap.type === "text" ? ap.text : "";
|
|
497
|
+
procParts = t2.parts.filter((_, idx) => idx !== lastTextIdx);
|
|
498
|
+
} else {
|
|
499
|
+
answer = this.stripProposal(t2.live);
|
|
500
|
+
}
|
|
501
|
+
t2.el.replaceChildren();
|
|
502
|
+
if (procParts.length) t2.el.append(this.renderProcSteps(procParts, false));
|
|
503
|
+
if (answer) this.renderAnswerSegments(answer, t2.el);
|
|
504
|
+
}
|
|
505
|
+
// Empty turn (model finished with no answer text): placeholder + retry button.
|
|
506
|
+
renderEmptyTurn(el, subtype) {
|
|
507
|
+
if (!el.querySelector(".msg.assistant")) {
|
|
508
|
+
const b = document.createElement("div");
|
|
509
|
+
b.className = "msg assistant empty-turn";
|
|
510
|
+
const reason = subtype && subtype !== "success" ? t("chat.emptyTurn.reason", { reason: esc(subtype) }) : "";
|
|
511
|
+
b.innerHTML = `<span class="empty-note">${esc(t("chat.emptyTurn.note"))}${reason}.</span>`;
|
|
512
|
+
el.append(b);
|
|
513
|
+
}
|
|
514
|
+
if (el.querySelector(".empty-retry")) return;
|
|
515
|
+
const bar = document.createElement("div");
|
|
516
|
+
bar.className = "empty-retry";
|
|
517
|
+
const btn = document.createElement("button");
|
|
518
|
+
btn.className = "retry-btn";
|
|
519
|
+
btn.textContent = t("chat.retry");
|
|
520
|
+
btn.onclick = () => void this.retryLast(btn);
|
|
521
|
+
bar.append(btn);
|
|
522
|
+
el.append(bar);
|
|
523
|
+
}
|
|
524
|
+
async retryLast(btn) {
|
|
525
|
+
if (!this.node || !this.lastPrompt) return;
|
|
526
|
+
if (this.busyNodes.has(this.node.id)) return;
|
|
527
|
+
btn.disabled = true;
|
|
528
|
+
this.openStream();
|
|
529
|
+
try {
|
|
530
|
+
await this.api("POST", `/nodes/${this.node.id}/messages`, { text: this.lastPrompt });
|
|
531
|
+
} catch (err) {
|
|
532
|
+
this.closeStream(`\u26A0 ${err.message}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Persisted notices replayed on setNode (no scroll during history replay).
|
|
536
|
+
renderNotices(node) {
|
|
537
|
+
for (const n of node.notices ?? []) this.renderNotice(n, false);
|
|
538
|
+
}
|
|
539
|
+
// A returnsTo detour result (DES-COLLAB-017) renders as a "▶ 이어받아 진행" card until consumed
|
|
540
|
+
// (a parent turn injects it -> delivered) or read (ack); everything else is a plain child-done line.
|
|
541
|
+
renderNotice(notice, scroll = true) {
|
|
542
|
+
if (notice.returns && !notice.delivered && !notice.ack) this.appendReturnCard(notice, scroll);
|
|
543
|
+
else if (notice.kind === "code-change" && !notice.delivered && !notice.ack) this.appendChangeCard(notice, scroll);
|
|
544
|
+
else this.appendNotice(notice, scroll);
|
|
545
|
+
}
|
|
546
|
+
// Overlapping code change from another node (DES-COLLAB-007) — reflect now (turn injects <related-updates>),
|
|
547
|
+
// read (ack), or jump to the source node. Persists as a line once delivered/acked.
|
|
548
|
+
// Merge-conflict resolution card (DES-COLLAB-016) — kicks off an AI turn that merges main into the
|
|
549
|
+
// worktree and resolves the conflicts; the user re-completes the node afterward to finish the merge.
|
|
550
|
+
renderConflict(node) {
|
|
551
|
+
if (node.mergeState !== "conflict") return;
|
|
552
|
+
const card = document.createElement("div");
|
|
553
|
+
card.className = "return-card conflict-card";
|
|
554
|
+
card.innerHTML = `<div class="return-head">${esc(t("chat.conflict.head"))}</div><div class="return-body">${esc(t("chat.conflict.body"))}</div><div class="return-actions"><button type="button" class="return-go">${esc(t("chat.conflict.start"))}</button></div>`;
|
|
555
|
+
const btn = card.querySelector(".return-go");
|
|
556
|
+
btn.onclick = async () => {
|
|
557
|
+
btn.disabled = true;
|
|
558
|
+
btn.textContent = t("chat.conflict.starting");
|
|
559
|
+
try {
|
|
560
|
+
await this.api("POST", `/nodes/${node.id}/resolve-conflict`);
|
|
561
|
+
} catch {
|
|
562
|
+
btn.disabled = false;
|
|
563
|
+
btn.textContent = t("chat.conflict.start");
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
567
|
+
this.$log.append(card);
|
|
568
|
+
}
|
|
569
|
+
appendChangeCard(notice, scroll = true) {
|
|
570
|
+
const card = document.createElement("div");
|
|
571
|
+
card.className = "return-card change-card";
|
|
572
|
+
const from = notice.fromNodeId ?? "";
|
|
573
|
+
const files = (notice.files ?? []).slice(0, 8).join(", ");
|
|
574
|
+
card.innerHTML = `<div class="return-head">${esc(t("chat.changeCard.head"))}</div><div class="return-body"><b class="return-from">${esc(from)}</b> "${esc(notice.fromTitle ?? "")}" \u2014 ${esc(notice.summary ?? "")}${files ? ` <span class="change-files">[${esc(files)}]</span>` : ""}</div><div class="return-actions"><button type="button" class="return-go">${esc(t("chat.reflect"))}</button><button type="button" class="return-later">${esc(t("common.later"))}</button><button type="button" class="return-view">${esc(t("chat.viewChange"))}</button></div>`;
|
|
575
|
+
card.querySelector(".return-go").onclick = () => {
|
|
576
|
+
card.remove();
|
|
577
|
+
this.$input.value = t("chat.reflectPrompt");
|
|
578
|
+
void this.send();
|
|
579
|
+
};
|
|
580
|
+
card.querySelector(".return-later").onclick = () => {
|
|
581
|
+
card.remove();
|
|
582
|
+
if (this.node) void this.api("POST", `/nodes/${this.node.id}/notices/ack`);
|
|
583
|
+
};
|
|
584
|
+
card.querySelector(".return-view").onclick = () => {
|
|
585
|
+
if (from) this.onGoto?.(from);
|
|
586
|
+
};
|
|
587
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
588
|
+
this.$log.append(card);
|
|
589
|
+
if (scroll) this.scroll();
|
|
590
|
+
}
|
|
591
|
+
appendReturnCard(notice, scroll = true) {
|
|
592
|
+
const card = document.createElement("div");
|
|
593
|
+
card.className = "return-card";
|
|
594
|
+
const from = notice.fromNodeId ?? "";
|
|
595
|
+
card.innerHTML = `<div class="return-head">${esc(t("chat.returnCard.head"))}</div><div class="return-body"><b class="return-from">${esc(from)}</b> "${esc(notice.fromTitle ?? "")}" \u2014 ${esc(notice.summary ?? "")}</div><div class="return-actions"><button type="button" class="return-go">${esc(t("chat.resume"))}</button><button type="button" class="return-later">${esc(t("common.later"))}</button></div>`;
|
|
596
|
+
if (from && this.onGoto) {
|
|
597
|
+
const f = card.querySelector(".return-from");
|
|
598
|
+
f.style.cursor = "pointer";
|
|
599
|
+
f.title = t("chat.gotoChild");
|
|
600
|
+
f.onclick = () => this.onGoto?.(f.textContent || from);
|
|
601
|
+
}
|
|
602
|
+
card.querySelector(".return-go").onclick = () => {
|
|
603
|
+
card.remove();
|
|
604
|
+
this.$input.value = t("chat.resumePrompt");
|
|
605
|
+
void this.send();
|
|
606
|
+
};
|
|
607
|
+
card.querySelector(".return-later").onclick = () => {
|
|
608
|
+
card.remove();
|
|
609
|
+
if (this.node) void this.api("POST", `/nodes/${this.node.id}/notices/ack`);
|
|
610
|
+
};
|
|
611
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
612
|
+
this.$log.append(card);
|
|
613
|
+
if (scroll) this.scroll();
|
|
614
|
+
}
|
|
615
|
+
appendNotice(notice, scroll = true) {
|
|
616
|
+
const div = document.createElement("div");
|
|
617
|
+
div.className = "notice-line";
|
|
618
|
+
div.textContent = `\u{1F514} ${notice.text ?? ""}`;
|
|
619
|
+
if (notice.fromNodeId && this.onGoto) {
|
|
620
|
+
const from = notice.fromNodeId;
|
|
621
|
+
div.classList.add("clickable");
|
|
622
|
+
div.title = t("chat.gotoNode");
|
|
623
|
+
div.onclick = () => this.onGoto?.(from);
|
|
624
|
+
}
|
|
625
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
626
|
+
this.$log.append(div);
|
|
627
|
+
if (scroll) this.scroll();
|
|
628
|
+
}
|
|
629
|
+
// Live node-notice SSE (routed from app) — append to the open node's log.
|
|
630
|
+
onNotice(data) {
|
|
631
|
+
if (!this.node || data.nodeId !== this.node.id || !data.notice) return;
|
|
632
|
+
this.renderNotice(data.notice);
|
|
633
|
+
}
|
|
634
|
+
// Fixed header: 🎯 purpose (branchSummary or first request, collapsible) + related-doc chips. Always visible.
|
|
635
|
+
renderPurpose(node) {
|
|
636
|
+
const purpose = node?.branchSummary || node?.activity?.[0]?.request || "";
|
|
637
|
+
const docs = node ? nodeOwnDocs(node) : [];
|
|
638
|
+
if (!purpose && !docs.length) {
|
|
639
|
+
this.$purpose.hidden = true;
|
|
640
|
+
this.$purpose.replaceChildren();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
this.$purpose.hidden = false;
|
|
644
|
+
this.$purpose.replaceChildren();
|
|
645
|
+
if (purpose) {
|
|
646
|
+
const row = document.createElement("div");
|
|
647
|
+
row.className = "purpose-row";
|
|
648
|
+
const toggle = document.createElement("button");
|
|
649
|
+
toggle.type = "button";
|
|
650
|
+
toggle.className = "purpose-toggle";
|
|
651
|
+
toggle.title = t("chat.purpose.toggle");
|
|
652
|
+
const text = document.createElement("div");
|
|
653
|
+
text.className = "purpose-text";
|
|
654
|
+
text.innerHTML = md(purpose);
|
|
655
|
+
const sync = () => {
|
|
656
|
+
text.classList.toggle("open", this.purposeOpen);
|
|
657
|
+
toggle.textContent = this.purposeOpen ? "\u25B4" : "\u25BE";
|
|
658
|
+
};
|
|
659
|
+
sync();
|
|
660
|
+
toggle.onclick = () => {
|
|
661
|
+
this.purposeOpen = !this.purposeOpen;
|
|
662
|
+
sync();
|
|
663
|
+
};
|
|
664
|
+
const label = document.createElement("span");
|
|
665
|
+
label.className = "purpose-label";
|
|
666
|
+
label.textContent = t("chat.purpose.label");
|
|
667
|
+
const head = document.createElement("div");
|
|
668
|
+
head.className = "purpose-head";
|
|
669
|
+
head.append(label, toggle);
|
|
670
|
+
row.append(head, text);
|
|
671
|
+
this.$purpose.append(row);
|
|
672
|
+
}
|
|
673
|
+
if (docs.length) {
|
|
674
|
+
const docRow = document.createElement("div");
|
|
675
|
+
docRow.className = "purpose-docs";
|
|
676
|
+
const label = document.createElement("span");
|
|
677
|
+
label.className = "purpose-docs-label";
|
|
678
|
+
label.textContent = t("chat.relatedDocs");
|
|
679
|
+
docRow.append(label);
|
|
680
|
+
for (const path of docs) {
|
|
681
|
+
const chip = document.createElement("button");
|
|
682
|
+
chip.type = "button";
|
|
683
|
+
chip.className = "purpose-doc";
|
|
684
|
+
chip.title = path;
|
|
685
|
+
chip.textContent = baseName(path);
|
|
686
|
+
chip.onclick = () => this.onOpenDoc?.(path);
|
|
687
|
+
docRow.append(chip);
|
|
688
|
+
}
|
|
689
|
+
this.$purpose.append(docRow);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// Fixed header: status emoji + last-active relative time + a collapsible activity log. Always visible.
|
|
693
|
+
renderMeta() {
|
|
694
|
+
const node = this.node;
|
|
695
|
+
if (!node) {
|
|
696
|
+
this.$meta.hidden = true;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const st = STATUS_VIEW[node.status] ? { icon: STATUS_VIEW[node.status].icon, label: t(STATUS_VIEW[node.status].labelKey) } : { icon: "\xB7", label: node.status };
|
|
700
|
+
const acts = (node.activity ?? []).slice().reverse();
|
|
701
|
+
const last = node.lastActiveAt ? `\xB7 ${t("chat.lastActive", { time: relTime(node.lastActiveAt) })}` : "";
|
|
702
|
+
const rows = acts.map((a) => {
|
|
703
|
+
const sub = a.error ? `<span class="act-err">\u26A0 ${esc(a.error)}</span>` : a.doneAt ? `${t("chat.act.done", { time: relTime(a.doneAt) })}${a.costUsd != null ? ` \xB7 $${a.costUsd.toFixed(4)}` : ""}` : `<span class="act-run">${t("chat.act.running")}</span>`;
|
|
704
|
+
return `<div class="act-row"><div class="act-req">${esc(a.request || t("chat.act.emptyReq"))}</div><div class="act-sub">${sub}</div></div>`;
|
|
705
|
+
}).join("");
|
|
706
|
+
const open = this.$meta.querySelector(".act-log")?.open ? " open" : "";
|
|
707
|
+
this.$meta.hidden = false;
|
|
708
|
+
this.$meta.innerHTML = `
|
|
709
|
+
<div class="meta-head">
|
|
710
|
+
<span class="meta-status">${st.icon} ${esc(st.label)}</span>
|
|
711
|
+
<span class="meta-last">${esc(last)}</span>
|
|
712
|
+
</div>
|
|
713
|
+
${acts.length ? `<details class="act-log"${open}><summary>${t("chat.actLog", { n: acts.length })}</summary><div class="act-list">${rows}</div></details>` : ""}`;
|
|
714
|
+
}
|
|
715
|
+
// SSE fork-proposal — approval card (per-item checkbox + editable title); 'go' POSTs the fork route per pick.
|
|
716
|
+
onForkProposal({ nodeId, context, items }) {
|
|
717
|
+
if (!this.node || nodeId !== this.node.id) return;
|
|
718
|
+
if (!Array.isArray(items) || items.length < 1) return;
|
|
719
|
+
const card = document.createElement("div");
|
|
720
|
+
card.className = "fork-card";
|
|
721
|
+
card.innerHTML = `
|
|
722
|
+
<div class="fork-head">${esc(t("chat.fork.head"))} <span class="fork-n">${esc(t("chat.fork.candidates", { n: items.length }))}</span></div>
|
|
723
|
+
${context ? '<div class="fork-ctx"></div>' : ""}
|
|
724
|
+
<div class="fork-items"></div>
|
|
725
|
+
<div class="fork-actions">
|
|
726
|
+
<button type="button" class="fork-go">${esc(t("chat.fork.go"))}</button>
|
|
727
|
+
<button type="button" class="fork-ignore">${esc(t("chat.fork.ignore"))}</button>
|
|
728
|
+
</div>`;
|
|
729
|
+
if (context) card.querySelector(".fork-ctx").textContent = context;
|
|
730
|
+
const itemsEl = card.querySelector(".fork-items");
|
|
731
|
+
for (const it of items) {
|
|
732
|
+
const row = document.createElement("div");
|
|
733
|
+
row.className = "fork-item";
|
|
734
|
+
row.innerHTML = '<input type="checkbox" class="fork-cb" checked><input type="text" class="fork-title"><div class="fork-sum"></div>';
|
|
735
|
+
row.querySelector(".fork-title").value = it.title;
|
|
736
|
+
row.querySelector(".fork-sum").textContent = it.summary ?? "";
|
|
737
|
+
row.dataset.summary = it.summary ?? "";
|
|
738
|
+
itemsEl.append(row);
|
|
739
|
+
}
|
|
740
|
+
const close = () => card.remove();
|
|
741
|
+
card.querySelector(".fork-ignore").onclick = close;
|
|
742
|
+
card.querySelector(".fork-go").onclick = async () => {
|
|
743
|
+
const picks = [...itemsEl.querySelectorAll(".fork-item")].filter((r) => r.querySelector(".fork-cb").checked).map((r) => ({ title: r.querySelector(".fork-title").value.trim(), summary: r.dataset.summary ?? "" })).filter((p) => p.title);
|
|
744
|
+
if (!picks.length) {
|
|
745
|
+
toast(t("chat.fork.selectPrompt"), "error");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
card.querySelectorAll("button, input").forEach((el) => {
|
|
749
|
+
el.disabled = true;
|
|
750
|
+
});
|
|
751
|
+
let ok = 0;
|
|
752
|
+
for (const p of picks) {
|
|
753
|
+
try {
|
|
754
|
+
await this.api("POST", `/nodes/${nodeId}/fork`, { title: p.title, summary: p.summary });
|
|
755
|
+
ok++;
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
close();
|
|
760
|
+
if (ok) toast(t("chat.fork.done", { n: ok }), "success");
|
|
761
|
+
};
|
|
762
|
+
this.$log.append(card);
|
|
763
|
+
this.scroll();
|
|
764
|
+
}
|
|
765
|
+
// SSE permission-request — ask-mode approval card, keyed by requestId.
|
|
766
|
+
onPermissionRequest({ nodeId, requestId, tool, summary, reason, canAlwaysAllow }) {
|
|
767
|
+
if (!this.node || nodeId !== this.node.id) return;
|
|
768
|
+
const card = document.createElement("div");
|
|
769
|
+
card.className = "perm-card";
|
|
770
|
+
card.dataset.requestId = requestId;
|
|
771
|
+
card.innerHTML = `
|
|
772
|
+
<div class="perm-head">${esc(t("chat.perm.reqHead"))} <b></b></div>
|
|
773
|
+
<pre class="perm-input"></pre>
|
|
774
|
+
${reason ? '<div class="perm-reason"></div>' : ""}
|
|
775
|
+
<div class="perm-actions">
|
|
776
|
+
<button type="button" class="perm-allow">${esc(t("chat.perm.allow"))}</button>
|
|
777
|
+
${canAlwaysAllow ? `<button type="button" class="perm-always">${esc(t("chat.perm.always"))}</button>` : ""}
|
|
778
|
+
<button type="button" class="perm-deny">${esc(t("chat.perm.deny"))}</button>
|
|
779
|
+
</div>`;
|
|
780
|
+
card.querySelector(".perm-head b").textContent = tool;
|
|
781
|
+
card.querySelector(".perm-input").textContent = summary;
|
|
782
|
+
if (reason) card.querySelector(".perm-reason").textContent = reason;
|
|
783
|
+
const respond = async (decision) => {
|
|
784
|
+
card.querySelectorAll("button").forEach((b) => {
|
|
785
|
+
b.disabled = true;
|
|
786
|
+
});
|
|
787
|
+
try {
|
|
788
|
+
await this.api("POST", `/permissions/${requestId}`, { decision });
|
|
789
|
+
} catch {
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
card.querySelector(".perm-allow").onclick = () => void respond("allow");
|
|
793
|
+
const always = card.querySelector(".perm-always");
|
|
794
|
+
if (always) always.onclick = () => void respond("always");
|
|
795
|
+
card.querySelector(".perm-deny").onclick = () => void respond("deny");
|
|
796
|
+
this.$log.append(card);
|
|
797
|
+
this.scroll();
|
|
798
|
+
}
|
|
799
|
+
onPermissionClosed({ requestId }) {
|
|
800
|
+
this.$log.querySelector(`.perm-card[data-request-id="${requestId}"]`)?.remove();
|
|
801
|
+
}
|
|
802
|
+
appendSystem(text) {
|
|
803
|
+
const div = document.createElement("div");
|
|
804
|
+
div.className = "sys-line";
|
|
805
|
+
div.textContent = text;
|
|
806
|
+
this.$log.querySelector(".pane-empty")?.remove();
|
|
807
|
+
this.$log.append(div);
|
|
808
|
+
this.scroll();
|
|
809
|
+
}
|
|
810
|
+
// SSE busy event (routed from app) — per-node working state.
|
|
811
|
+
setBusy(ids) {
|
|
812
|
+
this.busyNodes = new Set(ids);
|
|
813
|
+
if (this.node && !this.busyNodes.has(this.node.id)) {
|
|
814
|
+
this.$busyNotice?.remove();
|
|
815
|
+
this.$busyNotice = null;
|
|
816
|
+
}
|
|
817
|
+
this.updateButtons();
|
|
818
|
+
}
|
|
819
|
+
// Slash-command list (from /api/commands + the 'commands' SSE) — read lazily by the slash menu.
|
|
820
|
+
setCommands(list) {
|
|
821
|
+
this.commands = Array.isArray(list) ? list : [];
|
|
822
|
+
if (this.slash.isOpen) this.slash.update();
|
|
823
|
+
}
|
|
824
|
+
updateButtons() {
|
|
825
|
+
const busy = this.node ? this.busyNodes.has(this.node.id) : false;
|
|
826
|
+
this.$send.disabled = busy;
|
|
827
|
+
this.$send.hidden = busy;
|
|
828
|
+
this.$stop.hidden = !busy;
|
|
829
|
+
this.$done.hidden = !this.node || busy;
|
|
830
|
+
}
|
|
831
|
+
// SSE node-stream event (routed from app). init ignored; rest drives the turn.
|
|
832
|
+
onStream(ev) {
|
|
833
|
+
if (!this.node || ev.nodeId !== this.node.id) return;
|
|
834
|
+
if (ev.kind === "error") {
|
|
835
|
+
this.ensureTurn();
|
|
836
|
+
this.closeStream(`\u26A0 ${ev.text}`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (ev.kind === "done") {
|
|
840
|
+
if (this.turn) {
|
|
841
|
+
if (ev.empty) {
|
|
842
|
+
let lastTextIdx = -1;
|
|
843
|
+
this.turn.parts.forEach((p, idx) => {
|
|
844
|
+
if (p.type === "text" && p.text.trim()) lastTextIdx = idx;
|
|
845
|
+
});
|
|
846
|
+
if (lastTextIdx >= 0) this.turn.parts.splice(lastTextIdx, 1);
|
|
847
|
+
}
|
|
848
|
+
this.renderTurn(true);
|
|
849
|
+
this.turn.el.classList.remove("streaming");
|
|
850
|
+
if (ev.empty) this.renderEmptyTurn(this.turn.el, ev.subtype);
|
|
851
|
+
if (ev.costUsd != null) {
|
|
852
|
+
const cost = document.createElement("div");
|
|
853
|
+
cost.className = "cost";
|
|
854
|
+
cost.textContent = `$${ev.costUsd.toFixed(4)}`;
|
|
855
|
+
const lastMsg = [...this.turn.el.querySelectorAll(".msg.assistant")].pop();
|
|
856
|
+
(lastMsg ?? this.turn.el).append(cost);
|
|
857
|
+
}
|
|
858
|
+
this.turn = null;
|
|
859
|
+
} else if (this.node) {
|
|
860
|
+
void this.setNode(this.node);
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (ev.kind === "system") {
|
|
865
|
+
this.appendSystem(ev.text);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (ev.kind === "init") return;
|
|
869
|
+
this.ensureTurn();
|
|
870
|
+
const t2 = this.turn;
|
|
871
|
+
if (!t2) return;
|
|
872
|
+
if (ev.kind === "delta") t2.live += ev.text;
|
|
873
|
+
else if (ev.kind === "thinking") {
|
|
874
|
+
if (ev.text) t2.parts.push({ type: "thinking", text: ev.text });
|
|
875
|
+
} else if (ev.kind === "tool") {
|
|
876
|
+
const pending = this.stripProposal(t2.live);
|
|
877
|
+
if (pending) t2.parts.push({ type: "text", text: pending });
|
|
878
|
+
t2.live = "";
|
|
879
|
+
t2.parts.push({ type: "tool", name: ev.name ?? tr("chat.toolFallback"), detail: ev.detail });
|
|
880
|
+
} else if (ev.kind === "message") {
|
|
881
|
+
t2.parts.push({ type: "text", text: ev.text });
|
|
882
|
+
t2.live = "";
|
|
883
|
+
}
|
|
884
|
+
this.renderTurn(false);
|
|
885
|
+
this.scroll();
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
export {
|
|
889
|
+
Chat
|
|
890
|
+
};
|