mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
fix(app): group native assistant prose into selectable runs
This commit is contained in:
@@ -123,6 +123,88 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
|
||||
return [...result.tail, ...result.head];
|
||||
}
|
||||
|
||||
describe("native prose runs", () => {
|
||||
it("splits inline workspace images out of prose without mistaking code for images", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
event: assistant("Before ``  after."),
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{ text: "Before ``", markdownKind: "prose" },
|
||||
{ text: "", markdownKind: "block" },
|
||||
{ text: "after.", markdownKind: "prose" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ middle: "```ts\nconst a = 1;\n```", kind: "block" },
|
||||
{ middle: "> Quoted paragraph.", kind: "quote" },
|
||||
{ middle: "", kind: "block" },
|
||||
])("keeps $kind between prose in a separate row", ({ middle, kind }) => {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant(`First.\n\n${middle}\n\nLast.`), timestamp: new Date(1000) },
|
||||
]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{ id: `${source[0]!.id}:run:0`, text: "First.", markdownKind: "prose" },
|
||||
{ id: `${source[0]!.id}:block:1`, text: middle, markdownKind: kind },
|
||||
{ id: `${source[0]!.id}:run:2`, text: "Last.", markdownKind: "prose" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("grows the live run and retains promoted rows", () => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
function send(text: string) {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
return present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
const first = send("First.");
|
||||
const second = send("\n\nSecond.");
|
||||
expect(second.tail).toEqual([]);
|
||||
expect(second.head).toMatchObject([{ id: first.head[0]!.id, text: "First.\n\nSecond." }]);
|
||||
const boundary = send("\n\n```ts\na\n```");
|
||||
expect(boundary.tail[0]).toBe(second.head[0]);
|
||||
const last = send("\n\nAfter.");
|
||||
expect(last.tail[0]).toBe(second.head[0]);
|
||||
expect(last.tail[1]).toBe(boundary.head[0]);
|
||||
expect(last.head).toMatchObject([{ id: "message-1:run:3", text: "After." }]);
|
||||
});
|
||||
|
||||
it("keeps consecutive paragraphs in one selectable row", () => {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant("First paragraph.\n\nSecond paragraph."), timestamp: new Date(1000) },
|
||||
]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{
|
||||
id: `${source[0]!.id}:run:0`,
|
||||
text: "First paragraph.\n\nSecond paragraph.",
|
||||
markdownKind: "prose",
|
||||
blockGroupId: source[0]!.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream presentation through installed plugins", () => {
|
||||
it("offers every source tool call to an installed transformer in Overview mode", () => {
|
||||
const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")];
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type ToolCallDetailLevel,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
|
||||
import { groupMarkdownProse } from "@/components/markdown-prose/runs";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
@@ -53,7 +55,10 @@ function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageIt
|
||||
}
|
||||
|
||||
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
|
||||
export function createStreamPresentation() {
|
||||
export function createStreamPresentation({
|
||||
markdown = "blocks",
|
||||
}: { markdown?: "blocks" | "prose-runs" } = {}) {
|
||||
const runsBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
|
||||
function presentUserMessage(item: UserMessageItem): UserMessageItem {
|
||||
@@ -139,6 +144,27 @@ export function createStreamPresentation() {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
const blocks = nativeBlocks(item);
|
||||
if (markdown === "blocks" || item.kind !== "assistant_message") return blocks;
|
||||
const cached = runsBySource.get(item);
|
||||
if (cached) return cached;
|
||||
const previousSource = liveSources.get(item.id);
|
||||
const previous = previousSource && runsBySource.get(previousSource);
|
||||
const previousById = new Map(previous?.map((row) => [row.id, row]));
|
||||
const assistantBlocks = blocks.filter(
|
||||
(block): block is AssistantMessageItem => block.kind === "assistant_message",
|
||||
);
|
||||
const runs = groupMarkdownProse(assistantBlocks).map((row) => {
|
||||
const existing = previousById.get(row.id);
|
||||
return existing?.text === row.text && isReusableBlock(existing, item, row.id)
|
||||
? existing
|
||||
: row;
|
||||
});
|
||||
runsBySource.set(item, runs);
|
||||
return runs;
|
||||
}
|
||||
|
||||
return (input: PresentationInput) => {
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
@@ -146,7 +172,7 @@ export function createStreamPresentation() {
|
||||
// `blocksBySource` keeps both the live block identities and the split cost
|
||||
// from being paid again when only the tail's array identity changed.
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
nativeBlocks,
|
||||
presentRows,
|
||||
);
|
||||
historySource = input.tail;
|
||||
historyTransform = input.transform;
|
||||
@@ -155,7 +181,7 @@ export function createStreamPresentation() {
|
||||
const promoted: StreamItem[] = [];
|
||||
const nextLiveSources = new Map<string, AssistantMessageItem>();
|
||||
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
|
||||
const blocks = nativeBlocks(item);
|
||||
const blocks = presentRows(item);
|
||||
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
|
||||
if (item.kind === "assistant_message" && blocks.length > 1) {
|
||||
promoted.push(...blocks.slice(0, -1));
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { AssistantMessageItem } from "@/types/stream";
|
||||
|
||||
const parser = new MarkdownIt({ html: true });
|
||||
const partsByBlock = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
|
||||
function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
|
||||
const cached = partsByBlock.get(block);
|
||||
if (cached) return cached;
|
||||
const tokens = parser.parse(block.text, {});
|
||||
const preserved = tokens.some(
|
||||
(token) =>
|
||||
["fence", "code_block", "table_open", "html_block"].includes(token.type) ||
|
||||
token.children?.some((child) => child.type === "html_inline"),
|
||||
);
|
||||
const isQuote = tokens.some((token) => token.type === "blockquote_open");
|
||||
const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image"));
|
||||
let parts: AssistantMessageItem[];
|
||||
if (preserved || (isQuote && hasImage)) {
|
||||
parts = [{ ...block, markdownKind: "block" }];
|
||||
} else if (hasImage) {
|
||||
parts = splitParagraphImages(block);
|
||||
} else {
|
||||
parts = [{ ...block, markdownKind: isQuote ? "quote" : "prose" }];
|
||||
}
|
||||
partsByBlock.set(block, parts);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem[] {
|
||||
const parts: AssistantMessageItem[] = [];
|
||||
const state = new parser.inline.State(block.text, parser, {}, []);
|
||||
let cursor = 0;
|
||||
function append(text: string, markdownKind: "prose" | "block", offset: number) {
|
||||
if (!text.trim()) return;
|
||||
parts.push({ ...block, text: text.trim(), markdownKind, id: `${block.id}:part:${offset}` });
|
||||
}
|
||||
while (state.pos < state.posMax) {
|
||||
const start = state.pos;
|
||||
parser.inline.skipToken(state);
|
||||
const candidate = block.text.slice(start, state.pos);
|
||||
if (!candidate.includes("![")) continue;
|
||||
const inline = parser.parseInline(candidate, {})[0];
|
||||
if (!inline?.children?.some((child) => child.type === "image")) continue;
|
||||
append(block.text.slice(cursor, start), "prose", cursor);
|
||||
append(candidate, "block", start);
|
||||
cursor = state.pos;
|
||||
}
|
||||
append(block.text.slice(cursor), "prose", cursor);
|
||||
if (parts.length === 1) parts[0] = { ...parts[0]!, id: block.id };
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Native selection belongs to a text view, so adjacent prose shares one display row. */
|
||||
export function groupMarkdownProse(blocks: AssistantMessageItem[]): AssistantMessageItem[] {
|
||||
const rows: AssistantMessageItem[] = [];
|
||||
for (const block of blocks.flatMap(splitBlock)) {
|
||||
const previous = rows.at(-1);
|
||||
if (block.markdownKind === "prose" && previous?.markdownKind === "prose") {
|
||||
previous.text += `\n\n${block.text}`;
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
...block,
|
||||
id: block.markdownKind === "prose" ? block.id.replace(":block:", ":run:") : block.id,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -714,6 +714,7 @@ export interface AssistantMessageItem {
|
||||
/** Display-only fields, assigned after source-item plugin transforms. */
|
||||
blockGroupId?: string;
|
||||
blockIndex?: number;
|
||||
markdownKind?: "prose" | "quote" | "block";
|
||||
}
|
||||
|
||||
export interface TimelinePosition {
|
||||
|
||||
Reference in New Issue
Block a user