diff --git a/.changeset/real-file-uploads.md b/.changeset/real-file-uploads.md new file mode 100644 index 0000000000..6be647ce50 --- /dev/null +++ b/.changeset/real-file-uploads.md @@ -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. diff --git a/multimodal/pnpm-lock.yaml b/multimodal/pnpm-lock.yaml index 5e3d4dc589..de7a7c5b39 100644 --- a/multimodal/pnpm-lock.yaml +++ b/multimodal/pnpm-lock.yaml @@ -889,6 +889,9 @@ importers: mongoose: specifier: ^8.8.4 version: 8.18.0(@aws-sdk/credential-providers@3.883.0)(@mongodb-js/zstd@2.0.1)(kerberos@2.2.2)(snappy@7.3.2)(socks@2.8.4) + multer: + specifier: 2.2.0 + version: 2.2.0 transliteration: specifier: ^2.3.5 version: 2.3.5 @@ -917,6 +920,9 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.22 + '@types/multer': + specifier: 2.2.0 + version: 2.2.0 '@types/node': specifier: 22.15.30 version: 22.15.30 @@ -1130,6 +1136,9 @@ importers: tailwindcss: specifier: ^3.4.15 version: 3.4.17(ts-node@10.9.2(@types/node@22.15.30)(typescript@5.8.3)) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(jiti@2.6.1)(sass-embedded@1.89.0)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) tarko/agent-ui-builder: dependencies: @@ -5985,6 +5994,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + '@types/node-fetch@2.6.9': resolution: {integrity: sha512-bQVlnMLFJ2d35DkPNjEPmd9ueO/rh5EiaZt2bhqiSarPjZIuIV6bPQVqcrEyvNo+AfTrRGVazle1tl597w3gfA==} @@ -6339,6 +6351,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + appium-adb@13.0.3: resolution: {integrity: sha512-nce9sc6/fTN2pzs9y+38WqEfhyBxe67uQZFgMzs7b6IU08YX6N8j1pdzcKgl1jLCenhGPKWyc6Ca45/PQGg2uw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} @@ -6679,6 +6694,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -6972,6 +6991,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + conf@14.0.0: resolution: {integrity: sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==} engines: {node: '>=20'} @@ -9829,6 +9852,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -11418,6 +11445,10 @@ packages: stream-combiner@0.2.2: resolution: {integrity: sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + streamx@2.22.0: resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} @@ -11925,6 +11956,9 @@ packages: resolution: {integrity: sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -20990,6 +21024,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.2.0': + dependencies: + '@types/express': 4.17.22 + '@types/node-fetch@2.6.9': dependencies: '@types/node': 22.15.30 @@ -21452,6 +21490,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + append-field@1.0.0: {} + appium-adb@13.0.3: dependencies: '@appium/support': 7.0.1 @@ -21889,6 +21929,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.1.2: {} cac@6.7.14: {} @@ -22172,6 +22216,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + conf@14.0.0: dependencies: ajv: 8.17.1 @@ -26100,6 +26151,13 @@ snapshots: ms@2.1.3: {} + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + mute-stream@0.0.8: {} mz@2.7.0: @@ -28033,6 +28091,8 @@ snapshots: duplexer: 0.1.2 through: 2.3.8 + streamsearch@1.1.0: {} + streamx@2.22.0: dependencies: fast-fifo: 1.3.2 @@ -28656,6 +28716,8 @@ snapshots: typed-array-buffer: 1.0.3 typed-array-byte-offset: 1.0.4 + typedarray@0.0.6: {} + typescript@5.8.3: {} ufo@1.6.1: {} diff --git a/multimodal/tarko/agent-server/package.json b/multimodal/tarko/agent-server/package.json index ceeeacadd7..b50ff32fc5 100644 --- a/multimodal/tarko/agent-server/package.json +++ b/multimodal/tarko/agent-server/package.json @@ -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" }, @@ -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", diff --git a/multimodal/tarko/agent-server/src/api/controllers/files.ts b/multimodal/tarko/agent-server/src/api/controllers/files.ts new file mode 100644 index 0000000000..76a8287a8a --- /dev/null +++ b/multimodal/tarko/agent-server/src/api/controllers/files.ts @@ -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' }); + } +} diff --git a/multimodal/tarko/agent-server/src/api/controllers/index.ts b/multimodal/tarko/agent-server/src/api/controllers/index.ts index af9b923f51..5ba94ad7b4 100644 --- a/multimodal/tarko/agent-server/src/api/controllers/index.ts +++ b/multimodal/tarko/agent-server/src/api/controllers/index.ts @@ -8,3 +8,4 @@ export * from './queries'; export * from './system'; export * from './share'; export * from './oneshot'; +export * from './files'; diff --git a/multimodal/tarko/agent-server/src/api/routes/files.ts b/multimodal/tarko/agent-server/src/api/routes/files.ts new file mode 100644 index 0000000000..ae04139033 --- /dev/null +++ b/multimodal/tarko/agent-server/src/api/routes/files.ts @@ -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); +} diff --git a/multimodal/tarko/agent-server/src/api/routes/index.ts b/multimodal/tarko/agent-server/src/api/routes/index.ts index ad161d2472..52fe914a5d 100644 --- a/multimodal/tarko/agent-server/src/api/routes/index.ts +++ b/multimodal/tarko/agent-server/src/api/routes/index.ts @@ -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 @@ -20,4 +21,5 @@ export function registerAllRoutes(app: express.Application): void { registerSystemRoutes(app); registerShareRoutes(app); registerOneshotRoutes(app); + registerFileRoutes(app); } diff --git a/multimodal/tarko/agent-server/tests/api/files.test.ts b/multimodal/tarko/agent-server/tests/api/files.test.ts new file mode 100644 index 0000000000..9309fdd451 --- /dev/null +++ b/multimodal/tarko/agent-server/tests/api/files.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Bytedance, Inc. and its affiliates. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('express'); +vi.unmock('fs'); +vi.unmock('http'); + +import express from 'express'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; +import { + MAX_UPLOAD_FILE_SIZE, + createStoredFileName, + sanitizeFileName, + uploadFiles, + uploadFilesMiddleware, +} from '../../src/api/controllers/files'; +import { csrfProtectionMiddleware } from '../../src/api/middleware/csrf-protection'; +import { registerCsrfRoutes } from '../../src/api/routes/csrf'; +import { registerFileRoutes } from '../../src/api/routes/files'; + +describe('file upload API', () => { + let workspacePath: string; + let app: express.Application; + + beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'tarko-file-upload-')); + app = express(); + app.locals.server = { getCurrentWorkspace: () => workspacePath }; + app.post('/api/v1/files/upload', uploadFilesMiddleware, uploadFiles); + }); + + afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); + + it('persists a data file and returns a safe workspace-relative path', async () => { + const content = 'date,value\n2025-01-01,42\n'; + const response = await request(app) + .post('/api/v1/files/upload') + .attach('files', Buffer.from(content), 'quarterly report?.csv') + .expect(200); + + expect(response.body.files).toHaveLength(1); + expect(response.body.files[0]).toMatchObject({ + name: 'quarterly report?.csv', + size: Buffer.byteLength(content), + mimeType: 'text/csv', + }); + expect(response.body.files[0].relativePath).toMatch( + /^uploads\/quarterly-report-[A-Za-z0-9_-]{10}\.csv$/, + ); + + const storedPath = path.join(workspacePath, response.body.files[0].relativePath); + expect(fs.readFileSync(storedPath, 'utf8')).toBe(content); + }); + + it('supports multiple files in one request', async () => { + const response = await request(app) + .post('/api/v1/files/upload') + .attach('files', Buffer.from('{"ok":true}'), 'data.json') + .attach('files', Buffer.from('a,b\n1,2\n'), 'data.csv') + .expect(200); + + expect(response.body.files).toHaveLength(2); + expect(response.body.files.map((file: { relativePath: string }) => file.relativePath)).toEqual([ + expect.stringMatching(/^uploads\/data-[A-Za-z0-9_-]{10}\.json$/), + expect.stringMatching(/^uploads\/data-[A-Za-z0-9_-]{10}\.csv$/), + ]); + }); + + it('rejects an empty upload', async () => { + const response = await request(app).post('/api/v1/files/upload').expect(400); + expect(response.body).toEqual({ error: 'No files uploaded' }); + }); + + it('uses the production CSRF contract for multipart uploads', async () => { + const securedApp = express(); + securedApp.locals.server = { getCurrentWorkspace: () => workspacePath }; + securedApp.use(express.json()); + registerCsrfRoutes(securedApp); + securedApp.use(csrfProtectionMiddleware); + registerFileRoutes(securedApp); + + await request(securedApp) + .post('/api/v1/files/upload') + .attach('files', Buffer.from('blocked'), 'blocked.csv') + .expect(403); + + const tokenResponse = await request(securedApp).get('/api/v1/csrf-token').expect(200); + const response = await request(securedApp) + .post('/api/v1/files/upload') + .set('X-CSRF-Token', tokenResponse.body.token) + .attach('files', Buffer.from('allowed'), 'allowed.csv') + .expect(200); + + expect(response.body.files[0].relativePath).toMatch( + /^uploads\/allowed-[A-Za-z0-9_-]{10}\.csv$/, + ); + }); + + it('rejects files above the per-file size limit', async () => { + const response = await request(app) + .post('/api/v1/files/upload') + .attach('files', Buffer.alloc(MAX_UPLOAD_FILE_SIZE + 1), 'oversized.csv') + .expect(413); + + expect(response.body).toMatchObject({ code: 'LIMIT_FILE_SIZE' }); + }); + + it('sanitizes names and adds a collision-resistant suffix', () => { + expect(sanitizeFileName('../../unsafe data?.csv')).toBe('unsafe-data-.csv'); + expect(createStoredFileName('../../unsafe data?.csv')).toMatch( + /^unsafe-data-[A-Za-z0-9_-]{10}\.csv$/, + ); + }); +}); diff --git a/multimodal/tarko/agent-ui/package.json b/multimodal/tarko/agent-ui/package.json index d5dd03d3a3..d4691db1aa 100644 --- a/multimodal/tarko/agent-ui/package.json +++ b/multimodal/tarko/agent-ui/package.json @@ -32,10 +32,12 @@ "diff": "^8.0.2", "postcss": "^8.4.49", "react-router-dom": "^6.26.1", - "tailwindcss": "^3.4.15" + "tailwindcss": "^3.4.15", + "vitest": "3.2.4" }, "scripts": { "dev": "rsbuild dev", + "test": "vitest run", "prepublishOnly": "npm run build", "build": "rsbuild build", "preview": "rsbuild preview" diff --git a/multimodal/tarko/agent-ui/src/common/constants/index.ts b/multimodal/tarko/agent-ui/src/common/constants/index.ts index 845426b57c..04b0a21572 100644 --- a/multimodal/tarko/agent-ui/src/common/constants/index.ts +++ b/multimodal/tarko/agent-ui/src/common/constants/index.ts @@ -26,6 +26,7 @@ export const API_ENDPOINTS = { ABORT: '/api/v1/sessions/abort', GENERATE_SUMMARY: '/api/v1/sessions/generate-summary', HEALTH: '/api/v1/health', + FILE_UPLOAD: '/api/v1/files/upload', // Share endpoints SHARE_CONFIG: '/api/v1/share/config', diff --git a/multimodal/tarko/agent-ui/src/common/services/apiService.test.ts b/multimodal/tarko/agent-ui/src/common/services/apiService.test.ts new file mode 100644 index 0000000000..ba6eb498c3 --- /dev/null +++ b/multimodal/tarko/agent-ui/src/common/services/apiService.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/config/web-ui-config', () => ({ API_BASE_URL: '' })); + +import { apiService } from './apiService'; + +describe('ApiService file uploads', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends multipart data with CSRF protection and lets fetch set the boundary', async () => { + const uploadedFile = { + name: 'sample.csv', + storedName: 'sample-abc123.csv', + relativePath: 'uploads/sample-abc123.csv', + size: 8, + mimeType: 'text/csv', + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: 'csrf-token' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ files: [uploadedFile] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const file = new File(['a,b\n1,2\n'], 'sample.csv', { type: 'text/csv' }); + await expect(apiService.uploadFiles([file])).resolves.toEqual([uploadedFile]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/csrf-token'); + + const [uploadUrl, uploadOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(uploadUrl).toBe('/api/v1/files/upload'); + expect(uploadOptions.method).toBe('POST'); + expect(uploadOptions.body).toBeInstanceOf(FormData); + expect(uploadOptions.headers).toEqual({ 'X-CSRF-Token': 'csrf-token' }); + expect((uploadOptions.headers as Record)['Content-Type']).toBeUndefined(); + }); +}); diff --git a/multimodal/tarko/agent-ui/src/common/services/apiService.ts b/multimodal/tarko/agent-ui/src/common/services/apiService.ts index 54258854b7..2b37c98f9c 100644 --- a/multimodal/tarko/agent-ui/src/common/services/apiService.ts +++ b/multimodal/tarko/agent-ui/src/common/services/apiService.ts @@ -4,6 +4,7 @@ import { SessionInfo, SanitizedAgentOptions, WorkspaceInfo, + UploadedFileInfo, } from '@/common/types'; import { ChatCompletionContentPart, AgentModel } from '@tarko/agent-interface'; @@ -78,16 +79,16 @@ class ApiService { /** * Get headers for mutation requests (POST/PUT/DELETE) that include CSRF token. */ - private async getMutationHeaders(): Promise> { + private async getMutationHeaders(isMultipart = false): Promise> { try { const token = await this.fetchCsrfToken(); return { - 'Content-Type': 'application/json', + ...(!isMultipart && { 'Content-Type': 'application/json' }), 'X-CSRF-Token': token, }; } catch { // Fall back to headers without CSRF token if fetch fails - return { 'Content-Type': 'application/json' }; + return isMultipart ? {} : { 'Content-Type': 'application/json' }; } } @@ -95,7 +96,8 @@ class ApiService { * Perform a mutation fetch with CSRF token. Retries once on 403 (token expired). */ private async mutationFetch(url: string, init: RequestInit): Promise { - const headers = await this.getMutationHeaders(); + const isMultipart = typeof FormData !== 'undefined' && init.body instanceof FormData; + const headers = await this.getMutationHeaders(isMultipart); const response = await fetch(url, { ...init, headers: { ...headers, ...(init.headers as Record) }, @@ -104,7 +106,7 @@ class ApiService { // If 403, try refreshing the CSRF token and retry once if (response.status === 403) { this.csrfToken = null; - const freshHeaders = await this.getMutationHeaders(); + const freshHeaders = await this.getMutationHeaders(isMultipart); return fetch(url, { ...init, headers: { ...freshHeaders, ...(init.headers as Record) }, @@ -114,6 +116,44 @@ class ApiService { return response; } + /** + * Persist files in the Agent workspace and return paths that can be passed to + * the Agent. The browser supplies the multipart boundary automatically. + */ + async uploadFiles(files: File[]): Promise { + if (files.length === 0) { + return []; + } + + const formData = new FormData(); + files.forEach((file) => formData.append('files', file, file.name)); + + const response = await this.mutationFetch(`${API_BASE_URL}${API_ENDPOINTS.FILE_UPLOAD}`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + let message = `Failed to upload files: ${response.statusText}`; + try { + const payload = await response.json(); + if (typeof payload.error === 'string') { + message = payload.error; + } + } catch { + // Preserve the HTTP status text when the server did not return JSON. + } + throw new Error(message); + } + + const payload = (await response.json()) as { files?: UploadedFileInfo[] }; + if (!Array.isArray(payload.files)) { + throw new Error('Upload response did not include file metadata'); + } + + return payload.files; + } + /** * Check server health status */ @@ -408,10 +448,13 @@ class ApiService { */ async generateSummary(sessionId: string, messages: any[]): Promise { try { - const response = await this.mutationFetch(`${API_BASE_URL}${API_ENDPOINTS.GENERATE_SUMMARY}`, { - method: 'POST', - body: JSON.stringify({ sessionId, messages }), - }); + const response = await this.mutationFetch( + `${API_BASE_URL}${API_ENDPOINTS.GENERATE_SUMMARY}`, + { + method: 'POST', + body: JSON.stringify({ sessionId, messages }), + }, + ); if (!response.ok) { throw new Error(`Failed to generate summary: ${response.statusText}`); diff --git a/multimodal/tarko/agent-ui/src/common/types/index.ts b/multimodal/tarko/agent-ui/src/common/types/index.ts index f651ade83a..6f72cd998e 100644 --- a/multimodal/tarko/agent-ui/src/common/types/index.ts +++ b/multimodal/tarko/agent-ui/src/common/types/index.ts @@ -11,6 +11,15 @@ export type { SanitizedAgentOptions, WorkspaceInfo, SessionInfo }; export type { ChatCompletionContentPart, ChatCompletionMessageToolCall }; +/** A file persisted inside the Agent workspace by the upload API. */ +export interface UploadedFileInfo { + name: string; + storedName: string; + relativePath: string; + size: number; + mimeType: string; +} + /** * Tool result type with categorization and timing information */ diff --git a/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/AgentOptionsSelector.tsx b/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/AgentOptionsSelector.tsx index 7a8b3cf9b0..8ba21605d8 100644 --- a/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/AgentOptionsSelector.tsx +++ b/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/AgentOptionsSelector.tsx @@ -11,8 +11,7 @@ import { import { useReplayMode } from '@/common/hooks/useReplayMode'; import { useAtomValue } from 'jotai'; import { isProcessingAtom } from '@/common/state/atoms/ui'; -import { FiPlus, FiCheck, FiChevronRight, FiImage, FiPaperclip, FiLoader } from 'react-icons/fi'; -import { TbPhoto } from 'react-icons/tb'; +import { FiPlus, FiCheck, FiChevronRight, FiPaperclip, FiLoader } from 'react-icons/fi'; import { Dropdown, DropdownItem, DropdownHeader, DropdownDivider } from '@tarko/ui'; import { createPortal } from 'react-dom'; import { getAgentOptionIcon } from './agentIconUtils'; @@ -565,11 +564,11 @@ export const AgentOptionsSelector = forwardRef} + icon={} onClick={onFileUpload} disabled={isDisabled} > -
Add Images
+
Add Files
)} diff --git a/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/ChatInput.tsx b/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/ChatInput.tsx index 70c4d5cd43..a90ed7d473 100644 --- a/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/ChatInput.tsx +++ b/multimodal/tarko/agent-ui/src/standalone/chat/MessageInput/ChatInput.tsx @@ -1,6 +1,5 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { FiSend, FiRefreshCw, FiImage, FiSquare, FiX } from 'react-icons/fi'; -import { TbBulb, TbSearch, TbBook, TbSettings, TbBrain, TbBrowser } from 'react-icons/tb'; +import { FiSend, FiRefreshCw, FiPaperclip } from 'react-icons/fi'; import { AnimatePresence, motion } from 'framer-motion'; import { ConnectionStatus } from '@/common/types'; import { ChatCompletionContentPart } from '@tarko/agent-interface'; @@ -16,15 +15,16 @@ import { import { ContextualSelector } from '../ContextualSelector'; import { MessageAttachments } from './MessageAttachments'; import { ImagePreviewInline } from './ImagePreviewInline'; +import { FilePreviewInline } from './FilePreviewInline'; import { getAgentTitle, isContextualSelectorEnabled } from '@/config/web-ui-config'; import { composeMessageContent, isMessageEmpty, parseContextualReferences } from './utils'; import { handleMultimodalPaste } from '@/common/utils/clipboard'; -import { NavbarModelSelector } from '@/standalone/navbar/ModelSelector'; import { AgentOptionsSelector, AgentOptionsSelectorRef } from './AgentOptionsSelector'; import { HomeAgentOptionsSelector } from '@/standalone/home/HomeAgentOptionsSelector'; import { HomeChatBottomSettings } from '@/standalone/home/HomeChatBottomSettings'; import { ChatBottomSettings } from './ChatBottomSettings'; -import { useNavbarStyles } from '@tarko/ui'; +import { apiService } from '@/common/services/apiService'; +import type { UploadedFileInfo } from '@/common/types'; interface ChatInputProps { onSubmit: (content: string | ChatCompletionContentPart[]) => Promise; @@ -60,6 +60,9 @@ export const ChatInput: React.FC = ({ variant = 'default', }) => { const [uploadedImages, setUploadedImages] = useState([]); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); const [isAborting, setIsAborting] = useState(false); const [isFocused, setIsFocused] = useState(false); const [activeAgentOptions, setActiveAgentOptions] = useState< @@ -68,8 +71,7 @@ export const ChatInput: React.FC = ({ const [hasAgentOptions, setHasAgentOptions] = useState(false); const agentOptionsSelectorRef = useRef(null); - const { activeSessionId, sessionMetadata } = useSession(); - const { isDarkMode } = useNavbarStyles(); + const { sessionMetadata } = useSession(); const [contextualState, setContextualState] = useAtom(contextualSelectorAtom); const addContextualItem = useSetAtom(addContextualItemAction); @@ -82,6 +84,7 @@ export const ChatInput: React.FC = ({ const { abortQuery } = useSession(); const contextualSelectorEnabled = isContextualSelectorEnabled() && showContextualSelector; + const hasInlineAttachments = uploadedImages.length > 0 || uploadedFiles.length > 0; // Clear active agent options when session changes useEffect(() => { @@ -215,7 +218,12 @@ export const ChatInput: React.FC = ({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (isMessageEmpty(contextualState.input, uploadedImages) || isDisabled) return; + if ( + isMessageEmpty(contextualState.input, uploadedImages, uploadedFiles) || + isDisabled || + isUploading + ) + return; handleSelectorClose(); @@ -223,10 +231,16 @@ export const ChatInput: React.FC = ({ inputRef.current.style.height = 'auto'; } - const messageContent = composeMessageContent(contextualState.input, uploadedImages); + const messageContent = composeMessageContent( + contextualState.input, + uploadedImages, + uploadedFiles, + ); clearContextualState(); setUploadedImages([]); + setUploadedFiles([]); + setUploadError(null); try { await onSubmit(messageContent); @@ -265,36 +279,70 @@ export const ChatInput: React.FC = ({ }; const handleFileUpload = () => { - if (fileInputRef.current) { + if (!isDisabled && !isProcessing && !isUploading && fileInputRef.current) { fileInputRef.current.click(); } }; - const handleFileChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - - Array.from(files).forEach((file) => { - if (!file.type.startsWith('image/')) return; - + const readImage = (file: File): Promise => + new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { - if (event.target?.result) { - const newImage: ChatCompletionContentPart = { - type: 'image_url', - image_url: { - url: event.target.result as string, - detail: 'auto', - }, - }; - setUploadedImages((prev) => [...prev, newImage]); + if (typeof event.target?.result !== 'string') { + reject(new Error(`Failed to read ${file.name}`)); + return; } + + resolve({ + type: 'image_url', + image_url: { + url: event.target.result, + detail: 'auto', + }, + }); }; + reader.onerror = () => reject(reader.error || new Error(`Failed to read ${file.name}`)); reader.readAsDataURL(file); }); - if (fileInputRef.current) { - fileInputRef.current.value = ''; + const handleFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + const selectedFiles = Array.from(files); + const imageFiles = selectedFiles.filter((file) => file.type.startsWith('image/')); + const dataFiles = selectedFiles.filter((file) => !file.type.startsWith('image/')); + + setUploadError(null); + setIsUploading(true); + + try { + const [imageResult, uploadResult] = await Promise.allSettled([ + Promise.all(imageFiles.map(readImage)), + apiService.uploadFiles(dataFiles), + ]); + + if (imageResult.status === 'fulfilled' && imageResult.value.length > 0) { + setUploadedImages((previous) => [...previous, ...imageResult.value]); + } + if (uploadResult.status === 'fulfilled' && uploadResult.value.length > 0) { + setUploadedFiles((previous) => [...previous, ...uploadResult.value]); + } + + const failure = [imageResult, uploadResult].find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (failure) { + throw failure.reason; + } + } catch (error) { + console.error('Failed to attach files:', error); + setUploadError(error instanceof Error ? error.message : 'Failed to attach files'); + } finally { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } } }; @@ -376,6 +424,10 @@ export const ChatInput: React.FC = ({ setUploadedImages((prev) => prev.filter((_, i) => i !== index)); }; + const handleRemoveFile = (index: number) => { + setUploadedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + const defaultPlaceholder = connectionStatus && !connectionStatus.connected ? 'Server disconnected...' @@ -387,7 +439,7 @@ export const ChatInput: React.FC = ({ return (
- {/* Only show contextual items outside, images are now inside input */} + {/* Contextual references stay outside; uploaded previews render inside the input. */} {showAttachments && contextualState.contextualItems.length > 0 && ( = ({ className={`absolute inset-0 bg-gradient-to-r ${ isFocused || contextualState.input.trim() || - uploadedImages.length > 0 || + hasInlineAttachments || contextualState.contextualItems.length > 0 ? 'from-indigo-500 via-purple-500 to-pink-500 dark:from-indigo-400 dark:via-purple-400 dark:to-pink-400 animate-border-flow' : 'from-indigo-400 via-purple-400 to-pink-400 dark:from-indigo-300 dark:via-purple-300 dark:to-pink-300' @@ -430,9 +482,12 @@ export const ChatInput: React.FC = ({ isDisabled ? 'opacity-90' : '' }`} > - {/* Image previews inside input */} + {/* Uploaded previews inside input */} {showAttachments && ( - + <> + + + )}