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
6 changes: 6 additions & 0 deletions .changeset/real-file-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tarko/agent-server': patch
'@tarko/agent-ui': patch
---

Persist non-image chat attachments in the Agent workspace and include their safe relative paths in prompts so agents can access uploaded data files.
62 changes: 62 additions & 0 deletions multimodal/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions multimodal/tarko/agent-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"dependencies": {
"@tarko/agent-ui-builder": "workspace:*",
"@tarko/context-engineer": "workspace:*",
"multer": "2.2.0",
"mongoose": "^8.8.4",
"transliteration": "^2.3.5"
},
Expand All @@ -33,6 +34,7 @@
"@tarko/shared-utils": "workspace:*",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "2.2.0",
"@types/node": "22.15.30",
"@types/supertest": "^6.0.2",
"cors": "^2.8.5",
Expand Down
141 changes: 141 additions & 0 deletions multimodal/tarko/agent-server/src/api/controllers/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
* SPDX-License-Identifier: Apache-2.0
*/

import type { Request, RequestHandler, Response } from 'express';
import fs from 'fs';
import path from 'path';
import multer from 'multer';
import { nanoid } from 'nanoid';

export const MAX_UPLOAD_FILE_SIZE = 20 * 1024 * 1024;
export const MAX_UPLOAD_FILE_COUNT = 10;
const UPLOAD_DIRECTORY = 'uploads';

/**
* Remove path components and characters that would make an uploaded file
* difficult to reference from a chat message.
*/
export function sanitizeFileName(originalName: string): string {
const baseName = path.basename(originalName).normalize('NFKC');
const sanitized = baseName
.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^[.\-]+|[.\-]+$/g, '');

return sanitized || 'file';
}

export function createStoredFileName(originalName: string): string {
const sanitizedName = sanitizeFileName(originalName);
const extension = path.extname(sanitizedName);
const stem =
path
.basename(sanitizedName, extension)
.replace(/[.\-]+$/g, '')
.slice(0, 120) || 'file';
const safeExtension = extension.slice(0, 20);

return `${stem}-${nanoid(10)}${safeExtension}`;
}

function getUploadDirectory(req: Request): string {
const workspacePath = path.resolve(req.app.locals.server.getCurrentWorkspace());
const uploadDirectory = path.resolve(workspacePath, UPLOAD_DIRECTORY);
const relativePath = path.relative(workspacePath, uploadDirectory);

if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new Error('Upload directory is outside the configured workspace');
}

return uploadDirectory;
}

const upload = multer({
storage: multer.diskStorage({
destination(req, _file, callback) {
let uploadDirectory: string;
try {
uploadDirectory = getUploadDirectory(req);
} catch (error) {
callback(error as Error, '');
return;
}

fs.mkdir(uploadDirectory, { recursive: true }, (error) => {
callback(error, uploadDirectory);
});
},
filename(_req, file, callback) {
callback(null, createStoredFileName(file.originalname));
},
}),
limits: {
fileSize: MAX_UPLOAD_FILE_SIZE,
files: MAX_UPLOAD_FILE_COUNT,
},
});

/**
* Parse multipart uploads and keep Multer errors in the JSON API contract.
*/
export const uploadFilesMiddleware: RequestHandler = (req, res, next) => {
upload.array('files', MAX_UPLOAD_FILE_COUNT)(req, res, (error) => {
if (!error) {
next();
return;
}

if (error instanceof multer.MulterError) {
const status = error.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
res.status(status).json({
error:
error.code === 'LIMIT_FILE_SIZE'
? `Each file must be ${MAX_UPLOAD_FILE_SIZE / 1024 / 1024}MB or smaller`
: error.message,
code: error.code,
});
return;
}

console.error('Failed to receive uploaded files:', error);
res.status(500).json({ error: 'Failed to receive uploaded files' });
});
};

/**
* Return safe workspace-relative paths for files already persisted by Multer.
*/
export function uploadFiles(req: Request, res: Response) {
const files = req.files as Express.Multer.File[] | undefined;

if (!files?.length) {
return res.status(400).json({ error: 'No files uploaded' });
}

try {
const workspacePath = path.resolve(req.app.locals.server.getCurrentWorkspace());
const uploadedFiles = files.map((file) => {
const relativePath = path.relative(workspacePath, file.path);

if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new Error('Uploaded file was written outside the configured workspace');
}

return {
name: path.basename(file.originalname),
storedName: file.filename,
relativePath: relativePath.split(path.sep).join('/'),
size: file.size,
mimeType: file.mimetype,
};
});

return res.status(200).json({ files: uploadedFiles });
} catch (error) {
console.error('Failed to finalize uploaded files:', error);
return res.status(500).json({ error: 'Failed to finalize uploaded files' });
}
}
1 change: 1 addition & 0 deletions multimodal/tarko/agent-server/src/api/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './queries';
export * from './system';
export * from './share';
export * from './oneshot';
export * from './files';
17 changes: 17 additions & 0 deletions multimodal/tarko/agent-server/src/api/routes/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
* SPDX-License-Identifier: Apache-2.0
*/

import type express from 'express';
import { uploadFiles, uploadFilesMiddleware } from '../controllers/files';

/**
* Register file upload routes.
*
* Uploads are workspace-scoped instead of session-scoped so an attachment can
* be selected on the welcome page before the first session is created.
*/
export function registerFileRoutes(app: express.Application): void {
app.post('/api/v1/files/upload', uploadFilesMiddleware, uploadFiles);
}
2 changes: 2 additions & 0 deletions multimodal/tarko/agent-server/src/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { registerQueryRoutes } from './queries';
import { registerSystemRoutes } from './system';
import { registerShareRoutes } from './share';
import { registerOneshotRoutes } from './oneshot';
import { registerFileRoutes } from './files';

/**
* Register all API routes with the Express application
Expand All @@ -20,4 +21,5 @@ export function registerAllRoutes(app: express.Application): void {
registerSystemRoutes(app);
registerShareRoutes(app);
registerOneshotRoutes(app);
registerFileRoutes(app);
}
Loading