fix(web): plan-card catch, removeTurn on finish, pane ErrorBoundary, CRLF SSE frames

T20-F2/F3/F5/F6: reset stuck executing cards, reclaim AbortControllers via removeTurn, wrap chat/doc panes, and split SSE on CRLF blank lines as well as LF.
This commit is contained in:
WeiCHEN
2026-09-22 21:24:20 +08:00
parent 0c4baad4a2
commit 472ec6497f
8 changed files with 133 additions and 49 deletions
+21
View File
@@ -43,3 +43,24 @@ test('parseSSEChunk: skips a single malformed frame and keeps parsing the next v
assert.equal(events[0].delta, 'ok');
assert.equal(lastSeq, 2);
});
test('parseSSEChunk: parses CRLF-delimited frames', () => {
const raw =
'event: phase\r\ndata: {"type":"phase","phase":{"phase_id":"intent_recognizing"}}\r\n\r\n' +
'event: delta\r\ndata: {"type":"delta","delta":"hi"}\r\n\r\n';
const { events, rest } = parseSSEChunk(raw);
assert.equal(rest, '');
assert.equal(events.length, 2);
assert.equal(events[0].type, 'phase');
assert.equal(events[1].delta, 'hi');
});
test('parseSSEChunk: mixed LF and CRLF delimiters', () => {
const raw =
'data: {"type":"status"}\n\n' +
'data: {"type":"delta","delta":"x"}\r\n\r\n';
const { events, rest } = parseSSEChunk(raw);
assert.equal(rest, '');
assert.equal(events.length, 2);
assert.equal(events[1].delta, 'x');
});
+36 -13
View File
@@ -107,7 +107,7 @@ function parseFrame(frame: string): { event: StreamEvent | null; seq?: number }
let dataLine: string | undefined;
let seq: number | undefined;
for (const line of frame.split('\n')) {
for (const line of frame.split(/\r?\n/)) {
if (line.startsWith('id:')) {
const n = Number(line.slice(3).trim());
if (Number.isFinite(n)) seq = n;
@@ -127,6 +127,38 @@ function parseFrame(frame: string): { event: StreamEvent | null; seq?: number }
}
}
/** Split complete SSE frames; accept both LF and CRLF blank-line delimiters. */
function takeSSEFrames(
buffer: string,
flush?: boolean,
): { frames: string[]; rest: string } {
const frames: string[] = [];
let start = 0;
while (start < buffer.length) {
const lfIdx = buffer.indexOf('\n\n', start);
const crlfIdx = buffer.indexOf('\r\n\r\n', start);
let delimAt = -1;
let delimLen = 0;
if (lfIdx === -1 && crlfIdx === -1) {
break;
}
if (crlfIdx !== -1 && (lfIdx === -1 || crlfIdx <= lfIdx)) {
delimAt = crlfIdx;
delimLen = 4;
} else {
delimAt = lfIdx;
delimLen = 2;
}
frames.push(buffer.slice(start, delimAt));
start = delimAt + delimLen;
}
if (flush && start < buffer.length) {
frames.push(buffer.slice(start));
return { frames, rest: '' };
}
return { frames, rest: buffer.slice(start) };
}
export function parseSSEChunk(
buffer: string,
options?: { flush?: boolean },
@@ -134,22 +166,13 @@ export function parseSSEChunk(
const events: StreamEvent[] = [];
if (!buffer) return { events, rest: '' };
const frames = buffer.split('\n\n');
const hasTrailingDelimiter = buffer.endsWith('\n\n');
let processCount = hasTrailingDelimiter ? frames.length : Math.max(0, frames.length - 1);
if (options?.flush && !hasTrailingDelimiter && frames.length > 0) {
processCount = frames.length;
}
const { frames, rest } = takeSSEFrames(buffer, options?.flush);
let lastSeq: number | undefined;
for (let i = 0; i < processCount; i++) {
const parsed = parseFrame(frames[i]);
for (const frame of frames) {
const parsed = parseFrame(frame);
if (parsed.seq !== undefined) lastSeq = parsed.seq;
if (parsed.event) events.push(parsed.event);
}
const rest = hasTrailingDelimiter || options?.flush ? '' : (frames[frames.length - 1] ?? '');
return { events, rest, lastSeq };
}