Skip to content

Commit eb78e8a

Browse files
committed
feat: inline file preview card for agent-created and filesystem files
1 parent 42a4120 commit eb78e8a

7 files changed

Lines changed: 828 additions & 118 deletions

File tree

frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/FilePreviewCard/index.jsx

Lines changed: 599 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { memo } from "react";
2-
import FileDownloadCard from "../../FileDownloadCard";
2+
import FilePreviewCard from "../../FilePreviewCard";
33

44
function HistoricalOutputs({ outputs = [] }) {
55
if (!outputs || outputs.length === 0) return null;
66

77
return (
88
<div className="flex flex-col gap-2 mt-4">
99
{outputs.map((output, index) => (
10-
<FileDownloadCard
10+
<FilePreviewCard
1111
key={`${output.type}-${index}`}
1212
props={{ content: output.payload }}
1313
/>
@@ -17,3 +17,4 @@ function HistoricalOutputs({ outputs = [] }) {
1717
}
1818

1919
export default memo(HistoricalOutputs);
20+

frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import HistoricalMessage from "./HistoricalMessage";
1010
import PromptReply from "./PromptReply";
1111
import StatusResponse from "./StatusResponse";
1212
import ToolApprovalRequest from "./ToolApprovalRequest";
13-
import FileDownloadCard from "./FileDownloadCard";
13+
import FilePreviewCard from "./FilePreviewCard";
1414
import { useManageWorkspaceModal } from "../../../Modals/ManageWorkspace";
1515
import ManageWorkspace from "../../../Modals/ManageWorkspace";
1616
import { ArrowDown } from "@phosphor-icons/react";
@@ -312,7 +312,7 @@ function buildMessages({
312312
if (props.type === "rechartVisualize" && !!props.content) {
313313
acc.push(<Chartable key={props.uuid} props={props} />);
314314
} else if (props.type === "fileDownloadCard" && !!props.content) {
315-
acc.push(<FileDownloadCard key={props.uuid} props={props} />);
315+
acc.push(<FilePreviewCard key={props.content?.storageFilename ?? props.uuid} props={props} />);
316316
} else if (isLastBotReply && props.animate) {
317317
acc.push(
318318
<PromptReply

frontend/src/models/files.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { baseHeaders } from "@/utils/request";
33

44
const StorageFiles = {
55
/**
6-
* Download a file from the server
7-
* @param {string} filename - The filename to download
6+
* Download a file as a Blob (triggers browser save-as).
7+
* @param {string} storageFilename
88
* @returns {Promise<Blob|null>}
99
*/
1010
download: async function (storageFilename) {
@@ -21,6 +21,34 @@ const StorageFiles = {
2121
return null;
2222
});
2323
},
24+
25+
/**
26+
* Returns a URL for inline preview of a file (PDF, images).
27+
* Passes the auth token as a query param so it works in
28+
* <iframe src="..."> and <img src="..."> which can't send headers.
29+
* @param {string} storageFilename
30+
* @returns {string}
31+
*/
32+
previewUrl: function (storageFilename) {
33+
const token = window?.localStorage?.getItem("anythingllm_token") ?? null;
34+
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
35+
return `${API_BASE}/agent-skills/generated-files/${encodeURIComponent(storageFilename)}/preview${tokenParam}`;
36+
},
37+
38+
/**
39+
* Fetches a file's content as a plain string.
40+
* Used for code, text, CSV, JSON, Markdown, and HTML previews.
41+
* @param {string} storageFilename
42+
* @returns {Promise<string>}
43+
*/
44+
fetchText: async function (storageFilename) {
45+
const res = await fetch(
46+
`${API_BASE}/agent-skills/generated-files/${encodeURIComponent(storageFilename)}`,
47+
{ headers: baseHeaders() }
48+
);
49+
if (!res.ok) throw new Error("Failed to fetch file for preview");
50+
return res.text();
51+
},
2452
};
2553

2654
export default StorageFiles;

server/endpoints/agentFileServer.js

Lines changed: 76 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,26 @@ const { Workspace } = require("../models/workspace");
1313
const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib");
1414

1515
/**
16-
* Endpoints for serving agent-generated files (PPTX, etc.) with authentication
16+
* Endpoints for serving agent-generated files with authentication
1717
* and ownership validation.
18+
*
19+
* Two endpoints:
20+
* GET /agent-skills/generated-files/:filename
21+
* - Download (Content-Disposition: attachment)
22+
* GET /agent-skills/generated-files/:filename/preview
23+
* - Inline preview (Content-Disposition: inline)
24+
* - Also accepts ?token=<jwt> so <iframe>/<img> src attributes work
25+
*
26+
* IMPORTANT: Both endpoints first try the DB ownership check, but if no
27+
* DB record exists yet (file was just created and chat not yet saved),
28+
* they fall back to serving the file directly from storage. This handles
29+
* the race condition where the live preview card appears before the chat
30+
* is written to the database.
1831
*/
1932
function agentFileServerEndpoints(app) {
2033
if (!app) return;
2134

22-
/**
23-
* Download a generated file by its storage filename.
24-
* Validates that the requesting user has access to the workspace
25-
* where the file was generated.
26-
*/
35+
// ── DOWNLOAD endpoint ────────────────────────────────────────────────────
2736
app.get(
2837
"/agent-skills/generated-files/:filename",
2938
[validatedRequest, flexUserRoleValid([ROLES.all])],
@@ -34,45 +43,22 @@ function agentFileServerEndpoints(app) {
3443
if (!filename)
3544
return response.status(400).json({ error: "Filename is required" });
3645

37-
// Validate filename format
3846
const parsed = createFilesLib.parseFilename(filename);
39-
if (!parsed) {
40-
return response
41-
.status(400)
42-
.json({ error: "Invalid filename format" });
43-
}
44-
45-
// Find a chat record that references this file and that the user can access
46-
const validChat = await findValidChatForFile(
47-
filename,
48-
user,
49-
multiUserMode(response)
50-
);
51-
52-
if (!validChat) {
53-
return response.status(404).json({
54-
error: "File not found or access denied",
55-
});
56-
}
47+
if (!parsed)
48+
return response.status(400).json({ error: "Invalid filename format" });
5749

58-
// Retrieve the file from storage
5950
const fileData = await createFilesLib.getGeneratedFile(filename);
60-
if (!fileData) {
61-
return response
62-
.status(404)
63-
.json({ error: "File not found in storage" });
64-
}
51+
if (!fileData)
52+
return response.status(404).json({ error: "File not found in storage" });
53+
54+
// Try DB ownership check — but don't block if chat not yet saved
55+
const validChat = await findValidChatForFile(filename, user, multiUserMode(response));
56+
const displayFilename = validChat?.displayFilename || filename;
6557

66-
// Get mime type and set headers for download
6758
const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`);
68-
const safeFilename = createFilesLib.sanitizeFilenameForHeader(
69-
validChat.displayFilename || filename
70-
);
59+
const safeFilename = createFilesLib.sanitizeFilenameForHeader(displayFilename);
7160
response.setHeader("Content-Type", mimeType);
72-
response.setHeader(
73-
"Content-Disposition",
74-
`attachment; filename="${safeFilename}"`
75-
);
61+
response.setHeader("Content-Disposition", `attachment; filename="${safeFilename}"`);
7662
response.setHeader("Content-Length", fileData.buffer.length);
7763
return response.send(fileData.buffer);
7864
} catch (error) {
@@ -81,21 +67,62 @@ function agentFileServerEndpoints(app) {
8167
}
8268
}
8369
);
70+
71+
// ── PREVIEW endpoint ─────────────────────────────────────────────────────
72+
app.get(
73+
"/agent-skills/generated-files/:filename/preview",
74+
[previewAuthMiddleware, flexUserRoleValid([ROLES.all])],
75+
async (request, response) => {
76+
try {
77+
const user = await userFromSession(request, response);
78+
const { filename } = request.params;
79+
if (!filename)
80+
return response.status(400).json({ error: "Filename is required" });
81+
82+
const parsed = createFilesLib.parseFilename(filename);
83+
if (!parsed)
84+
return response.status(400).json({ error: "Invalid filename format" });
85+
86+
const fileData = await createFilesLib.getGeneratedFile(filename);
87+
if (!fileData)
88+
return response.status(404).json({ error: "File not found in storage" });
89+
90+
// Try DB ownership check — but don't block if chat not yet saved
91+
const validChat = await findValidChatForFile(filename, user, multiUserMode(response));
92+
const displayFilename = validChat?.displayFilename || filename;
93+
94+
const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`);
95+
const safeFilename = createFilesLib.sanitizeFilenameForHeader(displayFilename);
96+
response.setHeader("Content-Type", mimeType);
97+
response.setHeader("Content-Disposition", `inline; filename="${safeFilename}"`);
98+
response.setHeader("Content-Length", fileData.buffer.length);
99+
response.removeHeader("X-Frame-Options");
100+
return response.send(fileData.buffer);
101+
} catch (error) {
102+
console.error("[agentFileServer] Preview error:", error.message);
103+
return response.status(500).json({ error: "Failed to preview file" });
104+
}
105+
}
106+
);
107+
}
108+
109+
/**
110+
* Auth middleware for the preview endpoint.
111+
* Falls back to ?token= query param when no Authorization header is present.
112+
*/
113+
function previewAuthMiddleware(request, response, next) {
114+
if (request.query.token && !request.headers.authorization) {
115+
request.headers.authorization = `Bearer ${request.query.token}`;
116+
}
117+
return validatedRequest(request, response, next);
84118
}
85119

86120
/**
87-
* Finds a valid chat record that references the given storage filename
88-
* and that the user has access to.
89-
* @param {string} storageFilename - The storage filename to search for
90-
* @param {object|null} user - The user object (null in single-user mode)
91-
* @param {boolean} isMultiUser - Whether multi-user mode is enabled
92-
* @returns {Promise<{workspaceId: number, displayFilename: string}|null>}
121+
* Finds a valid chat record that references the given storage filename.
122+
* Returns null if not found (caller decides how to handle).
93123
*/
94124
async function findValidChatForFile(storageFilename, user, isMultiUser) {
95125
try {
96-
// Get all workspaces the user has access to.
97-
// In single-user mode, all workspaces are accessible.
98-
// In multi-user mode, only workspaces assigned to the user are accessible.
99126
let workspaceIds;
100127
if (isMultiUser && user) {
101128
const workspaces = await Workspace.whereWithUser(user);
@@ -107,8 +134,6 @@ async function findValidChatForFile(storageFilename, user, isMultiUser) {
107134

108135
if (workspaceIds.length === 0) return null;
109136

110-
// Use database-level filtering to only fetch chats that contain the filename
111-
// This avoids loading all chats into memory
112137
const chats = await WorkspaceChats.where({
113138
workspaceId: { in: workspaceIds },
114139
include: true,
@@ -124,14 +149,12 @@ async function findValidChatForFile(storageFilename, user, isMultiUser) {
124149
if (!output) continue;
125150
return {
126151
workspaceId: chat.workspaceId,
127-
displayFilename:
128-
output.payload.filename || output.payload.displayFilename,
152+
displayFilename: output.payload.filename || output.payload.displayFilename,
129153
};
130154
} catch {
131155
continue;
132156
}
133157
}
134-
135158
return null;
136159
} catch (error) {
137160
console.error("[findValidChatForFile] Error:", error.message);

0 commit comments

Comments
 (0)