mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
1c1673cc35
* fix(ui): structure chat transcript attachments by kind
* Refine attachment previews
* Refine attachment cards and previews
* Refine audio attachment controls
* Refine attachment cards and previews
* Move audio timer before progress
* Add attachment link fixture
* Make attachment downloads secondary
* Add attachment file icon system
* Tune attachment icon sizing
* Compact attachment preview headers
* fix(ui): align large attachment card inset
* feat(ui): color attachment glyphs by family
* feat(ui): use icon-only attachment actions
* fix(ui): space compact attachment metadata
* fix(ui): finalize attachment card contracts
* Revert "fix(ui): finalize attachment card contracts"
This reverts commit 41b28970b5.
* fix(ui): reduce mobile waveform density
* fix(ui): restore native image rendering
* fix(ui): align and enlarge chat images
* fix(ui): restore open labels on file cards
* fix(ui): simplify labeled attachment actions
* fix(ui): size waveforms from container width
* fix(ui): update attachment open icon
* fix(ui): use continuous corners for chat images
* fix(ui): preserve sidebar video playback
* fix(ui): retain attachment kind in sidebar
* fix(ui): refresh open sidebar attachments
* fix(ui): bound delimited attachment previews
* fix(ui): harden attachment preview lifecycle
* fix(ui): reset lazy attachment previews
* style(ui): format attachment preview code
* fix(ui): align attachment sidebar content
* fix(ui): constrain CSV attachment previews
* fix(ui): keep text attachments compact
* fix(ui): block external document previews
* fix(ui): verify document iframe loads
* fix(ui): render honest audio waveforms
* test(ui): align attachment player expectations
* test(ui): exercise same-origin CSV previews
* test(ui): use browser-origin CSV fixtures
* style(ui): format attachment fixes
* fix(ui): normalize document preview URLs
* test(ui): clarify CSV fixture indices
* fix(ui): stabilize audio time label
* fix(ui): preserve external attachment downloads
* fix(ui): avoid eager video attachment loading
* fix(ui): parse TSV previews by attachment type
* fix(ui): retry managed attachment resolution
* style(ui): format attachment review fixes
* fix(ui): pass attachment type to table preview
* test(ui): normalize compact card label
* fix(ui): preserve sidebar attachment downloads
* test(ui): align attachment card expectations
* style(ui): order attachment imports
* fix(ui): isolate document attachment previews
* fix(ui): release Files attachment ownership
* fix(ui): resolve sidebar attachments from live context
* fix(ui): preserve attachment video aspect ratios
* style(ui): format attachment surfaces
* fix(ui): apply renewed video tickets on interaction
* fix(ui): restore visible attachment waveforms
* fix(ui): fade truncated CSV previews
* fix(ui): lazy load attachment previews in view
* fix(ui): preview image attachments inline
* test(ui): remove link attachment fixture
* fix(ui): block network in HTML attachment previews
* fix(ui): keep image hover actions stable
* feat(ui): preview SVG attachments safely
* perf(ui): split attachment preview styles
* fix(ui): harden attachment preview ownership
* refactor(ui): deslop attachment rendering
* refactor(ui): render transcript attachments as cards
* fix(ui): keep remote SVG attachments CSP-safe
* test(ui): keep chat pane suite within lint limit
* test(ui): cover Files shortcut cleanup
* fix(ui): normalize structured SVG metadata
* fix(ui): classify SVG attachments by source
* fix(ui): preserve attachment type precedence
* perf(ui): keep attachment CSS out of startup
611 lines
28 KiB
TypeScript
611 lines
28 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { deflateRawSync } from "node:zlib";
|
|
import type { Plugin } from "vite";
|
|
|
|
const CHAT_ATTACHMENT_FIXTURE_PATH = "/__fixtures/chat-attachments/";
|
|
const MANAGED_IMAGE_FIXTURE_PATH = "/api/chat/media/outgoing/chat-attachment-fixture/";
|
|
const ASSISTANT_MEDIA_FIXTURE_PATH = "/__openclaw__/assistant-media";
|
|
const FIXTURE_MEDIA_TICKET = "chat-attachment-fixture";
|
|
const RENEWING_MEDIA_FIXTURE_ROOT = "/tmp/openclaw-ticket/";
|
|
let renewingMediaTicketGeneration = 0;
|
|
|
|
type FixtureAsset = {
|
|
body: Buffer;
|
|
contentType: string;
|
|
};
|
|
|
|
function textAsset(body: string, contentType: string): FixtureAsset {
|
|
return { body: Buffer.from(body, "utf8"), contentType };
|
|
}
|
|
|
|
function crc32(body: Buffer): number {
|
|
let value = 0xffffffff;
|
|
for (const byte of body) {
|
|
value ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
value = (value >>> 1) ^ (0xedb88320 & -(value & 1));
|
|
}
|
|
}
|
|
return (value ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function zipAsset(entries: Record<string, string>, contentType: string): FixtureAsset {
|
|
const localParts: Buffer[] = [];
|
|
const centralParts: Buffer[] = [];
|
|
let localOffset = 0;
|
|
for (const [name, value] of Object.entries(entries)) {
|
|
const nameBytes = Buffer.from(name, "utf8");
|
|
const body = Buffer.from(value, "utf8");
|
|
const compressed = deflateRawSync(body);
|
|
const checksum = crc32(body);
|
|
const localHeader = Buffer.alloc(30 + nameBytes.length);
|
|
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
localHeader.writeUInt16LE(20, 4);
|
|
localHeader.writeUInt16LE(8, 8);
|
|
localHeader.writeUInt32LE(checksum, 14);
|
|
localHeader.writeUInt32LE(compressed.length, 18);
|
|
localHeader.writeUInt32LE(body.length, 22);
|
|
localHeader.writeUInt16LE(nameBytes.length, 26);
|
|
nameBytes.copy(localHeader, 30);
|
|
localParts.push(localHeader, compressed);
|
|
|
|
const centralHeader = Buffer.alloc(46 + nameBytes.length);
|
|
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
centralHeader.writeUInt16LE(20, 4);
|
|
centralHeader.writeUInt16LE(20, 6);
|
|
centralHeader.writeUInt16LE(8, 10);
|
|
centralHeader.writeUInt32LE(checksum, 16);
|
|
centralHeader.writeUInt32LE(compressed.length, 20);
|
|
centralHeader.writeUInt32LE(body.length, 24);
|
|
centralHeader.writeUInt16LE(nameBytes.length, 28);
|
|
centralHeader.writeUInt32LE(localOffset, 42);
|
|
nameBytes.copy(centralHeader, 46);
|
|
centralParts.push(centralHeader);
|
|
localOffset += localHeader.length + compressed.length;
|
|
}
|
|
const centralDirectory = Buffer.concat(centralParts);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(centralParts.length, 8);
|
|
end.writeUInt16LE(centralParts.length, 10);
|
|
end.writeUInt32LE(centralDirectory.length, 12);
|
|
end.writeUInt32LE(localOffset, 16);
|
|
return { body: Buffer.concat([...localParts, centralDirectory, end]), contentType };
|
|
}
|
|
|
|
function buildWavAsset(): FixtureAsset {
|
|
const sampleRate = 8_000;
|
|
const durationSeconds = 2;
|
|
const sampleCount = Math.floor(sampleRate * durationSeconds);
|
|
const body = Buffer.alloc(44 + sampleCount * 2);
|
|
body.write("RIFF", 0, "ascii");
|
|
body.writeUInt32LE(body.length - 8, 4);
|
|
body.write("WAVEfmt ", 8, "ascii");
|
|
body.writeUInt32LE(16, 16);
|
|
body.writeUInt16LE(1, 20);
|
|
body.writeUInt16LE(1, 22);
|
|
body.writeUInt32LE(sampleRate, 24);
|
|
body.writeUInt32LE(sampleRate * 2, 28);
|
|
body.writeUInt16LE(2, 32);
|
|
body.writeUInt16LE(16, 34);
|
|
body.write("data", 36, "ascii");
|
|
body.writeUInt32LE(sampleCount * 2, 40);
|
|
for (let index = 0; index < sampleCount; index += 1) {
|
|
const envelope = Math.min(1, index / 240, (sampleCount - index) / 240);
|
|
const modulation = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin((2 * Math.PI * 2 * index) / sampleRate));
|
|
const sample =
|
|
Math.sin((2 * Math.PI * 440 * index) / sampleRate) * envelope * modulation * 0.35;
|
|
body.writeInt16LE(Math.round(sample * 0x7fff), 44 + index * 2);
|
|
}
|
|
return { body, contentType: "audio/wav" };
|
|
}
|
|
|
|
const fallbackVideoBase64 =
|
|
"AAAAHGZ0eXBpc281AAACAGlzbzVpc282bXA0MQAAAuhtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAB6nRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAYAAAADYAAAAAAYZtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAACgAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAdmlkZQAAAAAAAAAAAAAAAFZpZGVvSGFuZGxlcgAAAAExbWluZgAAABR2bWhkAAAAAQAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAA8XN0YmwAAAClc3RzZAAAAAAAAAABAAAAlWF2YzEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAYAA2AEgAAABIAAAAAAAAAAEVTGF2YzYyLjI4LjEwMCBsaWJ4MjY0AAAAAAAAAAAAAAAY//8AAAAvYXZjQwFCwAr/4QAXZ0LACtoYn5sBEAAAAwAQAAADAKDxImoBAAVozgE3IAAAABBwYXNwAAAAAQAAAAEAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAChtdmV4AAAAIHRyZXgAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjYyLjEyLjEwMAAAAHRtb29mAAAAEG1maGQAAAAAAAAAAQAAAFx0cmFmAAAAHHRmaGQAAgA4AAAAAQAACAAAAAJ6AQEAAAAAABR0ZmR0AQAAAAAAAAAAAAAAAAAAJHRydW4AAAIFAAAAAwAAAHwCAAAAAAACegAAACkAAAAKAAACtW1kYXQAAAJTBgX//0/cRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIyIGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MCByZWY9MSBkZWJsb2NrPTA6MDowIGFuYWx5c2U9MDowIG1lPWRpYSBzdWJtZT0wIHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4ZWRfcmVmPTAgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0wIDh4OGRjdD0wIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJvbWFfcXBfb2Zmc2V0PTAgdGhyZWFkcz0yIGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MCB3ZWlnaHRwPTAga2V5aW50PTI1MCBrZXlpbnRfbWluPTUgc2NlbmVjdD0wIGludHJhX3JlZnJlc2g9MCByYz1jcmYgbWJ0cmVldD0wIGNyZj00NS4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTAAgAAAAB9liIQ6EYoABXxwACknJycnJ1111111111111111114AAAAJUGaIBOvV/q/1f6v9X+r58VxHiPEeI8R5/P5/P5/P5/P5/P5/P4AAAAGQZpAE6DMAAAAQ21mcmEAAAArdGZyYQEAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAMEAQEBAAAAEG1mcm8AAAAAAAAAQw==";
|
|
|
|
function buildVideoAsset(): FixtureAsset {
|
|
try {
|
|
const body = execFileSync(
|
|
"ffmpeg",
|
|
[
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=#2563eb:s=640x360:r=30",
|
|
"-t",
|
|
"1.5",
|
|
"-an",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-movflags",
|
|
"frag_keyframe+empty_moov",
|
|
"-f",
|
|
"mp4",
|
|
"pipe:1",
|
|
],
|
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
);
|
|
return { body, contentType: "video/mp4" };
|
|
} catch {
|
|
return { body: Buffer.from(fallbackVideoBase64, "base64"), contentType: "video/mp4" };
|
|
}
|
|
}
|
|
|
|
const buildChatAttachmentAssets = (): Record<string, FixtureAsset> => ({
|
|
"sample-image.svg": textAsset(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#172554"/><stop offset="1" stop-color="#be123c"/></linearGradient></defs><rect width="640" height="360" rx="24" fill="url(#g)"/><circle cx="150" cy="128" r="52" fill="#fbbf24" opacity=".9"/><path d="M0 300 190 178l100 64 90-88 260 146H0Z" fill="#0f172a" opacity=".8"/></svg>`,
|
|
"image/svg+xml",
|
|
),
|
|
"sample-image-secondary.svg": textAsset(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#064e3b"/><stop offset="1" stop-color="#1d4ed8"/></linearGradient></defs><rect width="640" height="360" rx="24" fill="url(#g)"/><circle cx="488" cy="106" r="58" fill="#a7f3d0" opacity=".9"/><path d="M0 310 150 198l112 72 132-122 246 162H0Z" fill="#082f49" opacity=".82"/></svg>`,
|
|
"image/svg+xml",
|
|
),
|
|
"sample-image.png": {
|
|
body: Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAoAAAAFoCAIAAABIUN0GAAAACXBIWXMAAAABAAAAAQBPJcTWAAAF2klEQVR4nO3XsW0CUQBEQZCIHRJdSgEU4C7cA9IVhOQSXAcF0AQBiRMqICF16nfcn6lgs6fd7g9fGwDgf+3qAQAwIgEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAARWG+Db97We8Jam07GeADCE1QYYAJZMgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEmIF8fJ7rCTC0x2WuJyyIAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQGC1AZ5Ox3oCAPxptQEGgCUTYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQFmII/LXE8AeBFgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASCw+/m91xsAYDgeMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABAQaAgAADQECAASAgwAAQEGAACAgwAAQEGAACAgwAAQEGgIAAA0BAgAEgIMAAEBBgAAgIMAAEBBgAAgIMAAEBBoCAAANAQIABICDAABAQYAAICDAABAQYAAICDAABJ5ZDRF+UoF83gAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
),
|
|
contentType: "image/png",
|
|
},
|
|
"sample-video.mp4": buildVideoAsset(),
|
|
"sample-audio.wav": buildWavAsset(),
|
|
"sample-audio-secondary.wav": buildWavAsset(),
|
|
"brief.pdf": {
|
|
body: Buffer.from(
|
|
"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVudHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNTAgPj4Kc3RyZWFtCkJUCi9GMSAyNCBUZgo3MiA3MjAgVGQKKEF0dGFjaG1lbnQgZml4dHVyZSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCjAwMDAwMDAyNDEgMDAwMDAgbiAKMDAwMDAwMDMxMSAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDYgL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjQxMAolJUVPRgo=",
|
|
"base64",
|
|
),
|
|
contentType: "application/pdf",
|
|
},
|
|
"notes.md": textAsset(
|
|
"# Attachment fixture\n\nA Markdown attachment delivered as a compact card.\n",
|
|
"text/markdown",
|
|
),
|
|
"notes.txt": textAsset(
|
|
"Plain text attachment.\nSecond line in the downloaded file.\n",
|
|
"text/plain",
|
|
),
|
|
"preview.html": textAsset(
|
|
'<!doctype html><html><body style="font:16px system-ui;padding:32px;color:#172033"><h1>HTML attachment</h1><p>This file is delivered as a compact card.</p></body></html>',
|
|
"text/html",
|
|
),
|
|
"styles.css": textAsset(".attachment-card {\n display: grid;\n gap: 12px;\n}\n", "text/css"),
|
|
"settings.json": textAsset(
|
|
'{\n "theme": "dark",\n "attachments": true\n}\n',
|
|
"application/json",
|
|
),
|
|
"rows.csv": textAsset("name,status\nalpha,ready\nbeta,pending\n", "text/csv"),
|
|
"wide.csv": textAsset(
|
|
`${Array.from({ length: 64 }, (_, index) => `column_${index + 1}`).join(",")}\n${Array.from(
|
|
{ length: 64 },
|
|
(_, index) => `value_${index + 1}`,
|
|
).join(",")}\n`,
|
|
"text/csv",
|
|
),
|
|
"report.xlsx": zipAsset(
|
|
{
|
|
"[Content_Types].xml":
|
|
'<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>',
|
|
"_rels/.rels":
|
|
'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
|
|
"xl/workbook.xml":
|
|
'<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Attachment data" sheetId="1" r:id="rId1"/></sheets></workbook>',
|
|
"xl/_rels/workbook.xml.rels":
|
|
'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>',
|
|
"xl/worksheets/sheet1.xml":
|
|
'<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Name</t></is></c><c r="B1" t="inlineStr"><is><t>Status</t></is></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>alpha</t></is></c><c r="B2" t="inlineStr"><is><t>ready</t></is></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>beta</t></is></c><c r="B3" t="inlineStr"><is><t>pending</t></is></c></row></sheetData></worksheet>',
|
|
},
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
),
|
|
"brief.docx": zipAsset(
|
|
{
|
|
"[Content_Types].xml":
|
|
'<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
|
|
"_rels/.rels":
|
|
'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
|
|
"word/document.xml":
|
|
'<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Attachment fixture document</w:t></w:r></w:p><w:sectPr/></w:body></w:document>',
|
|
},
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
),
|
|
"config.xml": textAsset(
|
|
'<?xml version="1.0"?><attachment><name>fixture</name><ready>true</ready></attachment>\n',
|
|
"application/xml",
|
|
),
|
|
"deploy.yaml": textAsset("name: attachment-fixture\nready: true\n", "application/yaml"),
|
|
"worker.py": textAsset("def ready():\n return True\n", "text/x-python"),
|
|
"vector.svg": textAsset(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="24" fill="#e76f3c"/></svg>',
|
|
"image/svg+xml",
|
|
),
|
|
"broken-vector.svg": textAsset("not an svg", "image/svg+xml"),
|
|
"mystery.blob": textAsset("Unknown attachment family fixture.\n", "application/octet-stream"),
|
|
"readme.rtf": textAsset("{\\rtf1\\ansi Attachment fixture document}", "application/rtf"),
|
|
"bundle.zip": zipAsset(
|
|
{ "README.txt": "Attachment fixture archive\n", "data.json": '{"ready":true}\n' },
|
|
"application/zip",
|
|
),
|
|
"script.js": textAsset("export function ready() {\n return true;\n}\n", "text/javascript"),
|
|
});
|
|
|
|
let chatAttachmentAssets: Record<string, FixtureAsset> | undefined;
|
|
|
|
function getChatAttachmentAssets(): Record<string, FixtureAsset> {
|
|
return (chatAttachmentAssets ??= buildChatAttachmentAssets());
|
|
}
|
|
|
|
function fixtureUrl(fileName: string): string {
|
|
return `${CHAT_ATTACHMENT_FIXTURE_PATH}${fileName}`;
|
|
}
|
|
|
|
function managedImageUrl(fileName: string): string {
|
|
return `${MANAGED_IMAGE_FIXTURE_PATH}${fileName}/thumbnail?mediaTicket=${FIXTURE_MEDIA_TICKET}`;
|
|
}
|
|
|
|
export function buildChatAttachmentHistory(baseTime: number): unknown[] {
|
|
const assets = getChatAttachmentAssets();
|
|
const assetSize = (fileName: string): number => {
|
|
const asset = assets[fileName];
|
|
if (!asset) {
|
|
throw new Error(`Missing chat attachment fixture asset: ${fileName}`);
|
|
}
|
|
return asset.body.byteLength;
|
|
};
|
|
const documentAttachment = (fileName: string, mimeType: string) => ({
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "document",
|
|
label: fileName,
|
|
mimeType,
|
|
url: fixtureUrl(fileName),
|
|
sizeBytes: assetSize(fileName),
|
|
},
|
|
});
|
|
const sectionTitle = (text: string, timestamp: number) => ({
|
|
role: "assistant",
|
|
content: [{ type: "text", text }],
|
|
timestamp,
|
|
});
|
|
return [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Images",
|
|
},
|
|
{
|
|
type: "image",
|
|
url: managedImageUrl("sample-image.svg"),
|
|
alt: "Attachment preview",
|
|
fileName: "sample-image.svg",
|
|
sizeBytes: assetSize("sample-image.svg"),
|
|
},
|
|
{
|
|
type: "image",
|
|
url: managedImageUrl("sample-image-secondary.svg"),
|
|
alt: "Secondary attachment preview",
|
|
fileName: "sample-image-secondary.svg",
|
|
sizeBytes: assetSize("sample-image-secondary.svg"),
|
|
},
|
|
],
|
|
timestamp: baseTime,
|
|
},
|
|
sectionTitle("Documents", baseTime + 1),
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
documentAttachment("notes.md", "text/markdown"),
|
|
documentAttachment("notes.txt", "text/plain"),
|
|
documentAttachment("styles.css", "text/css"),
|
|
documentAttachment("settings.json", "application/json"),
|
|
documentAttachment("script.js", "text/javascript"),
|
|
documentAttachment("brief.pdf", "application/pdf"),
|
|
documentAttachment(
|
|
"brief.docx",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
),
|
|
],
|
|
timestamp: baseTime + 2,
|
|
},
|
|
sectionTitle("File icon families", baseTime + 3),
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
documentAttachment("mystery.blob", "application/octet-stream"),
|
|
documentAttachment("sample-image.png", "image/png"),
|
|
documentAttachment("config.xml", "application/xml"),
|
|
documentAttachment("deploy.yaml", "application/yaml"),
|
|
documentAttachment("worker.py", "text/x-python"),
|
|
documentAttachment("vector.svg", "image/svg+xml"),
|
|
documentAttachment("broken-vector.svg", "image/svg+xml"),
|
|
documentAttachment("readme.rtf", "application/rtf"),
|
|
],
|
|
timestamp: baseTime + 4,
|
|
},
|
|
sectionTitle("HTML", baseTime + 5),
|
|
{
|
|
role: "assistant",
|
|
content: [documentAttachment("preview.html", "text/html")],
|
|
timestamp: baseTime + 6,
|
|
},
|
|
sectionTitle("CSV / XLSX", baseTime + 7),
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
documentAttachment("rows.csv", "text/csv"),
|
|
documentAttachment("wide.csv", "text/csv"),
|
|
documentAttachment(
|
|
"report.xlsx",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
),
|
|
],
|
|
timestamp: baseTime + 8,
|
|
},
|
|
sectionTitle("Audio", baseTime + 9),
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "audio",
|
|
label: "sample-audio.wav",
|
|
mimeType: "audio/wav",
|
|
url: fixtureUrl("sample-audio.wav"),
|
|
sizeBytes: assetSize("sample-audio.wav"),
|
|
durationMs: 2_000,
|
|
},
|
|
},
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "audio",
|
|
label: "sample-audio-secondary.wav",
|
|
mimeType: "audio/wav",
|
|
url: fixtureUrl("sample-audio-secondary.wav"),
|
|
sizeBytes: assetSize("sample-audio-secondary.wav"),
|
|
durationMs: 2_000,
|
|
},
|
|
},
|
|
],
|
|
timestamp: baseTime + 10,
|
|
},
|
|
sectionTitle("Video", baseTime + 11),
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "video",
|
|
label: "sample-video.mp4",
|
|
mimeType: "video/mp4",
|
|
url: fixtureUrl("sample-video.mp4"),
|
|
sizeBytes: assetSize("sample-video.mp4"),
|
|
durationMs: 1_500,
|
|
width: 640,
|
|
height: 360,
|
|
},
|
|
},
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "video",
|
|
label: "renewing-ticket-video.mp4",
|
|
mimeType: "video/mp4",
|
|
url: `${RENEWING_MEDIA_FIXTURE_ROOT}sample-video.mp4`,
|
|
sizeBytes: assetSize("sample-video.mp4"),
|
|
durationMs: 1_500,
|
|
width: 640,
|
|
height: 360,
|
|
},
|
|
},
|
|
],
|
|
timestamp: baseTime + 12,
|
|
},
|
|
sectionTitle("Archive", baseTime + 13),
|
|
{
|
|
role: "assistant",
|
|
content: [documentAttachment("bundle.zip", "application/zip")],
|
|
timestamp: baseTime + 14,
|
|
},
|
|
sectionTitle("Unavailable / failed / removed", baseTime + 17),
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "document",
|
|
label: "temporarily-unavailable.pdf",
|
|
mimeType: "application/pdf",
|
|
url: fixtureUrl("temporarily-unavailable.pdf"),
|
|
},
|
|
},
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "document",
|
|
label: "download-failed.zip",
|
|
mimeType: "application/zip",
|
|
url: fixtureUrl("download-failed.zip"),
|
|
},
|
|
},
|
|
{
|
|
type: "attachment",
|
|
attachment: {
|
|
kind: "document",
|
|
label: "removed-file.docx",
|
|
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
url: fixtureUrl("removed-file.docx"),
|
|
},
|
|
},
|
|
],
|
|
timestamp: baseTime + 18,
|
|
},
|
|
];
|
|
}
|
|
|
|
function readFixtureAsset(pathname: string): FixtureAsset | undefined {
|
|
const fileName = decodeURIComponent(pathname).split("/").pop() ?? "";
|
|
return getChatAttachmentAssets()[fileName];
|
|
}
|
|
|
|
function serveAsset(asset: FixtureAsset, req: IncomingMessage, res: ServerResponse): void {
|
|
const range = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/u);
|
|
res.setHeader("content-type", asset.contentType);
|
|
res.setHeader("cache-control", "no-store");
|
|
res.setHeader("accept-ranges", "bytes");
|
|
let start = 0;
|
|
let end = asset.body.length - 1;
|
|
if (range) {
|
|
const startText = range[1] ?? "";
|
|
const endText = range[2] ?? "";
|
|
if (startText) {
|
|
start = Number(startText);
|
|
end = endText ? Math.min(Number(endText), end) : end;
|
|
} else {
|
|
const suffixLength = Number(endText);
|
|
start = Math.max(0, asset.body.length - suffixLength);
|
|
}
|
|
if ((!startText && !endText) || start >= asset.body.length || start > end) {
|
|
res.statusCode = 416;
|
|
res.setHeader("content-range", `bytes */${asset.body.length}`);
|
|
res.setHeader("content-length", "0");
|
|
res.end();
|
|
return;
|
|
}
|
|
}
|
|
const body = asset.body.subarray(start, end + 1);
|
|
res.statusCode = range ? 206 : 200;
|
|
res.setHeader("content-length", String(body.length));
|
|
if (range) {
|
|
res.setHeader("content-range", `bytes ${start}-${end}/${asset.body.length}`);
|
|
}
|
|
if (req.method === "HEAD") {
|
|
res.end();
|
|
return;
|
|
}
|
|
res.end(body);
|
|
}
|
|
|
|
function serveFixtureAsset(
|
|
pathname: string,
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
next: (error?: unknown) => void,
|
|
): void {
|
|
const asset = readFixtureAsset(pathname);
|
|
if (!asset) {
|
|
next();
|
|
return;
|
|
}
|
|
serveAsset(asset, req, res);
|
|
}
|
|
|
|
function serveAssistantMedia(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
next: (error?: unknown) => void,
|
|
): void {
|
|
const requestUrl = new URL(req.url ?? ASSISTANT_MEDIA_FIXTURE_PATH, "http://127.0.0.1");
|
|
if (requestUrl.pathname !== ASSISTANT_MEDIA_FIXTURE_PATH) {
|
|
next();
|
|
return;
|
|
}
|
|
const source = requestUrl.searchParams.get("source") ?? "";
|
|
const fileName = decodeURIComponent(source).split("/").pop() ?? "";
|
|
const renewingTicket = source.startsWith(RENEWING_MEDIA_FIXTURE_ROOT);
|
|
const asset =
|
|
source.startsWith(CHAT_ATTACHMENT_FIXTURE_PATH) || renewingTicket
|
|
? readFixtureAsset(source)
|
|
: undefined;
|
|
if (requestUrl.searchParams.get("meta") === "1") {
|
|
res.statusCode = 200;
|
|
res.setHeader("content-type", "application/json");
|
|
res.setHeader("cache-control", "no-store");
|
|
if (!asset) {
|
|
const removed = fileName === "removed-file.docx";
|
|
const temporarilyUnavailable = fileName === "temporarily-unavailable.pdf";
|
|
res.end(
|
|
JSON.stringify({
|
|
available: false,
|
|
reason: removed
|
|
? "file was removed"
|
|
: temporarilyUnavailable
|
|
? "temporarily unavailable"
|
|
: "download failed",
|
|
retryable: !removed,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const mediaFacts =
|
|
fileName === "sample-video.mp4"
|
|
? { durationMs: 1_500, width: 640, height: 360, playback: "native" }
|
|
: fileName === "sample-audio.wav" || fileName === "sample-audio-secondary.wav"
|
|
? { durationMs: 2_000, playback: "native" }
|
|
: {};
|
|
const mediaTicket = renewingTicket
|
|
? renewingMediaTicketGeneration++ < 2
|
|
? "ticket-A"
|
|
: "ticket-B"
|
|
: FIXTURE_MEDIA_TICKET;
|
|
const mediaTicketExpiresAt =
|
|
Date.now() + (renewingTicket && mediaTicket === "ticket-A" ? 31_000 : 5 * 60_000);
|
|
res.end(
|
|
JSON.stringify({
|
|
available: true,
|
|
contentType: asset.contentType,
|
|
mediaTicket,
|
|
mediaTicketExpiresAt: new Date(mediaTicketExpiresAt).toISOString(),
|
|
sizeBytes: asset.body.byteLength,
|
|
...mediaFacts,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (!asset) {
|
|
res.statusCode = 404;
|
|
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
res.end("Attachment fixture not found");
|
|
return;
|
|
}
|
|
serveAsset(asset, req, res);
|
|
}
|
|
|
|
export function createChatAttachmentFixturePlugin(): Plugin {
|
|
return {
|
|
configureServer(server) {
|
|
server.middlewares.use((req, res, next) => {
|
|
const pathname = req.url?.split("?", 1)[0] ?? "";
|
|
if (pathname === ASSISTANT_MEDIA_FIXTURE_PATH) {
|
|
serveAssistantMedia(req, res, next);
|
|
return;
|
|
}
|
|
if (pathname.startsWith(CHAT_ATTACHMENT_FIXTURE_PATH)) {
|
|
serveFixtureAsset(pathname.slice(CHAT_ATTACHMENT_FIXTURE_PATH.length), req, res, next);
|
|
return;
|
|
}
|
|
if (pathname.startsWith(MANAGED_IMAGE_FIXTURE_PATH)) {
|
|
const fileName = pathname.slice(MANAGED_IMAGE_FIXTURE_PATH.length).split("/", 1)[0];
|
|
if (fileName && getChatAttachmentAssets()[fileName]?.contentType.startsWith("image/")) {
|
|
serveFixtureAsset(fileName, req, res, next);
|
|
return;
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
},
|
|
enforce: "pre",
|
|
name: "openclaw-control-ui-chat-attachment-fixture",
|
|
};
|
|
}
|