From 4b8090fcc2b0c199a55a11df07b6931f9496b494 Mon Sep 17 00:00:00 2001 From: Enki-lee Date: Wed, 20 May 2026 22:31:04 +0800 Subject: [PATCH] fix(memory): prefix memory routes with /api/v1 and add image preview - Fix memory page 404 by aligning controller route with /api/v1 prefix used by web client - Add inline image preview in workspace via new ImagePreview component and inline=true download mode - Map common file extensions to specific MIME types so served images render correctly - Drop redundant close button in FullPreviewDialog (Dialog already provides one) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/memory/memory.controller.ts | 2 +- .../__tests__/workspace.controller.test.ts | 30 +++++++- .../api/src/workspace/workspace.controller.ts | 4 +- .../api/src/workspace/workspace.service.ts | 37 ++++++--- .../(dashboard)/workspace/file-preview.tsx | 23 +++--- .../(dashboard)/workspace/image-preview.tsx | 75 +++++++++++++++++++ .../workspace/workspace-dialogs.tsx | 31 ++++---- packages/web/src/lib/api/memory.ts | 10 +-- 8 files changed, 166 insertions(+), 46 deletions(-) create mode 100644 packages/web/src/app/(dashboard)/workspace/image-preview.tsx diff --git a/packages/api/src/memory/memory.controller.ts b/packages/api/src/memory/memory.controller.ts index 472f349..2ff96a2 100644 --- a/packages/api/src/memory/memory.controller.ts +++ b/packages/api/src/memory/memory.controller.ts @@ -35,7 +35,7 @@ interface AuthenticatedRequest { * (visibility-gated by the service). Writes are admin + developer; viewer * is read-only. */ -@Controller('memory') +@Controller('api/v1/memory') export class MemoryController { constructor(private readonly service: MemoryService) {} diff --git a/packages/api/src/workspace/__tests__/workspace.controller.test.ts b/packages/api/src/workspace/__tests__/workspace.controller.test.ts index 0479338..5614ffa 100644 --- a/packages/api/src/workspace/__tests__/workspace.controller.test.ts +++ b/packages/api/src/workspace/__tests__/workspace.controller.test.ts @@ -261,7 +261,7 @@ describe('WorkspaceController', () => { send: vi.fn().mockResolvedValue(undefined), } as any; - await controller.downloadFile(mockReq, '/index.ts', mockReply); + await controller.downloadFile(mockReq, '/index.ts', undefined, mockReply); expect(mockService.downloadFile).toHaveBeenCalledWith('user-1', '/index.ts'); expect(mockReply.header).toHaveBeenCalledWith('Content-Type', 'text/plain'); @@ -273,15 +273,37 @@ describe('WorkspaceController', () => { expect(mockReply.send).toHaveBeenCalledWith(mockStream); }); - it('should throw BadRequestException when path is missing', async () => { + it('should use inline Content-Disposition when inline=true', async () => { + const mockStream = { pipe: vi.fn() } as any; + mockService.downloadFile.mockResolvedValue({ + stream: mockStream, + filename: 'photo.png', + contentType: 'image/png', + size: 100, + }); + const mockReply = { header: vi.fn().mockReturnThis(), send: vi.fn().mockResolvedValue(undefined), } as any; - await expect(controller.downloadFile(mockReq, undefined, mockReply)).rejects.toThrow( - BadRequestException, + await controller.downloadFile(mockReq, '/photo.png', 'true', mockReply); + + expect(mockReply.header).toHaveBeenCalledWith( + 'Content-Disposition', + 'inline; filename="photo.png"', ); }); + + it('should throw BadRequestException when path is missing', async () => { + const mockReply = { + header: vi.fn().mockReturnThis(), + send: vi.fn().mockResolvedValue(undefined), + } as any; + + await expect( + controller.downloadFile(mockReq, undefined, undefined, mockReply), + ).rejects.toThrow(BadRequestException); + }); }); }); diff --git a/packages/api/src/workspace/workspace.controller.ts b/packages/api/src/workspace/workspace.controller.ts index c16c26a..176c1bd 100644 --- a/packages/api/src/workspace/workspace.controller.ts +++ b/packages/api/src/workspace/workspace.controller.ts @@ -145,6 +145,7 @@ export class WorkspaceController { async downloadFile( @Req() req: { user: JwtPayload }, @Query('path') filePath?: string, + @Query('inline') inline?: string, @Res() reply?: FastifyReply, ): Promise { if (!filePath) { @@ -154,8 +155,9 @@ export class WorkspaceController { req.user.sub, filePath, ); + const disposition = inline === 'true' ? 'inline' : 'attachment'; reply!.header('Content-Type', contentType); - reply!.header('Content-Disposition', `attachment; filename="${filename}"`); + reply!.header('Content-Disposition', `${disposition}; filename="${filename}"`); reply!.header('Content-Length', size); await reply!.send(stream); } diff --git a/packages/api/src/workspace/workspace.service.ts b/packages/api/src/workspace/workspace.service.ts index 98340fe..72759dc 100644 --- a/packages/api/src/workspace/workspace.service.ts +++ b/packages/api/src/workspace/workspace.service.ts @@ -94,6 +94,30 @@ const BINARY_TYPES: ReadonlySet = new Set(['image', 'video', 'audio', const EDITABLE_TYPES: ReadonlySet = new Set(['text', 'code', 'markdown', 'json']); +const MIME_TYPE_MAP: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.rar': 'application/vnd.rar', + '.json': 'application/json', + '.md': 'text/markdown', + '.mdx': 'text/markdown', +}; + @Injectable() export class WorkspaceService { constructor(private readonly userAgentRepo: UserAgentRepository) {} @@ -385,22 +409,17 @@ export class WorkspaceService { if (stat.isDirectory()) throw new BadRequestException('Cannot download a directory'); const resolved = sfs.resolve(filePath); const filename = path.basename(resolved); + const ext = path.extname(filename).toLowerCase(); const type = WorkspaceService.detectFileType(filename); - const contentTypeMap: Partial> = { - image: 'image/*', - video: 'video/*', - audio: 'audio/*', - pdf: 'application/pdf', - archive: 'application/octet-stream', - json: 'application/json', + const fallbackByType: Partial> = { code: 'text/plain', text: 'text/plain', - markdown: 'text/markdown', }; + const contentType = MIME_TYPE_MAP[ext] ?? fallbackByType[type] ?? 'application/octet-stream'; return { stream: sfs.createReadStream(filePath), filename, - contentType: contentTypeMap[type] ?? 'application/octet-stream', + contentType, size: stat.size, }; } diff --git a/packages/web/src/app/(dashboard)/workspace/file-preview.tsx b/packages/web/src/app/(dashboard)/workspace/file-preview.tsx index 7ac83a8..36881b6 100644 --- a/packages/web/src/app/(dashboard)/workspace/file-preview.tsx +++ b/packages/web/src/app/(dashboard)/workspace/file-preview.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { formatFileSize } from '@/lib/format'; +import { ImagePreview } from './image-preview'; import type { FileContent } from '@clawix/shared'; interface FilePreviewProps { @@ -47,7 +48,7 @@ export function FilePreview({ file, isLoading, onClose, onEdit, onFullPreview }:
- {onFullPreview && file.content !== null && ( + {onFullPreview && (file.content !== null || file.type === 'image') && ( )} -
{/* Content */}
{file.content === null ? ( -
-

- {file.truncated - ? 'File is too large to preview (> 1 MB)' - : 'Binary file — preview not available'} -

-
+ file.type === 'image' && !file.truncated ? ( + + ) : ( +
+

+ {file.truncated + ? 'File is too large to preview (> 1 MB)' + : 'Binary file — preview not available'} +

+
+ ) ) : isMarkdown ? (
{ - return authFetch(`/memory?scope=${encodeURIComponent(scope)}`); + return authFetch(`/api/v1/memory?scope=${encodeURIComponent(scope)}`); }, read(id: string): Promise { - return authFetch(`/memory/${encodeURIComponent(id)}`); + return authFetch(`/api/v1/memory/${encodeURIComponent(id)}`); }, create(input: CreateMemoryItemInput): Promise { - return authFetch('/memory', { + return authFetch('/api/v1/memory', { method: 'POST', body: JSON.stringify(input), }); }, update(id: string, input: UpdateMemoryItemInput): Promise { - return authFetch(`/memory/${encodeURIComponent(id)}`, { + return authFetch(`/api/v1/memory/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(input), }); }, delete(id: string): Promise { - return authFetch(`/memory/${encodeURIComponent(id)}`, { + return authFetch(`/api/v1/memory/${encodeURIComponent(id)}`, { method: 'DELETE', }); },