Skip to content

Commit b2c4598

Browse files
committed
Finish extensionless pages and document staging
1 parent a38f006 commit b2c4598

10 files changed

Lines changed: 88 additions & 207 deletions

File tree

apps/sim/app/api/files/utils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,16 @@ export function encodeFilenameForHeader(storageKey: string): string {
239239
export function createFileResponse(file: FileResponse): NextResponse {
240240
const { contentType, disposition } = getSecureFileHeaders(file.filename, file.contentType)
241241

242+
// Sim pages store an extensionless name and serve/download as compiled
243+
// HTML — re-append the extension so the saved file opens in a browser.
244+
const servedFilename =
245+
contentType === 'text/html' && !/\.[A-Za-z0-9]{1,8}$/.test(file.filename)
246+
? `${file.filename}.html`
247+
: file.filename
248+
242249
const headers: Record<string, string> = {
243250
'Content-Type': contentType,
244-
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`,
251+
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(servedFilename)}`,
245252
// Default to PRIVATE: this response is served only after access verification, so it must never be
246253
// stored by a shared cache/CDN and re-served cross-user. Genuinely public assets (avatars, OG images,
247254
// workspace logos) pass an explicit `cacheControl` (see PUBLIC_ASSET_CACHE_CONTROL in the serve route).

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ const MIME_TYPE_LABELS: Record<string, string> = {
208208
'text/csv': 'CSV',
209209
'text/plain': 'Text',
210210
'text/html': 'HTML',
211+
'text/x-sim-page': 'Page',
211212
'text/markdown': 'Markdown',
212213
}
213214

apps/sim/components/icons/document-icons.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ export function getDocumentIcon(
301301
return ChartFileIcon
302302
}
303303

304+
// Sim pages present as plain documents, not as HTML artifacts — the .html
305+
// is an implementation detail (legacy pages still carry the extension).
306+
if (mimeType === 'text/x-sim-page') {
307+
return DefaultFileIcon
308+
}
309+
304310
if (mimeType === 'text/html' || extension === 'html' || extension === 'htm') {
305311
return HtmlIcon
306312
}

apps/sim/lib/copilot/tools/server/files/create-file.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createWorkspaceFileByPath,
1414
updateWorkspaceFileContentByPath,
1515
} from '@/lib/workspace-files/application/write-workspace-file-by-path'
16+
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
1617

1718
const logger = createLogger('CreateFileServerTool')
1819
const CREATE_FILE_TOOL_ID = 'create_empty_file'
@@ -62,6 +63,11 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
6263
// creation) — or when the first apply_file_edit finds actual page
6364
// source, which re-stamps the record from reality either way.
6465
const contentType = outputFile?.mimeType ?? inferContentType(outputPath, explicitType)
66+
// A Sim page's stored name drops the .html the agent signals format with:
67+
// the record type carries the format, every surface then shows the bare
68+
// name with the plain file icon, and downloads re-append the extension.
69+
const storedPath =
70+
contentType === SIM_PAGE_CONTENT_TYPE ? outputPath.replace(/\.html?$/i, '') : outputPath
6571
assertServerToolNotAborted(context)
6672
const mode = outputFile?.mode ?? 'create'
6773
// An empty shell provably contains no secrets; recording that keeps the
@@ -71,7 +77,7 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
7177
const createShell = () =>
7278
executeCopilotFileUseCase(context, createWorkspaceFileByPath, {
7379
workspaceId,
74-
path: outputPath,
80+
path: storedPath,
7581
mode: 'create',
7682
content: '',
7783
encoding: 'utf-8',
@@ -85,7 +91,7 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
8591
try {
8692
result = await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, {
8793
workspaceId,
88-
path: outputPath,
94+
path: storedPath,
8995
mode,
9096
content: '',
9197
encoding: 'utf-8',

apps/sim/lib/copilot/tools/server/files/doc-asset-extract-pdf.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ const EXTRACT_TIMEOUT_MS = 180_000
1717
* row adjacency and recomposites them into an RGBA PNG; masks are never
1818
* shipped as standalone assets.
1919
*
20+
* Assets are shipped at presentation resolution: print-dpi originals are
21+
* downscaled to a 2560px long edge (alpha preserved) and exotic formats
22+
* (TIFF/JP2) are normalized to PNG/JPEG, so the extracted set stays inside
23+
* the doc-compile staging budget and re-stages fast on every slide edit.
24+
* Images already within range keep their original bytes.
25+
*
2026
* Unlike OOXML there is no declared theme in a PDF, so the palette is
2127
* explicitly labeled inferred; fonts are names only (embedded font files are
2228
* subsetted and license-restricted).
@@ -167,6 +173,42 @@ for num, ref in alpha_of.items():
167173
except Exception:
168174
pass # undecodable mask: the opaque base still beats losing the asset
169175
176+
# Downscale print-resolution assets and normalize exotic formats. Embedded PDF
177+
# images are often 300-dpi originals several MB each, a slide never shows more
178+
# than ~2560px on the long edge, and every deck compile re-stages the whole
179+
# referenced set — so oversized extractions slow every edit and blow the byte
180+
# staging budget. Images already within range keep their original bytes.
181+
MAX_ASSET_EDGE = 2560
182+
shipped_dims = {}
183+
for num, path in list(files.items()):
184+
try:
185+
im = Image.open(path)
186+
im.load()
187+
except Exception:
188+
continue # undecodable (jbig2/ccitt params etc.): ship as-is
189+
ext = path.rsplit(".", 1)[1].lower()
190+
exotic = ext not in ("png", "jpg", "jpeg")
191+
w, h = im.size
192+
scale = MAX_ASSET_EDGE / float(max(w, h))
193+
if scale >= 1 and not exotic:
194+
continue
195+
if scale < 1:
196+
im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
197+
has_alpha = im.mode in ("RGBA", "LA", "PA") or (im.mode == "P" and "transparency" in im.info)
198+
try:
199+
if ext in ("jpg", "jpeg") or (exotic and not has_alpha):
200+
out = path.rsplit(".", 1)[0] + ".jpg"
201+
im.convert("RGB").save(out, "JPEG", quality=85, optimize=True)
202+
else:
203+
out = path.rsplit(".", 1)[0] + ".png"
204+
im.convert("RGBA" if has_alpha else "RGB").save(out, "PNG", optimize=True)
205+
except Exception:
206+
continue # unencodable: keep the original bytes
207+
if out != path:
208+
os.remove(path)
209+
files[num] = out
210+
shipped_dims[num] = im.size
211+
170212
def to_hex(color):
171213
if color is None:
172214
return None
@@ -350,10 +392,11 @@ for num, path in sorted(files.items()):
350392
]
351393
with open(path, "rb") as f:
352394
data = base64.b64encode(f.read()).decode()
395+
dims = shipped_dims.get(num)
353396
images.append({
354397
"name": "image%d.%s" % (num, ext),
355-
"widthPx": row["width"],
356-
"heightPx": row["height"],
398+
"widthPx": dims[0] if dims else row["width"],
399+
"heightPx": dims[1] if dims else row["height"],
357400
"placements": pls,
358401
"base64": data,
359402
})

apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -108,32 +108,12 @@ describe('collectReferencedFileIds', () => {
108108
expect(collectReferencedFileIds(`slide.addText('hello', { x: 1, y: 1 })`)).toEqual(new Set())
109109
})
110110

111-
it('retains all references at the remote staging limit', () => {
112-
const ids = collectReferencedFileIds(referencedFileSource(20))
113-
114-
expect(ids.size).toBe(20)
115-
expect(ids.has('file-19')).toBe(true)
116-
})
117-
118-
it('stops collecting at the one-over-limit sentinel', () => {
111+
it('has no reference-count limit — a large deck rebuild references every extracted image', () => {
119112
const ids = collectReferencedFileIds(referencedFileSource(100))
120113

121-
expect(ids.size).toBe(21)
122-
expect(ids.has('file-20')).toBe(true)
123-
expect(ids.has('file-21')).toBe(false)
124-
})
125-
126-
it('rejects remote compilation at 21 references before resolving file metadata', async () => {
127-
await expect(
128-
compileDoc({
129-
source: referencedFileSource(21),
130-
fileName: 'report.pdf',
131-
workspaceId: 'workspace-1',
132-
filePrincipal: FILE_PRINCIPAL,
133-
})
134-
).rejects.toThrow('More than 20 referenced input files; maximum is 20')
135-
expect(readWorkspaceFileMetadataMock).not.toHaveBeenCalled()
136-
expect(executeInSandboxMock).not.toHaveBeenCalled()
114+
expect(ids.size).toBe(100)
115+
expect(ids.has('file-0')).toBe(true)
116+
expect(ids.has('file-99')).toBe(true)
137117
})
138118

139119
it('compiles referenced bytes and returns their canonical contributor identity', async () => {

apps/sim/lib/copilot/tools/server/files/doc-compile.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ const FILE_HELPER_RE =
106106
/\b(?:getFileBase64|addImage|drawImage)\(\s*(?:[A-Za-z_$][\w$]*\s*,\s*)?['"]([A-Za-z0-9_-]+)['"]/g
107107

108108
// The doc source is user/LLM-controlled, so bound how much it can pull into the
109-
// sandbox: each `/home/user/inputs/<id>` reference is only ~35 bytes, so the
110-
// source-size cap alone does not bound staging. These caps prevent an
111-
// authenticated member from forcing thousands of (or very large) workspace files
112-
// to be downloaded and base64-held in-process per compile request.
113-
const MAX_STAGED_INPUTS = 20
109+
// sandbox by BYTES (per file and total) — an authenticated member must not be
110+
// able to force very large workspace downloads to be base64-held in-process per
111+
// compile. There is deliberately no count cap: a deck rebuild references every
112+
// extracted image across the whole source (references accumulate over slides),
113+
// and the byte caps already bound the volume.
114114
const MAX_STAGED_FILE_BYTES = 25 * 1024 * 1024
115115
const MAX_STAGED_TOTAL_BYTES = 50 * 1024 * 1024
116116

@@ -147,16 +147,14 @@ function referencedImageIdentities(
147147
* image-helper call sites and the legacy `/home/user/inputs/<id>` path. Matching
148148
* is scoped to the helper calls (not bare id-like strings in slide text), and the
149149
* caller skips any id that does not resolve to a real file, so over-matching is
150-
* harmless. Retention stops at one over the remote staging limit: callers need
151-
* only distinguish no references, an admissible set, and an oversized set.
150+
* harmless.
152151
*/
153152
export function collectReferencedFileIds(source: string): Set<string> {
154153
const ids = new Set<string>()
155154
for (const re of [INPUT_PATH_RE, FILE_HELPER_RE]) {
156155
for (const match of source.matchAll(re)) {
157156
if (match[1]) {
158157
ids.add(match[1])
159-
if (ids.size > MAX_STAGED_INPUTS) return ids
160158
}
161159
}
162160
}
@@ -169,11 +167,6 @@ async function resolveReferencedImages(
169167
principal: Principal,
170168
ids = collectReferencedFileIds(source)
171169
): Promise<ReferencedImageResolution> {
172-
if (ids.size > MAX_STAGED_INPUTS) {
173-
throw new Error(
174-
`More than ${MAX_STAGED_INPUTS} referenced input files; maximum is ${MAX_STAGED_INPUTS}. Reference fewer files.`
175-
)
176-
}
177170
if (ids.size === 0) {
178171
return { images: [], referenceCount: 0 }
179172
}
@@ -207,12 +200,6 @@ async function stageReferencedImages(
207200
workspaceId: string,
208201
principal: Principal
209202
): Promise<SandboxFile[]> {
210-
if (resolution.referenceCount > MAX_STAGED_INPUTS) {
211-
throw new Error(
212-
`More than ${MAX_STAGED_INPUTS} referenced input files; maximum is ${MAX_STAGED_INPUTS}. Reference fewer files.`
213-
)
214-
}
215-
216203
const files: SandboxFile[] = []
217204
let totalBytes = 0
218205
for (const { fileId, record } of resolution.images) {
@@ -225,8 +212,8 @@ async function stageReferencedImages(
225212
continue
226213
}
227214
if (totalBytes + (record.size ?? 0) > MAX_STAGED_TOTAL_BYTES) {
228-
throw new Error(
229-
`Referenced input files exceed the ${MAX_STAGED_TOTAL_BYTES} byte staging budget.`
215+
throw new DocCompileUserError(
216+
`Referenced input files exceed the ${Math.round(MAX_STAGED_TOTAL_BYTES / (1024 * 1024))} MB total staging budget (the whole document's references count). Use smaller/compressed copies of the largest images.`
230217
)
231218
}
232219
const content = await readWorkspaceFileContent.execute({
@@ -263,8 +250,10 @@ async function stageReferencedImages(
263250
// outside the catch above so it fails the compile rather than being skipped.
264251
totalBytes += buffer.length
265252
if (totalBytes > MAX_STAGED_TOTAL_BYTES) {
266-
throw new Error(
267-
`Referenced input files exceed the ${MAX_STAGED_TOTAL_BYTES} byte staging budget.`
253+
// A user-fixable condition, not a transient failure: retrying with the
254+
// same references can never succeed — the images must shrink.
255+
throw new DocCompileUserError(
256+
`Referenced input files exceed the ${Math.round(MAX_STAGED_TOTAL_BYTES / (1024 * 1024))} MB total staging budget (the whole document's references count). Use smaller/compressed copies of the largest images.`
268257
)
269258
}
270259
files.push({
@@ -608,7 +597,7 @@ export async function loadCompiledDocByExt(
608597
const fmt = await getE2BDocFormat(`x.${ext}`)
609598
if (!fmt) return null
610599
const referencedFileIds = collectReferencedFileIds(source)
611-
if (referencedFileIds.size > MAX_STAGED_INPUTS || !options.filePrincipal) {
600+
if (!options.filePrincipal) {
612601
if (referencedFileIds.size === 0) {
613602
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
614603
return buffer ? { buffer, contentType: fmt.contentType } : null

apps/sim/lib/copilot/tools/server/files/edit-content.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
9494
// the source format, so it is rejected with the steer back to source.
9595
// Patches are exempt: small in-place fixes on a legacy stored-compiled
9696
// page legitimately contain compiled fragments.
97-
const isHtmlTarget = fileRecord.name.toLowerCase().endsWith('.html')
97+
// Sim pages store an extensionless name; the record type marks them.
98+
const isHtmlTarget =
99+
fileRecord.name.toLowerCase().endsWith('.html') || fileRecord.type === SIM_PAGE_CONTENT_TYPE
98100
if (
99101
isHtmlTarget &&
100102
(operation === 'append' || operation === 'update') &&

apps/sim/lib/workspace-files/page-chart-ssr.server.test.ts

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)