diff --git a/webui/native/dist/index.html b/webui/native/dist/index.html
index cb56598b..6902014d 100644
--- a/webui/native/dist/index.html
+++ b/webui/native/dist/index.html
@@ -31,20 +31,20 @@
diff --git a/webui/native/src/lib/text.ts b/webui/native/src/lib/text.ts
index 038a715a..8bf711f5 100644
--- a/webui/native/src/lib/text.ts
+++ b/webui/native/src/lib/text.ts
@@ -1,32 +1,143 @@
const speakerLine = /^\s*(Speaker\s+\d+\s*:)\s*(.*)$/i;
-const sentenceParts = /[^。!?!?;;…]*[。!?!?;;…]+|[^。!?!?;;…]+$/g;
+
+// Sentence terminators. '.' is handled separately by endsSentence, because it is
+// the only one that is ambiguous: it also marks decimals, abbreviations,
+// initials and file extensions.
+const terminators = new Set(['。', '!', '?', '!', '?', ';', ';', '…', '.']);
+
+// Abbreviations that end in a full stop without ending a sentence. Not
+// exhaustive -- it cannot be -- but it covers what prose actually contains, and
+// a miss costs a split in a slightly wrong place, not a failure.
+const abbreviations = new Set([
+ 'mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'mt', 'rev', 'hon',
+ 'vs', 'etc', 'eg', 'ie', 'approx', 'dept', 'est', 'fig', 'no', 'vol',
+ 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct',
+ 'nov', 'dec', 'inc', 'ltd', 'co', 'corp',
+]);
+
+function isDigit(character: string): boolean {
+ return character >= '0' && character <= '9';
+}
+
+function isWordCharacter(character: string): boolean {
+ return /[\p{L}\p{N}']/u.test(character);
+}
+
+/** Whether a full stop ends a sentence rather than a number or an abbreviation. */
+function endsSentence(text: string, index: number): boolean {
+ // Must be followed by whitespace or end of text, which keeps file.txt and
+ // example.com intact.
+ const next = text[index + 1];
+ if (next !== undefined && !/\s/.test(next)) return false;
+
+ // Not a decimal point.
+ if (index > 0 && isDigit(text[index - 1]) && next !== undefined && isDigit(next)) return false;
+
+ let start = index;
+ while (start > 0 && isWordCharacter(text[start - 1])) start -= 1;
+ const word = text.slice(start, index);
+
+ // A single letter is an initial: "J. R. R. Tolkien".
+ if (word.length === 1 && /\p{L}/u.test(word)) return false;
+
+ return !abbreviations.has(word.toLowerCase());
+}
+
+/**
+ * Break text after each sentence terminator, keeping the terminator and any
+ * following whitespace attached to the sentence it ends.
+ */
+function splitSentences(text: string): string[] {
+ const sentences: string[] = [];
+ let start = 0;
+
+ for (let index = 0; index < text.length; index += 1) {
+ if (!terminators.has(text[index])) continue;
+ if (text[index] === '.' && !endsSentence(text, index)) continue;
+
+ let end = index + 1;
+ while (end < text.length && terminators.has(text[end])) end += 1;
+ while (end < text.length && /\s/.test(text[end])) end += 1;
+
+ sentences.push(text.slice(start, end));
+ start = end;
+ index = end - 1;
+ }
+
+ if (start < text.length) sentences.push(text.slice(start));
+ return sentences;
+}
+
+/**
+ * Cut text into budget-sized pieces at whitespace, falling back to a hard cut
+ * only for a single token that is itself longer than the budget.
+ */
+function breakOnWords(text: string, budget: number): string[] {
+ const pieces: string[] = [];
+ let rest = text.trim();
+
+ while (rest.length > budget) {
+ let cut = rest.lastIndexOf(' ', budget);
+ if (cut <= 0) cut = budget;
+
+ const piece = rest.slice(0, cut).trim();
+ if (piece) pieces.push(piece);
+ rest = rest.slice(cut).trim();
+ }
+
+ if (rest) pieces.push(rest);
+ return pieces;
+}
function splitLongLine(line: string, budget: number): string[] {
const match = speakerLine.exec(line);
const prefix = match ? `${match[1]} ` : '';
const body = match ? match[2] : line.trim();
- const sentences = body.match(sentenceParts) || [body];
+
+ // The prefix is repeated onto every chunk, so it has to come out of the
+ // budget or chunks carrying one exceed the limit the caller asked for.
+ const room = Math.max(1, budget - prefix.length);
+
+ const sentences = splitSentences(body);
+ if (!sentences.length) sentences.push(body);
+
const chunks: string[] = [];
let current = '';
+ // Whatever whitespace actually followed the last sentence added to `current`.
+ // splitSentences keeps it, and it has to be carried rather than replaced with
+ // a space: sentences in Chinese, Japanese and Korean are adjacent, separated
+ // by a full-width terminator and nothing else. Inserting a space there both
+ // changes the text the model is asked to speak and spends a character of the
+ // budget, so three 20-character sentences stop fitting in two 40-character
+ // chunks and become three requests instead of two.
+ let separator = '';
- for (const sentence of sentences) {
- if (current && current.length + sentence.length > budget) {
- chunks.push(prefix + current);
+ for (const raw of sentences) {
+ const sentence = raw.trimEnd();
+ if (!sentence) continue;
+ const trailing = raw.slice(sentence.length);
+
+ if (current && current.length + separator.length + sentence.length > room) {
+ chunks.push(prefix + current.trim());
current = '';
+ separator = '';
}
- if (sentence.length <= budget) {
- current += sentence;
+ if (sentence.length <= room) {
+ current = current ? `${current}${separator}${sentence}` : sentence;
+ separator = trailing;
continue;
}
if (current) {
- chunks.push(prefix + current);
+ chunks.push(prefix + current.trim());
current = '';
+ separator = '';
}
- for (let offset = 0; offset < sentence.length; offset += budget) {
- chunks.push(prefix + sentence.slice(offset, offset + budget));
- }
+ // No sentence boundary fits, so fall back to word boundaries. Only a token
+ // longer than the whole budget is ever cut mid-word.
+ for (const piece of breakOnWords(sentence, room)) chunks.push(prefix + piece);
}
- if (current) chunks.push(prefix + current);
+ if (current) chunks.push(prefix + current.trim());
+
return chunks.length ? chunks : [line];
}