1import { getFilePreviewKind } from "../../lib/filePreviewKind";2 3export type CanvasRequestSurface = "spreadsheet" | "document";4 5export type CanvasRequest = {6
/** Which embedded canvas surface produced this request. */
7 surface: CanvasRequestSurface;
8 fileName: string | null;
9 /** File kind hint (e.g. "xlsx", "csv", "markdown", "slide", "text"). */
10 fileKind: string | null;
11 /** Spreadsheet only: active sheet name. */
12 sheet: string | null;
13 /** Spreadsheet only: selected range or active cell, in A1 notation. */
14 region: string | null;
15 /** Selected preview text — a spreadsheet cell value or a document selection. */
16 selectionText: string | null;
17 userRequest: string;
18};
19
20function unescapeXml(value: string): string {
21 // Order matters: decode "&" last so a literal "<" isn't double-decoded.
22 return value
23 .replace(/</g, "<")
24 .replace(/>/g, ">")
25 .replace(/"/g, '"')
26 .replace(/'/g, "'")
27 .replace(/&/g, "&");
28}
29
30function firstCapture(text: string, pattern: RegExp): string | null {
31 const match = text.match(pattern);
32 if (!match || match[1] === undefined) return null;
33 const decoded = unescapeXml(match[1]).trim();
34 return decoded.length > 0 ? decoded : null;
35}
36
37// `<spreadsheet_canvas_request>` — see `lib/univerSpreadsheet.ts`.
38function parseSpreadsheetEnvelope(text: string): CanvasRequest | null {
39 const userRequestMatch = text.match(/<user_request>([\s\S]*?)<\/user_request>/);
40 if (!userRequestMatch) return null;
41
42 const selectionMatch = text.match(/<selection\s+range="([^"]*)"\s+active_cell="([^"]*)"/);
43 const range = selectionMatch ? unescapeXml(selectionMatch[1]).trim() : "";
44 const activeCell = selectionMatch ? unescapeXml(selectionMatch[2]).trim() : "";
45
46 return {
47 surface: "spreadsheet",
48 fileName: firstCapture(text, /<workbook\b[^>]*?\sfile_name="([^"]*)"/),
49 fileKind: firstCapture(text, /<workbook\b[^>]*?\skind="([^"]*)"/),
50 sheet: firstCapture(text, /<active_sheet>([\s\S]*?)<\/active_sheet>/),
51 region: range || activeCell || null,
52 selectionText: firstCapture(text, /<selection\b[^>]*>\s*<value>([\s\S]*?)<\/value>/),
53 userRequest: unescapeXml(userRequestMatch[1]).trim(),
54 };
55}
56
57// `<canvas_request>` — see `lib/canvasRequest.ts`.
58function parseDocumentEnvelope(text: string): CanvasRequest | null {
59 const userRequestMatch = text.match(/<user_request>([\s\S]*?)<\/user_request>/);
60 if (!userRequestMatch) return null;
61
62 return {
63 surface: "document",
64 fileName: firstCapture(text, /<file\b[^>]*?\sname="([^"]*)"/),
65 fileKind: firstCapture(text, /<file\b[^>]*?\skind="([^"]*)"/),
66 sheet: null,
67 region: null,
68 selectionText: firstCapture(text, /<selection>([\s\S]*?)<\/selection>/),
69 userRequest: unescapeXml(userRequestMatch[1]).trim(),
70 };
71}
72
73// Legacy markdown envelope from older document-canvas builds, kept so historical
74// transcripts render through the same bubble.
75function parseLegacyCanvasEdit(text: string): CanvasRequest | null {
76 const instMarker = "**Instructions:**\n";
77 const instIdx = text.indexOf(instMarker);
78 if (instIdx === -1) return null;
79
80 const fileName = text.match(/edit the file `([^`]+)`/)?.[1]?.trim() ?? null;
81 const rest = text.slice(instIdx + instMarker.length);
82 const targetMarker = "\n\n**Target Section / Selection:**";
83 const targetIdx = rest.indexOf(targetMarker);
84
85 let instructions = rest;
86 let selection: string | null = null;
87 if (targetIdx !== -1) {
88 instructions = rest.slice(0, targetIdx);
89 const selPart = rest.slice(targetIdx + targetMarker.length).trim();
90 selection = selPart.startsWith(">") ? selPart.slice(1).trim() : selPart;
91 }
92
93 return {
94 surface: "document",
95 fileName,
96 fileKind: null,
97 sheet: null,
98 region: null,
99 selectionText: selection ? selection.trim() || null : null,
100 userRequest: instructions.trim(),
101 };
102}
103
104/**
105 * Parse any embedded-canvas request a user message may carry so the transcript
106 * can render a compact file/region header above the request instead of the raw
107 * envelope. Handles the spreadsheet XML envelope, the document XML envelope, and
108 * the legacy markdown envelope from older builds. Returns null for ordinary
109 * messages.
110 */
111export function parseCanvasRequest(text: string): CanvasRequest | null {
112 const trimmed = text.trim();
113 if (trimmed.startsWith("<spreadsheet_canvas_request")) return parseSpreadsheetEnvelope(trimmed);
114 if (trimmed.startsWith("<canvas_request")) return parseDocumentEnvelope(trimmed);
115 if (trimmed.startsWith("[Canvas Collaborative Edit]")) return parseLegacyCanvasEdit(trimmed);
116 return null;
117}
118
119function looksLikeCanvasEnvelope(text: string): boolean {
120 const trimmed = text.trim();
121 return (
122 trimmed.startsWith("<spreadsheet_canvas_request") ||
123 trimmed.startsWith("<canvas_request") ||
124 trimmed.startsWith("[Canvas Collaborative Edit]")
125 );
126}
127
128function inferCanvasSurface(text: string): CanvasRequestSurface {
129 return text.trim().startsWith("<spreadsheet_canvas_request") ? "spreadsheet" : "document";
130}
131
132/**
133 * Recover a compact canvas model from a stored envelope, including malformed
134 * or partial payloads. Never returns null for a recognized envelope — the
135 * transcript can always show a readable chip instead of raw serialization.
136 */
137export function interpretCanvasRequest(text: string): CanvasRequest | null {
138 const trimmed = text.trim();
139 if (!looksLikeCanvasEnvelope(trimmed)) return null;
140
141 const parsed = parseCanvasRequest(trimmed);
142 if (parsed) return parsed;
143
144 const userRequest = firstCapture(trimmed, /<user_request>([\s\S]*?)<\/user_request>/) ?? "";
145 const fileName =
146 firstCapture(trimmed, /<workbook\b[^>]*?\sfile_name="([^"]*)"/) ??
147 firstCapture(trimmed, /<file\b[^>]*?\sname="([^"]*)"/) ??
148 trimmed.match(/edit the file `([^`]+)`/)?.[1]?.trim() ??
149 null;
150
151 return {
152 surface: inferCanvasSurface(trimmed),
153 fileName,
154 fileKind: firstCapture(trimmed, /\skind="([^"]*)"/),
155 sheet: firstCapture(trimmed, /<active_sheet>([\s\S]*?)<\/active_sheet>/),
156 region: firstCapture(trimmed, /\srange="([^"]*)"/),
157 selectionText:
158 firstCapture(trimmed, /<selection\b[^>]*>\s*<value>([\s\S]*?)<\/value>/) ??
159 firstCapture(trimmed, /<selection>([\s\S]*?)<\/selection>/),
160 userRequest,
161 };
162}
163
164function parseAttachmentNameList(raw: string): string[] {
165 const unwrapped = raw
166 .trim()
167 .replace(/^\[[\s\u00A0]*/, "")
168 .replace(/[\s\u00A0]*\]$/, "");
169 if (!unwrapped) return [];
170 return unwrapped
171 .split(/,\s+/)
172 .map((name) => name.trim())
173 .filter(Boolean);
174}
175
176export function parseUserMessageAttachments(text: string): {
177 cleanText: string;
178 fileNames: string[];
179} {
180 const attachedMatch = text.match(/\n\nAttached:\s+\[(.*?)\]\s*$/);
181 if (attachedMatch) {
182 return {
183 cleanText: text.substring(0, attachedMatch.index).trim(),
184 fileNames: parseAttachmentNameList(attachedMatch[1]),
185 };
186 }
187
188 const attachedLooseMatch = text.match(/\n\nAttached:\s*(\S[\s\S]*)$/);
189 if (attachedLooseMatch) {
190 return {
191 cleanText: text.substring(0, attachedLooseMatch.index).trim(),
192 fileNames: parseAttachmentNameList(attachedLooseMatch[1]),
193 };
194 }
195
196 const onlyAttachmentsMatch = text.match(/^\[(.*?)\]\s*$/);
197 if (onlyAttachmentsMatch) {
198 return {
199 cleanText: "",
200 fileNames: parseAttachmentNameList(onlyAttachmentsMatch[1]),
201 };
202 }
203
204 return { cleanText: text, fileNames: [] };
205}
206
207export type VisibleUserAttachment = {
208 fileName: string;
209 displayName: string;
210 isImage: boolean;
211};
212
213export type VisibleUserMessage = {
214 bodyText: string;
215 attachments: VisibleUserAttachment[];
216 canvas: CanvasRequest | null;
217 copyText: string;
218};
219
220function attachmentDisplayName(fileName: string): string {
221 const normalized = fileName.replace(/\\/g, "/").trim();
222 const base = normalized.split("/").pop()?.trim();
223 if (!base || base === "." || base === "..") return fileName.trim();
224 return base;
225}
226
227export function canvasFallbackName(surface: CanvasRequestSurface): string {
228 return surface === "spreadsheet" ? "Spreadsheet" : "Document";
229}
230
231function formatCanvasCopyText(request: CanvasRequest): string {
232 const header = [
233 request.fileName ?? canvasFallbackName(request.surface),
234 request.sheet,
235 request.region,
236 ]
237 .filter((part): part is string => Boolean(part?.trim()))
238 .join(" · ");
239 const lines: string[] = [];
240 if (header) lines.push(header);
241 if (request.selectionText) lines.push(`\u201C${request.selectionText}\u201D`);
242 if (request.userRequest) lines.push(request.userRequest);
243 return lines.join("\n");
244}
245
246function formatAttachmentCopyText(attachments: readonly VisibleUserAttachment[]): string {
247 const names = attachments.map((attachment) => attachment.displayName).filter(Boolean);
248 if (names.length === 0) return "";
249 if (names.length === 1) return names[0];
250 return `Attached: ${names.join(", ")}`;
251}
252
253function formatVisibleUserCopyText(opts: {
254 bodyText: string;
255 attachments: readonly VisibleUserAttachment[];
256 canvas: CanvasRequest | null;
257}): string {
258 if (opts.canvas) {
259 const canvasText = formatCanvasCopyText(opts.canvas);
260 const attached = formatAttachmentCopyText(opts.attachments);
261 if (canvasText && attached) return `${canvasText}\n\n${attached}`;
262 return canvasText || attached;
263 }
264 const attached = formatAttachmentCopyText(opts.attachments);
265 const body = opts.bodyText.trim();
266 if (body && attached) return `${body}\n\n${attached}`;
267 return body || attached;
268}
269
270function isImageAttachmentName(fileName: string): boolean {
271 return (
272 getFilePreviewKind(fileName) === "image" ||
273 getFilePreviewKind(attachmentDisplayName(fileName)) === "image"
274 );
275}
276
277/**
278 * One semantic view of a persisted user turn: visible body, attachments, canvas
279 * context, and the clipboard string. Callers must not copy or render `rawText`
280 * once this model exists — that string can contain attachment/Canvas markup.
281 */
282export function buildVisibleUserMessage(rawText: string): VisibleUserMessage {
283 const parsed = parseUserMessageAttachments(rawText);
284 const canvas = interpretCanvasRequest(parsed.cleanText);
285 const attachments = parsed.fileNames.map((fileName) => ({
286 fileName,
287 displayName: attachmentDisplayName(fileName),
288 isImage: isImageAttachmentName(fileName),
289 }));
290 const bodyText = canvas ? canvas.userRequest : parsed.cleanText;
291 return {
292 bodyText,
293 attachments,
294 canvas,
295 copyText: formatVisibleUserCopyText({
296 bodyText: canvas ? canvas.userRequest : parsed.cleanText,
297 attachments,
298 canvas,
299 }),
300 };
301}
302