@narumitw/pi-webui 0.20.2

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,198 @@
1
+ export function parseMarkdown(input) {
2
+ const lines = String(input ?? "")
3
+ .replaceAll("\r\n", "\n")
4
+ .replaceAll("\r", "\n")
5
+ .split("\n");
6
+ const blocks = [];
7
+ let index = 0;
8
+ while (index < lines.length) {
9
+ const line = lines[index];
10
+ if (!line.trim()) {
11
+ index += 1;
12
+ continue;
13
+ }
14
+ const fence = line.match(/^\s*```([\w-]*)\s*$/);
15
+ if (fence) {
16
+ const content = [];
17
+ index += 1;
18
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) {
19
+ content.push(lines[index]);
20
+ index += 1;
21
+ }
22
+ if (index < lines.length) index += 1;
23
+ blocks.push({ type: "codeBlock", language: fence[1] ?? "", text: content.join("\n") });
24
+ continue;
25
+ }
26
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
27
+ if (heading) {
28
+ blocks.push({
29
+ type: "heading",
30
+ level: heading[1].length,
31
+ children: parseInline(heading[2]),
32
+ });
33
+ index += 1;
34
+ continue;
35
+ }
36
+ if (/^\s*>\s?/.test(line)) {
37
+ const quoted = [];
38
+ while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
39
+ quoted.push(lines[index].replace(/^\s*>\s?/, ""));
40
+ index += 1;
41
+ }
42
+ blocks.push({ type: "blockquote", children: parseMarkdown(quoted.join("\n")) });
43
+ continue;
44
+ }
45
+ const list = line.match(/^\s*(?:(\d+)[.)]|([-+*]))\s+(.+)$/);
46
+ if (list) {
47
+ const ordered = Boolean(list[1]);
48
+ const items = [];
49
+ while (index < lines.length) {
50
+ const item = lines[index].match(/^\s*(?:(\d+)[.)]|([-+*]))\s+(.+)$/);
51
+ if (!item || Boolean(item[1]) !== ordered) break;
52
+ items.push(parseInline(item[3]));
53
+ index += 1;
54
+ }
55
+ blocks.push({ type: "list", ordered, items });
56
+ continue;
57
+ }
58
+ const paragraph = [line];
59
+ index += 1;
60
+ while (index < lines.length && lines[index].trim() && !startsBlock(lines[index])) {
61
+ paragraph.push(lines[index]);
62
+ index += 1;
63
+ }
64
+ blocks.push({ type: "paragraph", children: parseInline(paragraph.join("\n")) });
65
+ }
66
+ return blocks;
67
+ }
68
+
69
+ export function isSafeLink(url) {
70
+ try {
71
+ const parsed = new URL(url);
72
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
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
+ function startsBlock(line) {
85
+ return (
86
+ /^\s*```/.test(line) ||
87
+ /^\s{0,3}#{1,6}\s+/.test(line) ||
88
+ /^\s*>\s?/.test(line) ||
89
+ /^\s*(?:(?:\d+)[.)]|[-+*])\s+/.test(line)
90
+ );
91
+ }
92
+
93
+ function parseInline(text) {
94
+ const nodes = [];
95
+ let index = 0;
96
+ while (index < text.length) {
97
+ const codeEnd = text[index] === "`" ? text.indexOf("`", index + 1) : -1;
98
+ if (codeEnd > index + 1) {
99
+ nodes.push({ type: "code", text: text.slice(index + 1, codeEnd) });
100
+ index = codeEnd + 1;
101
+ continue;
102
+ }
103
+ const link = text.slice(index).match(/^\[([^\]]+)\]\(([^)\s]+)\)/);
104
+ if (link) {
105
+ if (isSafeLink(link[2])) {
106
+ nodes.push({ type: "link", href: link[2], children: parseInline(link[1]) });
107
+ } else {
108
+ pushText(nodes, link[0]);
109
+ }
110
+ index += link[0].length;
111
+ continue;
112
+ }
113
+ const strongEnd = text.startsWith("**", index) ? text.indexOf("**", index + 2) : -1;
114
+ if (strongEnd > index + 2) {
115
+ nodes.push({ type: "strong", children: parseInline(text.slice(index + 2, strongEnd)) });
116
+ index = strongEnd + 2;
117
+ continue;
118
+ }
119
+ const emphasisEnd = text[index] === "*" ? text.indexOf("*", index + 1) : -1;
120
+ if (emphasisEnd > index + 1) {
121
+ nodes.push({ type: "emphasis", children: parseInline(text.slice(index + 1, emphasisEnd)) });
122
+ index = emphasisEnd + 1;
123
+ continue;
124
+ }
125
+ pushText(nodes, text[index]);
126
+ index += 1;
127
+ }
128
+ return nodes;
129
+ }
130
+
131
+ function pushText(nodes, text) {
132
+ const previous = nodes.at(-1);
133
+ if (previous?.type === "text") previous.text += text;
134
+ else nodes.push({ type: "text", text });
135
+ }
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
+ }
@@ -0,0 +1,166 @@
1
+ export function initialState() {
2
+ return {
3
+ sequence: 0,
4
+ session: undefined,
5
+ messages: [],
6
+ tools: [],
7
+ activity: "idle",
8
+ closed: false,
9
+ connected: false,
10
+ stale: false,
11
+ needsSnapshot: false,
12
+ pending: false,
13
+ readingImages: 0,
14
+ leaseClaimed: false,
15
+ leaseGeneration: 0,
16
+ following: true,
17
+ unseenUpdateIds: [],
18
+ text: "",
19
+ images: [],
20
+ error: "",
21
+ };
22
+ }
23
+
24
+ export function applySnapshot(current, snapshot) {
25
+ if (!Number.isSafeInteger(snapshot?.sequence) || snapshot.sequence < current.sequence)
26
+ return current;
27
+ return {
28
+ ...current,
29
+ sequence: snapshot.sequence,
30
+ session: snapshot.session,
31
+ messages: Array.isArray(snapshot.messages) ? snapshot.messages : [],
32
+ tools: Array.isArray(snapshot.tools) ? snapshot.tools : [],
33
+ activity: snapshot.activity ?? "idle",
34
+ closed: Boolean(snapshot.closed),
35
+ needsSnapshot: false,
36
+ };
37
+ }
38
+
39
+ export function applyConversationEvent(current, event) {
40
+ if (!Number.isSafeInteger(event?.sequence) || event.sequence <= current.sequence) return current;
41
+ if (event.sequence !== current.sequence + 1) return { ...current, needsSnapshot: true };
42
+ if (event.type === "snapshot") {
43
+ return event.payload?.sequence === event.sequence
44
+ ? applySnapshot(current, event.payload)
45
+ : { ...current, needsSnapshot: true };
46
+ }
47
+ const next = { ...current, sequence: event.sequence };
48
+ switch (event.type) {
49
+ case "message":
50
+ if (event.payload?.id) next.messages = upsertById(current.messages, event.payload);
51
+ break;
52
+ case "tool":
53
+ if (event.payload?.id) next.tools = upsertById(current.tools, event.payload);
54
+ break;
55
+ case "activity":
56
+ if (["idle", "running", "ended"].includes(event.payload?.activity)) {
57
+ next.activity = event.payload.activity;
58
+ }
59
+ break;
60
+ case "session-ended":
61
+ next.closed = true;
62
+ next.activity = "ended";
63
+ break;
64
+ }
65
+ return next;
66
+ }
67
+
68
+ export function applyLease(current, lease, clientId, claimed = false) {
69
+ const generation = Number.isSafeInteger(lease?.generation) ? lease.generation : 0;
70
+ if (generation < current.leaseGeneration) {
71
+ return claimed && !current.leaseClaimed ? { ...current, leaseClaimed: true } : current;
72
+ }
73
+ return {
74
+ ...current,
75
+ leaseClaimed: current.leaseClaimed || claimed,
76
+ leaseGeneration: generation,
77
+ stale: lease?.activeClientId !== clientId,
78
+ };
79
+ }
80
+
81
+ export function prepareSend(current, requestId, delivery = "next") {
82
+ const attempt = current.outbox ?? {
83
+ requestId,
84
+ text: current.text,
85
+ images: [...current.images],
86
+ delivery,
87
+ };
88
+ return {
89
+ state: { ...current, pending: true, error: "", outbox: attempt },
90
+ attempt,
91
+ };
92
+ }
93
+
94
+ export function completeSend(current, attempt, delivery) {
95
+ const submittedImageIds = new Set(attempt.images.map((image) => image.id));
96
+ return {
97
+ ...current,
98
+ pending: false,
99
+ text: current.text === attempt.text ? "" : current.text,
100
+ images: current.images.filter((image) => !submittedImageIds.has(image.id)),
101
+ outbox: current.outbox === attempt ? undefined : current.outbox,
102
+ error: "",
103
+ lastDelivery: delivery,
104
+ };
105
+ }
106
+
107
+ export function failSend(current, attempt, error) {
108
+ return {
109
+ ...current,
110
+ pending: false,
111
+ outbox: current.outbox === attempt ? attempt : current.outbox,
112
+ error,
113
+ };
114
+ }
115
+
116
+ export function invalidateSendAttempt(current) {
117
+ return { ...current, outbox: undefined, lastDelivery: undefined };
118
+ }
119
+
120
+ export function setNearBottom(current, nearBottom) {
121
+ if (!nearBottom) return current.following ? { ...current, following: false } : current;
122
+ if (current.following && current.unseenUpdateIds.length === 0) return current;
123
+ return { ...current, following: true, unseenUpdateIds: [] };
124
+ }
125
+
126
+ export function noteUnseenUpdate(current, key) {
127
+ if (current.following || current.unseenUpdateIds.includes(key)) return current;
128
+ return { ...current, unseenUpdateIds: [...current.unseenUpdateIds, key] };
129
+ }
130
+
131
+ export function followLatest(current) {
132
+ return { ...current, following: true, unseenUpdateIds: [] };
133
+ }
134
+
135
+ export function upsertById(items, value) {
136
+ const index = items.findIndex((item) => item.id === value.id);
137
+ if (index < 0) return [...items, value];
138
+ const next = [...items];
139
+ next[index] = value;
140
+ return next;
141
+ }
142
+
143
+ export function canSend(current) {
144
+ return Boolean(
145
+ current.connected &&
146
+ !current.closed &&
147
+ !current.stale &&
148
+ !current.pending &&
149
+ current.readingImages === 0 &&
150
+ (current.text.trim() || current.images.length > 0),
151
+ );
152
+ }
153
+
154
+ export function deliveryNotice(current) {
155
+ if (current.lastDelivery === "immediate") return "Message accepted by Pi.";
156
+ if (current.lastDelivery === "followUp") return "Queued to run after Pi finishes.";
157
+ if (current.lastDelivery === "steer") return "Steering message accepted by Pi.";
158
+ return "";
159
+ }
160
+
161
+ export function busyLabel(current) {
162
+ if (!current.connected) return "Reconnect to send";
163
+ if (current.closed) return "Session ended";
164
+ if (current.stale) return "Tab is read-only";
165
+ return current.activity === "running" ? "Queue next" : "Send";
166
+ }