Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { memo } from "react";
import FileDownloadCard from "../../FileDownloadCard";
import FilePreviewCard from "../../FilePreviewCard";

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

return (
<div className="flex flex-col gap-2 mt-4">
{outputs.map((output, index) => (
<FileDownloadCard
<FilePreviewCard
key={`${output.type}-${index}`}
props={{ content: output.payload }}
/>
Expand All @@ -17,3 +17,4 @@ function HistoricalOutputs({ outputs = [] }) {
}

export default memo(HistoricalOutputs);

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import HistoricalMessage from "./HistoricalMessage";
import PromptReply from "./PromptReply";
import StatusResponse from "./StatusResponse";
import ToolApprovalRequest from "./ToolApprovalRequest";
import FileDownloadCard from "./FileDownloadCard";
import FilePreviewCard from "./FilePreviewCard";
import { useManageWorkspaceModal } from "../../../Modals/ManageWorkspace";
import ManageWorkspace from "../../../Modals/ManageWorkspace";
import { ArrowDown } from "@phosphor-icons/react";
Expand Down Expand Up @@ -312,7 +312,7 @@ function buildMessages({
if (props.type === "rechartVisualize" && !!props.content) {
acc.push(<Chartable key={props.uuid} props={props} />);
} else if (props.type === "fileDownloadCard" && !!props.content) {
acc.push(<FileDownloadCard key={props.uuid} props={props} />);
acc.push(<FilePreviewCard key={props.content?.storageFilename ?? props.uuid} props={props} />);
} else if (isLastBotReply && props.animate) {
acc.push(
<PromptReply
Expand Down
32 changes: 30 additions & 2 deletions frontend/src/models/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { baseHeaders } from "@/utils/request";

const StorageFiles = {
/**
* Download a file from the server
* @param {string} filename - The filename to download
* Download a file as a Blob (triggers browser save-as).
* @param {string} storageFilename
* @returns {Promise<Blob|null>}
*/
download: async function (storageFilename) {
Expand All @@ -21,6 +21,34 @@ const StorageFiles = {
return null;
});
},

/**
* Returns a URL for inline preview of a file (PDF, images).
* Passes the auth token as a query param so it works in
* <iframe src="..."> and <img src="..."> which can't send headers.
* @param {string} storageFilename
* @returns {string}
*/
previewUrl: function (storageFilename) {
const token = window?.localStorage?.getItem("anythingllm_token") ?? null;
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
return `${API_BASE}/agent-skills/generated-files/${encodeURIComponent(storageFilename)}/preview${tokenParam}`;
},

/**
* Fetches a file's content as a plain string.
* Used for code, text, CSV, JSON, Markdown, and HTML previews.
* @param {string} storageFilename
* @returns {Promise<string>}
*/
fetchText: async function (storageFilename) {
const res = await fetch(
`${API_BASE}/agent-skills/generated-files/${encodeURIComponent(storageFilename)}`,
{ headers: baseHeaders() }
);
if (!res.ok) throw new Error("Failed to fetch file for preview");
return res.text();
},
};

export default StorageFiles;
129 changes: 76 additions & 53 deletions server/endpoints/agentFileServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,26 @@ const { Workspace } = require("../models/workspace");
const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib");

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

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

// Validate filename format
const parsed = createFilesLib.parseFilename(filename);
if (!parsed) {
return response
.status(400)
.json({ error: "Invalid filename format" });
}

// Find a chat record that references this file and that the user can access
const validChat = await findValidChatForFile(
filename,
user,
multiUserMode(response)
);

if (!validChat) {
return response.status(404).json({
error: "File not found or access denied",
});
}
if (!parsed)
return response.status(400).json({ error: "Invalid filename format" });

// Retrieve the file from storage
const fileData = await createFilesLib.getGeneratedFile(filename);
if (!fileData) {
return response
.status(404)
.json({ error: "File not found in storage" });
}
if (!fileData)
return response.status(404).json({ error: "File not found in storage" });

// Try DB ownership check — but don't block if chat not yet saved
const validChat = await findValidChatForFile(filename, user, multiUserMode(response));
const displayFilename = validChat?.displayFilename || filename;

// Get mime type and set headers for download
const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`);
const safeFilename = createFilesLib.sanitizeFilenameForHeader(
validChat.displayFilename || filename
);
const safeFilename = createFilesLib.sanitizeFilenameForHeader(displayFilename);
response.setHeader("Content-Type", mimeType);
response.setHeader(
"Content-Disposition",
`attachment; filename="${safeFilename}"`
);
response.setHeader("Content-Disposition", `attachment; filename="${safeFilename}"`);
response.setHeader("Content-Length", fileData.buffer.length);
return response.send(fileData.buffer);
} catch (error) {
Expand All @@ -81,21 +67,62 @@ function agentFileServerEndpoints(app) {
}
}
);

// ── PREVIEW endpoint ─────────────────────────────────────────────────────
app.get(
"/agent-skills/generated-files/:filename/preview",
[previewAuthMiddleware, flexUserRoleValid([ROLES.all])],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const { filename } = request.params;
if (!filename)
return response.status(400).json({ error: "Filename is required" });

const parsed = createFilesLib.parseFilename(filename);
if (!parsed)
return response.status(400).json({ error: "Invalid filename format" });

const fileData = await createFilesLib.getGeneratedFile(filename);
if (!fileData)
return response.status(404).json({ error: "File not found in storage" });

// Try DB ownership check — but don't block if chat not yet saved
const validChat = await findValidChatForFile(filename, user, multiUserMode(response));
const displayFilename = validChat?.displayFilename || filename;

const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`);
const safeFilename = createFilesLib.sanitizeFilenameForHeader(displayFilename);
response.setHeader("Content-Type", mimeType);
response.setHeader("Content-Disposition", `inline; filename="${safeFilename}"`);
response.setHeader("Content-Length", fileData.buffer.length);
response.removeHeader("X-Frame-Options");
return response.send(fileData.buffer);
} catch (error) {
console.error("[agentFileServer] Preview error:", error.message);
return response.status(500).json({ error: "Failed to preview file" });
}
}
);
}

/**
* Auth middleware for the preview endpoint.
* Falls back to ?token= query param when no Authorization header is present.
*/
function previewAuthMiddleware(request, response, next) {
if (request.query.token && !request.headers.authorization) {
request.headers.authorization = `Bearer ${request.query.token}`;
}
return validatedRequest(request, response, next);
}

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

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

// Use database-level filtering to only fetch chats that contain the filename
// This avoids loading all chats into memory
const chats = await WorkspaceChats.where({
workspaceId: { in: workspaceIds },
include: true,
Expand All @@ -124,14 +149,12 @@ async function findValidChatForFile(storageFilename, user, isMultiUser) {
if (!output) continue;
return {
workspaceId: chat.workspaceId,
displayFilename:
output.payload.filename || output.payload.displayFilename,
displayFilename: output.payload.filename || output.payload.displayFilename,
};
} catch {
continue;
}
}

return null;
} catch (error) {
console.error("[findValidChatForFile] Error:", error.message);
Expand Down
Loading