pi-ui-extend 0.1.17 → 0.1.18
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/apps/desktop-tauri/README.md +103 -0
- package/apps/desktop-tauri/bin/pix-desktop.mjs +89 -0
- package/dist/app/input/input-controller.d.ts +1 -0
- package/dist/app/input/input-controller.js +29 -0
- package/dist/app/input/input-paste-handler.d.ts +1 -1
- package/dist/app/input/input-paste-handler.js +6 -5
- package/dist/app/model/model-usage-status.js +4 -27
- package/dist/app/rendering/render-controller.js +12 -8
- package/dist/app/screen/mouse-controller.d.ts +1 -0
- package/dist/app/screen/mouse-controller.js +13 -16
- package/external/pi-tools-suite/src/config.ts +43 -0
- package/external/pi-tools-suite/src/dcp/commands.ts +1 -1
- package/external/pi-tools-suite/src/dcp/index.ts +21 -1
- package/external/pi-tools-suite/src/dcp/state.ts +225 -3
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +5 -0
- package/external/pi-tools-suite/src/index.ts +1 -0
- package/external/pi-tools-suite/src/telegram-mirror/README.md +168 -0
- package/external/pi-tools-suite/src/telegram-mirror/bot.ts +228 -0
- package/external/pi-tools-suite/src/telegram-mirror/events.ts +94 -0
- package/external/pi-tools-suite/src/telegram-mirror/format.ts +120 -0
- package/external/pi-tools-suite/src/telegram-mirror/index.ts +424 -0
- package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +419 -0
- package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +408 -0
- package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +214 -0
- package/package.json +14 -3
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Telegram Bot API client.
|
|
3
|
+
*
|
|
4
|
+
* Uses native fetch (Node 18+ / Bun) and long polling via getUpdates.
|
|
5
|
+
* No dependencies on grammY or node-telegram-bot-api.
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - sendMessage / editMessageText (HTML parse mode)
|
|
9
|
+
* - getUpdates long-poll loop with backoff
|
|
10
|
+
* - Auth gate by chat_id (single whitelist)
|
|
11
|
+
* - Graceful shutdown via AbortController
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface TelegramUpdate {
|
|
15
|
+
update_id: number;
|
|
16
|
+
message?: {
|
|
17
|
+
message_id: number;
|
|
18
|
+
date: number;
|
|
19
|
+
chat: { id: number; type: string };
|
|
20
|
+
from?: { id: number; first_name?: string; username?: string };
|
|
21
|
+
text?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TelegramMessage {
|
|
26
|
+
message_id: number;
|
|
27
|
+
chat: { id: number };
|
|
28
|
+
date: number;
|
|
29
|
+
text?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BotConfig {
|
|
33
|
+
token: string;
|
|
34
|
+
allowedChatId: number;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SendOptions {
|
|
39
|
+
parseMode?: "HTML" | "MarkdownV2" | "Markdown";
|
|
40
|
+
disablePreview?: boolean;
|
|
41
|
+
silent?: boolean;
|
|
42
|
+
replyToMessageId?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface EditOptions {
|
|
46
|
+
parseMode?: "HTML" | "MarkdownV2" | "Markdown";
|
|
47
|
+
disablePreview?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class TelegramBot {
|
|
51
|
+
private readonly baseUrl: string;
|
|
52
|
+
private readonly allowedChatId: number;
|
|
53
|
+
private readonly timeoutMs: number;
|
|
54
|
+
private readonly controller = new AbortController();
|
|
55
|
+
private polling = false;
|
|
56
|
+
private lastUpdateId = 0;
|
|
57
|
+
private consecutiveErrors = 0;
|
|
58
|
+
|
|
59
|
+
constructor(config: BotConfig) {
|
|
60
|
+
if (!config.token) throw new Error("TelegramBot: token is required");
|
|
61
|
+
this.baseUrl = `https://api.telegram.org/bot${config.token}`;
|
|
62
|
+
this.allowedChatId = config.allowedChatId;
|
|
63
|
+
this.timeoutMs = config.timeoutMs ?? 35_000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get signal(): AbortSignal {
|
|
67
|
+
return this.controller.signal;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get chatId(): number {
|
|
71
|
+
return this.allowedChatId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
isAllowedChat(chatId: number): boolean {
|
|
75
|
+
return chatId === this.allowedChatId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getMe(): Promise<{ ok: boolean; result?: { username: string; first_name: string } }> {
|
|
79
|
+
return this.requestJson("GET", "getMe", undefined);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async sendMessage(text: string, options: SendOptions = {}): Promise<TelegramMessage | undefined> {
|
|
83
|
+
return this.requestJson("POST", "sendMessage", {
|
|
84
|
+
chat_id: this.allowedChatId,
|
|
85
|
+
text,
|
|
86
|
+
parse_mode: options.parseMode ?? "HTML",
|
|
87
|
+
disable_web_page_preview: options.disablePreview ?? true,
|
|
88
|
+
disable_notification: options.silent ?? false,
|
|
89
|
+
...(options.replyToMessageId ? { reply_to_message_id: options.replyToMessageId } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async editMessageText(messageId: number, text: string, options: EditOptions = {}): Promise<TelegramMessage | undefined> {
|
|
94
|
+
try {
|
|
95
|
+
return await this.requestJson("POST", "editMessageText", {
|
|
96
|
+
chat_id: this.allowedChatId,
|
|
97
|
+
message_id: messageId,
|
|
98
|
+
text,
|
|
99
|
+
parse_mode: options.parseMode ?? "HTML",
|
|
100
|
+
disable_web_page_preview: options.disablePreview ?? true,
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// Telegram returns 400 "message is not modified" if content is identical.
|
|
104
|
+
// Treat that as success; surface everything else.
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
106
|
+
if (/not modified/i.test(message)) return undefined;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async deleteMessage(messageId: number): Promise<void> {
|
|
112
|
+
try {
|
|
113
|
+
await this.requestJson("POST", "deleteMessage", {
|
|
114
|
+
chat_id: this.allowedChatId,
|
|
115
|
+
message_id: messageId,
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
// best-effort
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Start long-polling loop. The callback receives every update from the
|
|
124
|
+
* allowed chat; updates from other chats are dropped silently.
|
|
125
|
+
*
|
|
126
|
+
* The loop exits cleanly when abort() is called.
|
|
127
|
+
*/
|
|
128
|
+
startPolling(onUpdate: (update: TelegramUpdate) => void | Promise<void>): void {
|
|
129
|
+
if (this.polling) return;
|
|
130
|
+
this.polling = true;
|
|
131
|
+
void this.pollLoop(onUpdate);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
abort(): void {
|
|
135
|
+
if (this.controller.signal.aborted) return;
|
|
136
|
+
this.controller.abort();
|
|
137
|
+
this.polling = false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async pollLoop(onUpdate: (update: TelegramUpdate) => void | Promise<void>): Promise<void> {
|
|
141
|
+
while (this.polling && !this.controller.signal.aborted) {
|
|
142
|
+
try {
|
|
143
|
+
const payload = await this.requestJson<{
|
|
144
|
+
ok: boolean;
|
|
145
|
+
result?: TelegramUpdate[];
|
|
146
|
+
}>("POST", "getUpdates", {
|
|
147
|
+
offset: this.lastUpdateId > 0 ? this.lastUpdateId + 1 : undefined,
|
|
148
|
+
timeout: Math.floor(this.timeoutMs / 1000),
|
|
149
|
+
allowed_updates: ["message"],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
this.consecutiveErrors = 0;
|
|
153
|
+
|
|
154
|
+
if (!payload?.ok || !Array.isArray(payload.result)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const update of payload.result) {
|
|
159
|
+
if (update.update_id > this.lastUpdateId) this.lastUpdateId = update.update_id;
|
|
160
|
+
if (!update.message) continue;
|
|
161
|
+
if (!this.isAllowedChat(update.message.chat.id)) continue;
|
|
162
|
+
try {
|
|
163
|
+
await onUpdate(update);
|
|
164
|
+
} catch (handlerError) {
|
|
165
|
+
this.logError("onUpdate handler", handlerError);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (this.controller.signal.aborted) break;
|
|
170
|
+
this.consecutiveErrors += 1;
|
|
171
|
+
this.logError("polling", error);
|
|
172
|
+
// Backoff: 1s → 60s capped
|
|
173
|
+
const backoff = Math.min(60_000, 1000 * 2 ** Math.min(5, this.consecutiveErrors - 1));
|
|
174
|
+
await sleep(backoff).catch(() => undefined);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async requestJson<T>(method: "GET" | "POST", endpoint: string, body?: Record<string, unknown>): Promise<T> {
|
|
180
|
+
const url = `${this.baseUrl}/${endpoint}`;
|
|
181
|
+
const init: RequestInit = {
|
|
182
|
+
method,
|
|
183
|
+
signal: this.controller.signal,
|
|
184
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
185
|
+
body: body ? JSON.stringify(removeUndefined(body)) : undefined,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const response = await fetch(url, init);
|
|
189
|
+
const text = await response.text();
|
|
190
|
+
let parsed: unknown;
|
|
191
|
+
try {
|
|
192
|
+
parsed = text ? JSON.parse(text) : {};
|
|
193
|
+
} catch {
|
|
194
|
+
throw new Error(`Telegram API ${endpoint} returned invalid JSON: ${text.slice(0, 200)}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!response.ok || (isRecord(parsed) && parsed.ok === false)) {
|
|
198
|
+
const desc = isRecord(parsed) && typeof parsed.description === "string" ? parsed.description : text.slice(0, 200);
|
|
199
|
+
const errorCode = isRecord(parsed) && typeof parsed.error_code === "number" ? parsed.error_code : response.status;
|
|
200
|
+
throw new Error(`Telegram API ${endpoint} failed (${errorCode}): ${desc}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return parsed as T;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private logError(label: string, error: unknown): void {
|
|
207
|
+
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
208
|
+
// Use stderr so we don't pollute the TUI. Pi redirects stderr to its log.
|
|
209
|
+
// eslint-disable-next-line no-console
|
|
210
|
+
console.error(`[telegram-mirror] ${label}: ${message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
215
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function removeUndefined(value: Record<string, unknown>): Record<string, unknown> {
|
|
219
|
+
const out: Record<string, unknown> = {};
|
|
220
|
+
for (const [k, v] of Object.entries(value)) {
|
|
221
|
+
if (v !== undefined) out[k] = v;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function sleep(ms: number): Promise<void> {
|
|
227
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
228
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pix event → TurnRenderer adapters.
|
|
3
|
+
*
|
|
4
|
+
* Keeps a per-turn renderer (one TG message chain per agent turn) and exposes
|
|
5
|
+
* ExtensionAPI event handlers that append to it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { RendererEvent } from "./renderer.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal sink for rendering events. The leader wires this to a Multiplexer
|
|
13
|
+
* (which routes to a TurnRenderer for the active instance); a follower wires
|
|
14
|
+
* it to an IPC socket so events travel to the leader.
|
|
15
|
+
*/
|
|
16
|
+
export interface RendererSink {
|
|
17
|
+
push(event: RendererEvent): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerPixEventHandlers(pi: ExtensionAPI, hooks: PixMirrorHooks): void {
|
|
21
|
+
pi.on("agent_start", () => {
|
|
22
|
+
hooks.getRenderer()?.push({ kind: "turn_start" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
pi.on("before_agent_start", (event) => {
|
|
26
|
+
const prompt = event?.prompt?.trim();
|
|
27
|
+
if (!prompt) return;
|
|
28
|
+
hooks.getRenderer()?.push({ kind: "info", text: `user: ${truncate(prompt, 200)}` });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.on("message_update", (event) => {
|
|
32
|
+
const type = event?.assistantMessageEvent?.type;
|
|
33
|
+
if (type === "text_delta") {
|
|
34
|
+
const delta = (event.assistantMessageEvent as { delta?: string }).delta ?? "";
|
|
35
|
+
if (delta) hooks.getRenderer()?.push({ kind: "assistant_text", delta });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (type === "thinking_delta" || type === "thinking_start") {
|
|
39
|
+
// Render a single `💭 thinking…` marker per turn. The renderer
|
|
40
|
+
// dedupes further thinking events so we don't spam the chat
|
|
41
|
+
// with streaming thinking chunks.
|
|
42
|
+
hooks.getRenderer()?.push({ kind: "thinking" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
pi.on("tool_execution_start", (event) => {
|
|
48
|
+
hooks.getRenderer()?.push({
|
|
49
|
+
kind: "tool_start",
|
|
50
|
+
toolCallId: event.toolCallId,
|
|
51
|
+
toolName: event.toolName,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
pi.on("tool_execution_end", (event) => {
|
|
56
|
+
hooks.getRenderer()?.push({
|
|
57
|
+
kind: "tool_end",
|
|
58
|
+
toolCallId: event.toolCallId,
|
|
59
|
+
toolName: event.toolName,
|
|
60
|
+
isError: event.isError,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.on("agent_end", () => {
|
|
65
|
+
hooks.getRenderer()?.push({ kind: "turn_end", reason: "end" });
|
|
66
|
+
hooks.notifyAgentEnd();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PixMirrorHooks {
|
|
71
|
+
getRenderer(): RendererSink | undefined;
|
|
72
|
+
notifyAgentEnd(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function captureAbortableContext(ctx: ExtensionContext | undefined, hooks: ContextCapture): void {
|
|
76
|
+
if (!ctx) return;
|
|
77
|
+
hooks.captureAbort(() => ctx.abort());
|
|
78
|
+
hooks.captureIdle(() => ctx.isIdle());
|
|
79
|
+
hooks.capturePending(() => ctx.hasPendingMessages());
|
|
80
|
+
hooks.captureCompact(() => ctx.compact());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ContextCapture {
|
|
84
|
+
captureAbort(fn: () => void): void;
|
|
85
|
+
captureIdle(fn: () => boolean): void;
|
|
86
|
+
capturePending(fn: () => boolean): void;
|
|
87
|
+
captureCompact(fn: () => void): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function truncate(value: string, max: number): string {
|
|
91
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
92
|
+
if (collapsed.length <= max) return collapsed;
|
|
93
|
+
return `${collapsed.slice(0, Math.max(0, max - 1))}…`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal markdown → Telegram HTML conversion.
|
|
3
|
+
*
|
|
4
|
+
* Telegram supports a small subset of HTML: <b>, <i>, <code>, <pre>, <a>, <s>, <u>.
|
|
5
|
+
* See https://core.telegram.org/bots/api#html-style
|
|
6
|
+
*
|
|
7
|
+
* We escape < > & first, then re-introduce supported tags from common markdown
|
|
8
|
+
* patterns. This is intentionally lossy — blockquotes, headings, lists render
|
|
9
|
+
* as plain text. The goal is to keep streaming output legible on a phone screen.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ENTITY_RE = /[<>&]/g;
|
|
13
|
+
|
|
14
|
+
export function escapeHtml(value: string): string {
|
|
15
|
+
return value.replace(ENTITY_RE, (ch) => {
|
|
16
|
+
switch (ch) {
|
|
17
|
+
case "<":
|
|
18
|
+
return "<";
|
|
19
|
+
case ">":
|
|
20
|
+
return ">";
|
|
21
|
+
case "&":
|
|
22
|
+
return "&";
|
|
23
|
+
default:
|
|
24
|
+
return ch;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert a chunk of assistant-flavoured markdown to Telegram HTML.
|
|
31
|
+
*
|
|
32
|
+
* Handles:
|
|
33
|
+
* ```lang\nfenced\n``` → <pre><code class="language-lang">…</code></pre>
|
|
34
|
+
* `inline code` → <code>…</code>
|
|
35
|
+
* **bold** → <b>…</b>
|
|
36
|
+
* __bold__ / *italic* → <b>…</b> / <i>…</i>
|
|
37
|
+
*
|
|
38
|
+
* Everything else is HTML-escaped.
|
|
39
|
+
*/
|
|
40
|
+
export function markdownToTelegram(value: string): string {
|
|
41
|
+
if (!value) return "";
|
|
42
|
+
|
|
43
|
+
// Pull out fenced code blocks first so their contents aren't mangled
|
|
44
|
+
// by inline replacements.
|
|
45
|
+
const fences: string[] = [];
|
|
46
|
+
const fenced = value.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
|
|
47
|
+
const langAttr = typeof lang === "string" && lang.trim() ? ` class="language-${escapeHtml(lang.trim())}"` : "";
|
|
48
|
+
const rendered = `<pre><code${langAttr}>${escapeHtml(body)}</code></pre>`;
|
|
49
|
+
fences.push(rendered);
|
|
50
|
+
return `\u0000FENCE${fences.length - 1}\u0000`;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let out = escapeHtml(fenced);
|
|
54
|
+
|
|
55
|
+
// Inline code (do before bold/italic so backticks inside ** stay escaped)
|
|
56
|
+
out = out.replace(/`([^`\n]+?)`/g, (_m, code) => `<code>${code}</code>`);
|
|
57
|
+
|
|
58
|
+
// Bold
|
|
59
|
+
out = out.replace(/\*\*([^*\n]+?)\*\*/g, "<b>$1</b>");
|
|
60
|
+
out = out.replace(/__([^_\n]+?)__/g, "<b>$1</b>");
|
|
61
|
+
|
|
62
|
+
// Italic (single * or _)
|
|
63
|
+
out = out.replace(/(^|[^*])\*([^*\n]+?)\*(?!\*)/g, "$1<i>$2</i>");
|
|
64
|
+
out = out.replace(/(^|[^_])_([^_\n]+?)_(?!_)/g, "$1<i>$2</i>");
|
|
65
|
+
|
|
66
|
+
// Restore fenced blocks
|
|
67
|
+
out = out.replace(/\u0000FENCE(\d+)\u0000/g, (_m, idx) => fences[Number(idx)] ?? "");
|
|
68
|
+
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Telegram message size limit. */
|
|
73
|
+
export const TELEGRAM_MESSAGE_MAX = 4096;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split a rendered HTML payload into chunks that fit a single Telegram message.
|
|
77
|
+
* Tries to break on blank lines first, then on newlines, then hard-splits.
|
|
78
|
+
*
|
|
79
|
+
* Each chunk is guaranteed to be <= maxChars characters.
|
|
80
|
+
*/
|
|
81
|
+
export function chunkForTelegram(html: string, maxChars = TELEGRAM_MESSAGE_MAX): string[] {
|
|
82
|
+
if (html.length <= maxChars) return [html];
|
|
83
|
+
|
|
84
|
+
const chunks: string[] = [];
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
while (cursor < html.length) {
|
|
87
|
+
const remaining = html.length - cursor;
|
|
88
|
+
if (remaining <= maxChars) {
|
|
89
|
+
chunks.push(html.slice(cursor));
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const window = html.slice(cursor, cursor + maxChars);
|
|
94
|
+
let cut = -1;
|
|
95
|
+
|
|
96
|
+
// Prefer blank line break
|
|
97
|
+
const blank = window.lastIndexOf("\n\n");
|
|
98
|
+
if (blank >= maxChars * 0.5) cut = blank + 1;
|
|
99
|
+
|
|
100
|
+
// Then any newline
|
|
101
|
+
if (cut < 0) {
|
|
102
|
+
const nl = window.lastIndexOf("\n");
|
|
103
|
+
if (nl >= maxChars * 0.5) cut = nl + 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Then a space
|
|
107
|
+
if (cut < 0) {
|
|
108
|
+
const sp = window.lastIndexOf(" ");
|
|
109
|
+
if (sp >= maxChars * 0.5) cut = sp + 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Hard cut
|
|
113
|
+
if (cut < 0) cut = maxChars;
|
|
114
|
+
|
|
115
|
+
chunks.push(html.slice(cursor, cursor + cut));
|
|
116
|
+
cursor += cut;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return chunks.filter((chunk) => chunk.length > 0);
|
|
120
|
+
}
|