@narumitw/pi-webui 0.25.0 → 0.29.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/README.md +10 -5
- package/package.json +15 -6
- package/src/index.ts +1 -0
- package/src/server.ts +12 -11
- package/src/web/app.css +2 -0
- package/src/web/app.js +102 -995
- package/src/web/image-drag.js +0 -73
- package/src/web/index.html +3 -119
- package/src/web/markdown.js +0 -69
- package/src/web/transcript.js +0 -258
- package/src/web/ui/app.jsx +908 -0
- package/src/web/ui/client.js +690 -0
- package/src/web/ui/overlays.jsx +126 -0
- package/src/web/ui/styles.css +573 -0
- package/src/web/ui/view-helpers.js +68 -0
- package/src/web/styles.css +0 -946
package/src/web/image-drag.js
CHANGED
|
@@ -1,76 +1,3 @@
|
|
|
1
|
-
const IMAGE_DRAG_TYPE = "application/x-pi-webui-image";
|
|
2
|
-
|
|
3
|
-
export function createImageDragController(previews, { isLocked, onDrop }) {
|
|
4
|
-
let draggedId = "";
|
|
5
|
-
|
|
6
|
-
function clearDropTargets() {
|
|
7
|
-
previews.classList.remove("vertical-drop");
|
|
8
|
-
for (const candidate of previews.children) {
|
|
9
|
-
candidate.classList.remove("drag-before", "drag-after");
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function setDropTarget(item, after, vertical) {
|
|
14
|
-
previews.classList.toggle("vertical-drop", vertical);
|
|
15
|
-
for (const candidate of previews.children) {
|
|
16
|
-
candidate.classList.toggle("drag-before", candidate === item && !after);
|
|
17
|
-
candidate.classList.toggle("drag-after", candidate === item && after);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function focus(id) {
|
|
22
|
-
requestAnimationFrame(() => {
|
|
23
|
-
const escapedId = CSS.escape(id);
|
|
24
|
-
const item = previews.querySelector(`[data-image-id="${escapedId}"]`);
|
|
25
|
-
(item?.tabIndex === 0 ? item : item?.querySelector(".remove-image"))?.focus();
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function bind(item, { id, orderingLocked }) {
|
|
30
|
-
item.addEventListener("dragstart", (event) => {
|
|
31
|
-
if (isLocked() || orderingLocked || !event.dataTransfer) return;
|
|
32
|
-
draggedId = id;
|
|
33
|
-
event.dataTransfer.effectAllowed = "move";
|
|
34
|
-
event.dataTransfer.setData(IMAGE_DRAG_TYPE, id);
|
|
35
|
-
item.classList.add("dragging");
|
|
36
|
-
});
|
|
37
|
-
item.addEventListener("dragend", () => {
|
|
38
|
-
draggedId = "";
|
|
39
|
-
item.classList.remove("dragging");
|
|
40
|
-
clearDropTargets();
|
|
41
|
-
});
|
|
42
|
-
item.addEventListener("dragover", (event) => {
|
|
43
|
-
const sourceId = draggedId || event.dataTransfer?.getData(IMAGE_DRAG_TYPE);
|
|
44
|
-
if (isLocked() || orderingLocked || !sourceId) return;
|
|
45
|
-
if (sourceId === id) {
|
|
46
|
-
clearDropTargets();
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
event.preventDefault();
|
|
50
|
-
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
|
51
|
-
const vertical = imagesStackVertically(previews.children);
|
|
52
|
-
setDropTarget(item, dropAfterTarget(event, item, vertical), vertical);
|
|
53
|
-
});
|
|
54
|
-
item.addEventListener("dragleave", (event) => {
|
|
55
|
-
if (event.relatedTarget && item.contains(event.relatedTarget)) return;
|
|
56
|
-
item.classList.remove("drag-before", "drag-after");
|
|
57
|
-
});
|
|
58
|
-
item.addEventListener("drop", (event) => {
|
|
59
|
-
const sourceId = draggedId || event.dataTransfer?.getData(IMAGE_DRAG_TYPE);
|
|
60
|
-
if (isLocked() || orderingLocked || !sourceId || sourceId === id) return;
|
|
61
|
-
event.preventDefault();
|
|
62
|
-
event.stopPropagation();
|
|
63
|
-
const vertical = imagesStackVertically(previews.children);
|
|
64
|
-
const after = dropAfterTarget(event, item, vertical);
|
|
65
|
-
draggedId = "";
|
|
66
|
-
clearDropTargets();
|
|
67
|
-
onDrop({ sourceId, targetId: id, after });
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return { bind, clearDropTargets, focus };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
1
|
export function imagesStackVertically(items) {
|
|
75
2
|
const list = [...items];
|
|
76
3
|
return !list.some((item, index) =>
|
package/src/web/index.html
CHANGED
|
@@ -4,128 +4,12 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<meta name="csp-nonce" content="__PI_CSP_NONCE__" />
|
|
7
8
|
<title>Pi WebUI</title>
|
|
8
|
-
<link rel="stylesheet" href="/
|
|
9
|
+
<link rel="stylesheet" href="/app.css" />
|
|
9
10
|
</head>
|
|
10
11
|
<body>
|
|
11
|
-
<
|
|
12
|
-
<header class="session-header">
|
|
13
|
-
<div class="session-identity">
|
|
14
|
-
<p class="eyebrow">Pi WebUI</p>
|
|
15
|
-
<h1 id="project-name">Connecting…</h1>
|
|
16
|
-
<p id="session-name" class="session-name">Current session</p>
|
|
17
|
-
</div>
|
|
18
|
-
<div class="session-controls">
|
|
19
|
-
<p id="connection-status" class="connection" role="status">Connecting…</p>
|
|
20
|
-
<details class="session-details">
|
|
21
|
-
<summary>Session details</summary>
|
|
22
|
-
<dl>
|
|
23
|
-
<dt>Working directory</dt>
|
|
24
|
-
<dd id="cwd">—</dd>
|
|
25
|
-
</dl>
|
|
26
|
-
</details>
|
|
27
|
-
</div>
|
|
28
|
-
</header>
|
|
29
|
-
|
|
30
|
-
<main>
|
|
31
|
-
<section class="conversation" aria-labelledby="conversation-title">
|
|
32
|
-
<h2 id="conversation-title" class="visually-hidden">Current Pi conversation</h2>
|
|
33
|
-
<div id="empty-state" class="empty-state">
|
|
34
|
-
<strong>No messages yet</strong>
|
|
35
|
-
<span>Messages from this Pi session will appear here.</span>
|
|
36
|
-
</div>
|
|
37
|
-
<ol id="transcript"></ol>
|
|
38
|
-
<p id="transcript-status" class="visually-hidden" role="status" aria-live="polite"></p>
|
|
39
|
-
</section>
|
|
40
|
-
|
|
41
|
-
<button id="jump-latest" class="jump-latest" type="button" hidden>
|
|
42
|
-
↓ Jump to latest
|
|
43
|
-
</button>
|
|
44
|
-
|
|
45
|
-
<section id="blocking-state" class="blocking-state" role="alert" hidden>
|
|
46
|
-
<strong id="blocking-title">This page is read-only</strong>
|
|
47
|
-
<span id="blocking-message"></span>
|
|
48
|
-
</section>
|
|
49
|
-
|
|
50
|
-
<form id="composer" class="composer">
|
|
51
|
-
<label for="message-input">Message Pi</label>
|
|
52
|
-
<textarea
|
|
53
|
-
id="message-input"
|
|
54
|
-
aria-label="Message Pi"
|
|
55
|
-
rows="1"
|
|
56
|
-
placeholder="Ask Pi to do something…"
|
|
57
|
-
></textarea>
|
|
58
|
-
<p id="attachment-summary" class="attachment-summary" hidden></p>
|
|
59
|
-
<ul id="image-previews" class="image-previews" aria-label="Attached images"></ul>
|
|
60
|
-
<p
|
|
61
|
-
id="attachment-announcement"
|
|
62
|
-
class="visually-hidden"
|
|
63
|
-
role="status"
|
|
64
|
-
aria-live="polite"
|
|
65
|
-
aria-atomic="true"
|
|
66
|
-
></p>
|
|
67
|
-
<p id="composer-error" class="composer-error" role="alert" hidden></p>
|
|
68
|
-
<div class="composer-bottom">
|
|
69
|
-
<div class="composer-support">
|
|
70
|
-
<label class="button secondary" for="image-input">Add images</label>
|
|
71
|
-
<input
|
|
72
|
-
id="image-input"
|
|
73
|
-
type="file"
|
|
74
|
-
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/heic,image/heif,image/avif,.bmp,.tif,.tiff,.heic,.heif,.avif"
|
|
75
|
-
multiple
|
|
76
|
-
hidden
|
|
77
|
-
/>
|
|
78
|
-
<span class="image-hint">Paste, drop, or choose images.</span>
|
|
79
|
-
<button id="clear-attachments" class="button secondary" type="button" hidden>
|
|
80
|
-
Clear attachments
|
|
81
|
-
</button>
|
|
82
|
-
</div>
|
|
83
|
-
<div class="send-actions">
|
|
84
|
-
<button id="steer" class="button secondary" type="button" hidden>Steer</button>
|
|
85
|
-
<button id="send-next" class="button primary" type="submit">Send</button>
|
|
86
|
-
</div>
|
|
87
|
-
</div>
|
|
88
|
-
<p id="composer-status" class="composer-status" role="status">
|
|
89
|
-
Pi is idle · Messages send immediately.
|
|
90
|
-
</p>
|
|
91
|
-
</form>
|
|
92
|
-
</main>
|
|
93
|
-
|
|
94
|
-
<footer>
|
|
95
|
-
<details>
|
|
96
|
-
<summary>Privacy and limitations</summary>
|
|
97
|
-
<p>
|
|
98
|
-
This loopback page reflects semantic Pi messages, not terminal pixels. Data stays in
|
|
99
|
-
this live Pi process and browser tab. Thinking is collapsed by default.
|
|
100
|
-
</p>
|
|
101
|
-
</details>
|
|
102
|
-
</footer>
|
|
103
|
-
|
|
104
|
-
<dialog id="clear-attachments-dialog" class="confirmation-dialog" aria-labelledby="clear-attachments-title">
|
|
105
|
-
<form method="dialog">
|
|
106
|
-
<h2 id="clear-attachments-title">Clear attachments?</h2>
|
|
107
|
-
<p id="clear-attachments-message">Remove all unsent image attachments? Message text will be kept.</p>
|
|
108
|
-
<div class="dialog-actions">
|
|
109
|
-
<button type="submit" value="cancel">Cancel</button>
|
|
110
|
-
<button id="confirm-clear-attachments" class="danger" type="submit" value="confirm">
|
|
111
|
-
Clear attachments
|
|
112
|
-
</button>
|
|
113
|
-
</div>
|
|
114
|
-
</form>
|
|
115
|
-
</dialog>
|
|
116
|
-
|
|
117
|
-
<dialog id="image-preview-dialog" class="image-preview-dialog" aria-labelledby="image-preview-title">
|
|
118
|
-
<div class="image-preview-content">
|
|
119
|
-
<header>
|
|
120
|
-
<h2 id="image-preview-title">Image preview</h2>
|
|
121
|
-
<button id="image-preview-close" class="button secondary" type="button">Close</button>
|
|
122
|
-
</header>
|
|
123
|
-
<button id="image-preview-dismiss" class="image-preview-dismiss" type="button" aria-label="Close enlarged image">
|
|
124
|
-
<img id="image-preview" alt="" />
|
|
125
|
-
</button>
|
|
126
|
-
</div>
|
|
127
|
-
</dialog>
|
|
128
|
-
|
|
12
|
+
<div id="root"></div>
|
|
129
13
|
<script type="module" src="/app.js"></script>
|
|
130
14
|
</body>
|
|
131
15
|
</html>
|
package/src/web/markdown.js
CHANGED
|
@@ -75,12 +75,6 @@ export function isSafeLink(url) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function renderMarkdown(input, documentRef = document) {
|
|
79
|
-
const fragment = documentRef.createDocumentFragment();
|
|
80
|
-
for (const block of parseMarkdown(input)) fragment.append(renderBlock(block, documentRef));
|
|
81
|
-
return fragment;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
78
|
function startsBlock(line) {
|
|
85
79
|
return (
|
|
86
80
|
/^\s*```/.test(line) ||
|
|
@@ -133,66 +127,3 @@ function pushText(nodes, text) {
|
|
|
133
127
|
if (previous?.type === "text") previous.text += text;
|
|
134
128
|
else nodes.push({ type: "text", text });
|
|
135
129
|
}
|
|
136
|
-
|
|
137
|
-
function renderBlock(block, documentRef) {
|
|
138
|
-
if (block.type === "heading") {
|
|
139
|
-
const heading = documentRef.createElement(`h${Math.min(6, block.level + 2)}`);
|
|
140
|
-
heading.className = "markdown-heading";
|
|
141
|
-
appendInline(heading, block.children, documentRef);
|
|
142
|
-
return heading;
|
|
143
|
-
}
|
|
144
|
-
if (block.type === "list") {
|
|
145
|
-
const list = documentRef.createElement(block.ordered ? "ol" : "ul");
|
|
146
|
-
list.className = "markdown-list";
|
|
147
|
-
for (const children of block.items) {
|
|
148
|
-
const item = documentRef.createElement("li");
|
|
149
|
-
appendInline(item, children, documentRef);
|
|
150
|
-
list.append(item);
|
|
151
|
-
}
|
|
152
|
-
return list;
|
|
153
|
-
}
|
|
154
|
-
if (block.type === "blockquote") {
|
|
155
|
-
const quote = documentRef.createElement("blockquote");
|
|
156
|
-
for (const child of block.children) quote.append(renderBlock(child, documentRef));
|
|
157
|
-
return quote;
|
|
158
|
-
}
|
|
159
|
-
if (block.type === "codeBlock") {
|
|
160
|
-
const pre = documentRef.createElement("pre");
|
|
161
|
-
pre.className = "markdown-code";
|
|
162
|
-
const code = documentRef.createElement("code");
|
|
163
|
-
if (block.language) code.dataset.language = block.language;
|
|
164
|
-
code.append(documentRef.createTextNode(block.text));
|
|
165
|
-
pre.append(code);
|
|
166
|
-
return pre;
|
|
167
|
-
}
|
|
168
|
-
const paragraph = documentRef.createElement("p");
|
|
169
|
-
paragraph.className = "message-text";
|
|
170
|
-
appendInline(paragraph, block.children, documentRef);
|
|
171
|
-
return paragraph;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function appendInline(parent, nodes, documentRef) {
|
|
175
|
-
for (const node of nodes) {
|
|
176
|
-
if (node.type === "text") {
|
|
177
|
-
parent.append(documentRef.createTextNode(node.text));
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
const element = documentRef.createElement(
|
|
181
|
-
node.type === "code"
|
|
182
|
-
? "code"
|
|
183
|
-
: node.type === "strong"
|
|
184
|
-
? "strong"
|
|
185
|
-
: node.type === "emphasis"
|
|
186
|
-
? "em"
|
|
187
|
-
: "a",
|
|
188
|
-
);
|
|
189
|
-
if (node.type === "link") {
|
|
190
|
-
element.href = node.href;
|
|
191
|
-
element.target = "_blank";
|
|
192
|
-
element.rel = "noopener noreferrer";
|
|
193
|
-
}
|
|
194
|
-
if (node.type === "code") element.append(documentRef.createTextNode(node.text));
|
|
195
|
-
else appendInline(element, node.children, documentRef);
|
|
196
|
-
parent.append(element);
|
|
197
|
-
}
|
|
198
|
-
}
|
package/src/web/transcript.js
CHANGED
|
@@ -1,52 +1,3 @@
|
|
|
1
|
-
import { renderMarkdown } from "./markdown.js";
|
|
2
|
-
|
|
3
|
-
export function createTranscriptRenderer({
|
|
4
|
-
documentRef = document,
|
|
5
|
-
list,
|
|
6
|
-
onReattach = () => undefined,
|
|
7
|
-
onForget = () => undefined,
|
|
8
|
-
}) {
|
|
9
|
-
const messages = new Map();
|
|
10
|
-
|
|
11
|
-
return {
|
|
12
|
-
render(nextMessages, tools, sentImages = { items: [] }) {
|
|
13
|
-
const toolById = new Map(tools.map((tool) => [tool.id, tool]));
|
|
14
|
-
const retainedImageIds = new Set((sentImages.items ?? []).map((item) => item.id));
|
|
15
|
-
const retained = new Set();
|
|
16
|
-
const changed = [];
|
|
17
|
-
let cursor = list.firstChild;
|
|
18
|
-
for (const message of nextMessages) {
|
|
19
|
-
retained.add(message.id);
|
|
20
|
-
let view = messages.get(message.id);
|
|
21
|
-
if (!view) {
|
|
22
|
-
view = createMessageView(message.role, documentRef);
|
|
23
|
-
messages.set(message.id, view);
|
|
24
|
-
}
|
|
25
|
-
if (
|
|
26
|
-
updateMessageView(
|
|
27
|
-
view,
|
|
28
|
-
message,
|
|
29
|
-
toolById,
|
|
30
|
-
retainedImageIds,
|
|
31
|
-
{ onReattach, onForget },
|
|
32
|
-
documentRef,
|
|
33
|
-
)
|
|
34
|
-
) {
|
|
35
|
-
changed.push(message.id);
|
|
36
|
-
}
|
|
37
|
-
if (view.node !== cursor) list.insertBefore(view.node, cursor);
|
|
38
|
-
cursor = view.node.nextSibling;
|
|
39
|
-
}
|
|
40
|
-
for (const [id, view] of messages) {
|
|
41
|
-
if (retained.has(id)) continue;
|
|
42
|
-
view.node.remove();
|
|
43
|
-
messages.delete(id);
|
|
44
|
-
}
|
|
45
|
-
return changed;
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
1
|
export function isCollapsibleMessageRole(role) {
|
|
51
2
|
return role === "toolResult";
|
|
52
3
|
}
|
|
@@ -68,212 +19,3 @@ export function toolCommandPreview(tool) {
|
|
|
68
19
|
if (typeof command !== "string") return "";
|
|
69
20
|
return command.length > 120 ? `${command.slice(0, 120)}…` : command;
|
|
70
21
|
}
|
|
71
|
-
|
|
72
|
-
function createMessageView(role, documentRef) {
|
|
73
|
-
const node = documentRef.createElement("li");
|
|
74
|
-
const body = documentRef.createElement("div");
|
|
75
|
-
body.className = "message-body";
|
|
76
|
-
let heading;
|
|
77
|
-
if (isCollapsibleMessageRole(role)) {
|
|
78
|
-
const disclosure = documentRef.createElement("details");
|
|
79
|
-
disclosure.className = "tool-result-disclosure";
|
|
80
|
-
heading = documentRef.createElement("summary");
|
|
81
|
-
heading.className = "message-heading";
|
|
82
|
-
disclosure.append(heading, body);
|
|
83
|
-
node.append(disclosure);
|
|
84
|
-
} else {
|
|
85
|
-
heading = documentRef.createElement("div");
|
|
86
|
-
heading.className = "message-heading";
|
|
87
|
-
node.append(heading, body);
|
|
88
|
-
}
|
|
89
|
-
return { node, heading, body, blocks: new Map(), role: "", final: undefined };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function updateMessageView(view, message, toolById, retainedImageIds, actions, documentRef) {
|
|
93
|
-
let changed = false;
|
|
94
|
-
const role = knownRole(message.role);
|
|
95
|
-
if (view.role !== role) {
|
|
96
|
-
view.node.className = `message ${role}`;
|
|
97
|
-
view.role = role;
|
|
98
|
-
changed = true;
|
|
99
|
-
}
|
|
100
|
-
const heading = roleLabel(message);
|
|
101
|
-
if (view.final !== message.final || view.heading.textContent !== heading) {
|
|
102
|
-
view.heading.textContent = heading;
|
|
103
|
-
view.final = message.final;
|
|
104
|
-
changed = true;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const retained = new Set();
|
|
108
|
-
let cursor = view.body.firstChild;
|
|
109
|
-
for (const [index, block] of (message.content ?? []).entries()) {
|
|
110
|
-
const key = `${index}:${block.type}:${block.id ?? ""}`;
|
|
111
|
-
retained.add(key);
|
|
112
|
-
let blockView = view.blocks.get(key);
|
|
113
|
-
if (!blockView) {
|
|
114
|
-
blockView = createBlockView(block, documentRef);
|
|
115
|
-
view.blocks.set(key, blockView);
|
|
116
|
-
changed = true;
|
|
117
|
-
}
|
|
118
|
-
if (
|
|
119
|
-
updateBlockView(
|
|
120
|
-
blockView,
|
|
121
|
-
block,
|
|
122
|
-
toolById.get(block.id),
|
|
123
|
-
retainedImageIds,
|
|
124
|
-
actions,
|
|
125
|
-
documentRef,
|
|
126
|
-
)
|
|
127
|
-
) {
|
|
128
|
-
changed = true;
|
|
129
|
-
}
|
|
130
|
-
if (blockView.node !== cursor) view.body.insertBefore(blockView.node, cursor);
|
|
131
|
-
cursor = blockView.node.nextSibling;
|
|
132
|
-
}
|
|
133
|
-
for (const [key, blockView] of view.blocks) {
|
|
134
|
-
if (retained.has(key)) continue;
|
|
135
|
-
blockView.node.remove();
|
|
136
|
-
view.blocks.delete(key);
|
|
137
|
-
changed = true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const errorText = message.errorMessage ?? "";
|
|
141
|
-
if (errorText) {
|
|
142
|
-
if (!view.error) {
|
|
143
|
-
view.error = documentRef.createElement("p");
|
|
144
|
-
view.error.className = "message-error";
|
|
145
|
-
view.body.append(view.error);
|
|
146
|
-
changed = true;
|
|
147
|
-
}
|
|
148
|
-
if (view.error.textContent !== errorText) {
|
|
149
|
-
view.error.textContent = errorText;
|
|
150
|
-
changed = true;
|
|
151
|
-
}
|
|
152
|
-
} else if (view.error) {
|
|
153
|
-
view.error.remove();
|
|
154
|
-
view.error = undefined;
|
|
155
|
-
changed = true;
|
|
156
|
-
}
|
|
157
|
-
return changed;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function createBlockView(block, documentRef) {
|
|
161
|
-
if (block.type === "thinking") {
|
|
162
|
-
const node = documentRef.createElement("details");
|
|
163
|
-
node.className = "thinking";
|
|
164
|
-
const summary = documentRef.createElement("summary");
|
|
165
|
-
summary.textContent = "Thinking";
|
|
166
|
-
const text = documentRef.createElement("pre");
|
|
167
|
-
node.append(summary, text);
|
|
168
|
-
return { type: block.type, node, text, value: undefined };
|
|
169
|
-
}
|
|
170
|
-
if (block.type === "toolCall") return createToolView(documentRef);
|
|
171
|
-
const node = documentRef.createElement(block.type === "image" ? "span" : "div");
|
|
172
|
-
if (block.type === "image") node.className = "image-chip";
|
|
173
|
-
else node.className = "message-markdown";
|
|
174
|
-
return { type: block.type, node, value: undefined };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function updateBlockView(view, block, tool, retainedImageIds, actions, documentRef) {
|
|
178
|
-
if (block.type === "text") {
|
|
179
|
-
if (view.value === block.text) return false;
|
|
180
|
-
view.value = block.text;
|
|
181
|
-
view.node.replaceChildren(renderMarkdown(block.text, documentRef));
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
184
|
-
if (block.type === "thinking") {
|
|
185
|
-
if (view.value === block.text) return false;
|
|
186
|
-
view.value = block.text;
|
|
187
|
-
view.text.textContent = block.text;
|
|
188
|
-
return true;
|
|
189
|
-
}
|
|
190
|
-
if (block.type === "image") {
|
|
191
|
-
const status = retainedImageStatus(block, retainedImageIds);
|
|
192
|
-
const label = `Image${block.mimeType ? ` · ${block.mimeType}` : ""}`;
|
|
193
|
-
const value = `${label}:${status}`;
|
|
194
|
-
if (view.value === value) return false;
|
|
195
|
-
view.value = value;
|
|
196
|
-
const text = documentRef.createElement("span");
|
|
197
|
-
text.textContent = label;
|
|
198
|
-
view.node.replaceChildren(text);
|
|
199
|
-
if (status === "eligible") {
|
|
200
|
-
const attach = documentRef.createElement("button");
|
|
201
|
-
attach.type = "button";
|
|
202
|
-
attach.className = "message-image-action";
|
|
203
|
-
attach.textContent = "Attach again";
|
|
204
|
-
attach.setAttribute("aria-label", `Attach image again: ${label}`);
|
|
205
|
-
attach.addEventListener("click", () => actions.onReattach(block.retainedImageId));
|
|
206
|
-
const forget = documentRef.createElement("button");
|
|
207
|
-
forget.type = "button";
|
|
208
|
-
forget.className = "message-image-action subtle";
|
|
209
|
-
forget.textContent = "Forget";
|
|
210
|
-
forget.setAttribute("aria-label", `Forget retained image: ${label}`);
|
|
211
|
-
forget.addEventListener("click", () => actions.onForget(block.retainedImageId));
|
|
212
|
-
view.node.append(attach, forget);
|
|
213
|
-
} else if (status === "expired") {
|
|
214
|
-
const expired = documentRef.createElement("span");
|
|
215
|
-
expired.className = "message-image-expired";
|
|
216
|
-
expired.textContent = "Expired";
|
|
217
|
-
view.node.append(expired);
|
|
218
|
-
}
|
|
219
|
-
return true;
|
|
220
|
-
}
|
|
221
|
-
if (block.type === "toolCall") return updateToolView(view, block, tool);
|
|
222
|
-
return false;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function createToolView(documentRef) {
|
|
226
|
-
const node = documentRef.createElement("details");
|
|
227
|
-
node.className = "tool";
|
|
228
|
-
const summary = documentRef.createElement("summary");
|
|
229
|
-
const title = documentRef.createElement("span");
|
|
230
|
-
const command = documentRef.createElement("code");
|
|
231
|
-
command.className = "tool-command";
|
|
232
|
-
const args = documentRef.createElement("pre");
|
|
233
|
-
args.className = "tool-arguments";
|
|
234
|
-
const result = documentRef.createElement("pre");
|
|
235
|
-
result.className = "tool-result";
|
|
236
|
-
summary.append(title, command);
|
|
237
|
-
node.append(summary, args, result);
|
|
238
|
-
return { type: "toolCall", node, title, command, args, result, value: undefined };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function updateToolView(view, call, tool) {
|
|
242
|
-
const phase = toolPhaseLabel(tool);
|
|
243
|
-
const command = toolCommandPreview(tool);
|
|
244
|
-
const args = safeJson(tool?.args ?? call.arguments);
|
|
245
|
-
const result = tool?.result === undefined ? "" : safeJson(tool.result);
|
|
246
|
-
const value = JSON.stringify([call.name, phase, command, args, result, Boolean(tool?.isError)]);
|
|
247
|
-
if (view.value === value) return false;
|
|
248
|
-
view.value = value;
|
|
249
|
-
view.node.className = `tool ${tool?.isError ? "failed" : ""}`;
|
|
250
|
-
view.title.textContent = `${call.name} · ${phase}`;
|
|
251
|
-
view.command.textContent = command;
|
|
252
|
-
view.command.hidden = !command;
|
|
253
|
-
view.args.textContent = args;
|
|
254
|
-
view.result.textContent = result;
|
|
255
|
-
view.result.hidden = !result;
|
|
256
|
-
return true;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function roleLabel(message) {
|
|
260
|
-
if (message.role === "user") return "You";
|
|
261
|
-
if (message.role === "assistant") return message.final ? "Pi" : "Pi · Streaming";
|
|
262
|
-
if (message.role === "toolResult") {
|
|
263
|
-
return message.toolName ? `Tool · ${message.toolName}` : "Tool";
|
|
264
|
-
}
|
|
265
|
-
return message.role;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function knownRole(role) {
|
|
269
|
-
if (role === "user" || role === "assistant" || role === "toolResult") return role;
|
|
270
|
-
return "other";
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function safeJson(value) {
|
|
274
|
-
try {
|
|
275
|
-
return JSON.stringify(value, null, 2);
|
|
276
|
-
} catch {
|
|
277
|
-
return "Details unavailable";
|
|
278
|
-
}
|
|
279
|
-
}
|