mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
COD-38 document attachment previews + thumbnails (attachment cards)
Builds on the COD-37 registry: surfaces detected/registered attachments as dismissible cards with a first-page thumbnail and an inline preview — the consumer the registry PR deliberately deferred. Backend: - document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via pdftoppm; Office via the preview cache). - document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice / PowerShell COM), in-flight dedup, multi-converter fallback. - file-routes: serveConvertedPreview / serveThumbnail + four routes — GET .../attachments/:id/preview, .../thumbnail and the workspace-path file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe resolveServableAttachmentPath, so previews stream the freshly-resolved path. - server: enrich detected attachment events with a thumbnail route. - image-watcher: .png now routes to attachment:detected — this PR adds the card consumer, so the screenshot popup is no longer its only handler. Frontend: - panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all, per-session cleanup) plus a 3-arg openFilePreview that renders registered attachments inline (image/PDF) or via the server-converted PDF (docx/pptx). - app.js: wire attachment:detected -> _onAttachmentDetected and card state. - styles: attachment-card + stack styling. Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer + preview-cache unit tests pass; full test:ci green (2861 passed); card render + preview overlay + dismiss verified in-browser.
This commit is contained in:
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Shared disk cache for expensive Office document previews.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { basename, dirname, extname, join } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
|
||||||
|
function buildWordExportPdfScript(sourcePath: string, outputPath: string): string {
|
||||||
|
return `
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$source = ${toPowerShellSingleQuotedString(sourcePath)}
|
||||||
|
$output = ${toPowerShellSingleQuotedString(outputPath)}
|
||||||
|
$word = $null
|
||||||
|
$doc = $null
|
||||||
|
try {
|
||||||
|
$word = New-Object -ComObject Word.Application
|
||||||
|
$word.Visible = $false
|
||||||
|
$word.DisplayAlerts = 0
|
||||||
|
$doc = $word.Documents.Open($source)
|
||||||
|
$doc.ExportAsFixedFormat($output, 17)
|
||||||
|
} finally {
|
||||||
|
if ($null -ne $doc) {
|
||||||
|
$doc.Close($false) | Out-Null
|
||||||
|
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
|
||||||
|
}
|
||||||
|
if ($null -ne $word) {
|
||||||
|
$word.Quit() | Out-Null
|
||||||
|
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
|
||||||
|
}
|
||||||
|
[System.GC]::Collect()
|
||||||
|
[System.GC]::WaitForPendingFinalizers()
|
||||||
|
}
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfficePreviewConverter = 'msword' | 'libreoffice';
|
||||||
|
|
||||||
|
const inFlightOfficeConversions = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
export function clearDocumentPreviewCache(): void {
|
||||||
|
inFlightOfficeConversions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
|
||||||
|
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||||
|
if (ext !== 'docx' && ext !== 'pptx') return null;
|
||||||
|
|
||||||
|
let sourceStat;
|
||||||
|
try {
|
||||||
|
sourceStat = await fs.stat(filePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const converter of getOfficePreviewConverters(filePath, ext)) {
|
||||||
|
const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter);
|
||||||
|
const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter);
|
||||||
|
|
||||||
|
if (await fileExists(cachePath)) {
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inFlightKey = `${converter}:${cacheKey}`;
|
||||||
|
const inFlight = inFlightOfficeConversions.get(inFlightKey);
|
||||||
|
if (inFlight) {
|
||||||
|
const converted = await inFlight;
|
||||||
|
if (converted) return converted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversion =
|
||||||
|
converter === 'msword'
|
||||||
|
? convertWordDocumentToCachedPdf(filePath, cachePath)
|
||||||
|
: convertLibreOfficeDocumentToCachedPdf(filePath, cachePath);
|
||||||
|
inFlightOfficeConversions.set(inFlightKey, conversion);
|
||||||
|
try {
|
||||||
|
const converted = await conversion;
|
||||||
|
if (converted) return converted;
|
||||||
|
} finally {
|
||||||
|
inFlightOfficeConversions.delete(inFlightKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] {
|
||||||
|
if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) {
|
||||||
|
return ['msword', 'libreoffice'];
|
||||||
|
}
|
||||||
|
return ['libreoffice'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDocumentPreviewCacheKey(
|
||||||
|
filePath: string,
|
||||||
|
extension: string,
|
||||||
|
size: number,
|
||||||
|
mtimeMs: number,
|
||||||
|
converter: OfficePreviewConverter
|
||||||
|
): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs }))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string {
|
||||||
|
if (converter === 'msword') {
|
||||||
|
const windowsCacheDir = getWindowsUserTempCacheDir(filePath);
|
||||||
|
if (windowsCacheDir) {
|
||||||
|
return join(windowsCacheDir, `${cacheKey}.pdf`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
return typeof stat.isFile !== 'function' || stat.isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||||
|
const outputPath = wslMountPathToWindowsPath(cachePath);
|
||||||
|
if (!outputPath) return null;
|
||||||
|
|
||||||
|
let sourceCopyPath: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dirname(cachePath), { recursive: true });
|
||||||
|
sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`);
|
||||||
|
await fs.copyFile(filePath, sourceCopyPath);
|
||||||
|
|
||||||
|
const sourcePath = wslMountPathToWindowsPath(sourceCopyPath);
|
||||||
|
if (!sourcePath) return null;
|
||||||
|
|
||||||
|
await execFileAsync(
|
||||||
|
'powershell.exe',
|
||||||
|
[
|
||||||
|
'-NoProfile',
|
||||||
|
'-NonInteractive',
|
||||||
|
'-ExecutionPolicy',
|
||||||
|
'Bypass',
|
||||||
|
'-EncodedCommand',
|
||||||
|
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await fileExists(cachePath)) {
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`,
|
||||||
|
getCacheErrorMessage(err)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (sourceCopyPath) {
|
||||||
|
await fs.rm(sourceCopyPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||||
|
let workDir: string | undefined;
|
||||||
|
try {
|
||||||
|
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
|
||||||
|
workDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
|
||||||
|
const profileDir = join(workDir, 'profile');
|
||||||
|
await fs.mkdir(profileDir, { recursive: true });
|
||||||
|
|
||||||
|
await execFileAsync(
|
||||||
|
'soffice',
|
||||||
|
[
|
||||||
|
'--headless',
|
||||||
|
'--nologo',
|
||||||
|
'--nofirststartwizard',
|
||||||
|
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
||||||
|
'--convert-to',
|
||||||
|
'pdf',
|
||||||
|
'--outdir',
|
||||||
|
workDir,
|
||||||
|
filePath,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const converted = (await fs.readdir(workDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
|
||||||
|
if (!converted) return null;
|
||||||
|
|
||||||
|
await fs.rename(join(workDir, converted), cachePath);
|
||||||
|
return cachePath;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`,
|
||||||
|
getCacheErrorMessage(err)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (workDir) {
|
||||||
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wslMountPathToWindowsPath(filePath: string): string | null {
|
||||||
|
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsUserTempCacheDir(filePath: string): string | null {
|
||||||
|
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//);
|
||||||
|
if (!match) return null;
|
||||||
|
return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPowerShellSingleQuotedString(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePowerShellCommand(script: string): string {
|
||||||
|
return Buffer.from(script, 'utf16le').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviewPdfDownloadName(fileName: string, extension: string): string {
|
||||||
|
return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCacheErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Best-effort first-page thumbnails for attachment cards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { basename, extname, join } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { getOfficePreviewPdfPath } from './document-preview-cache.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
export interface ThumbnailResult {
|
||||||
|
content: Buffer;
|
||||||
|
contentType: 'image/png';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
|
||||||
|
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (ext === 'png') {
|
||||||
|
return { content: await fs.readFile(filePath), contentType: 'image/png' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
return renderPdfFirstPage(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext === 'docx' || ext === 'pptx') {
|
||||||
|
return renderOfficeFirstPage(filePath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOfficeFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||||
|
try {
|
||||||
|
const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, ''));
|
||||||
|
if (!previewPdfPath) return null;
|
||||||
|
return await renderPdfFirstPage(previewPdfPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`,
|
||||||
|
getThumbnailErrorMessage(err)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPdfFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||||
|
let previewDir: string | undefined;
|
||||||
|
try {
|
||||||
|
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
|
||||||
|
const prefix = join(previewDir, basename(filePath, extname(filePath)));
|
||||||
|
await execFileAsync(
|
||||||
|
'pdftoppm',
|
||||||
|
['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix],
|
||||||
|
{
|
||||||
|
timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const content = await fs.readFile(`${prefix}.png`);
|
||||||
|
return { content, contentType: 'image/png' };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`,
|
||||||
|
getThumbnailErrorMessage(err)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (previewDir) {
|
||||||
|
await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThumbnailErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
@@ -20,12 +20,8 @@ import { KeyedDebouncer } from './utils/index.js';
|
|||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** Supported image file extensions (lowercase) */
|
/** Supported image file extensions (lowercase) */
|
||||||
// PNG stays on the image-popup path: it's the dominant screenshot format and the
|
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||||
// frontend only wires the `image:detected` popup today. The attachment-card UI
|
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
|
||||||
// that would consume `attachment:detected` for images is out of scope for this
|
|
||||||
// PR, so routing PNG to it would silently break the dropped-screenshot popup.
|
|
||||||
const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
||||||
const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']);
|
|
||||||
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
||||||
|
|
||||||
/** Time to wait for file writes to stabilize (ms) */
|
/** Time to wait for file writes to stabilize (ms) */
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ const _SSE_HANDLER_MAP = [
|
|||||||
|
|
||||||
// Images
|
// Images
|
||||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||||
|
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||||
|
|
||||||
// Tunnel
|
// Tunnel
|
||||||
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
||||||
@@ -386,6 +387,8 @@ class CodemanApp {
|
|||||||
// Image popup windows (auto-open for detected screenshots/images)
|
// Image popup windows (auto-open for detected screenshots/images)
|
||||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||||
|
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
|
||||||
|
this.attachmentCardStack = null;
|
||||||
|
|
||||||
// File browser state (methods in panels-ui.js)
|
// File browser state (methods in panels-ui.js)
|
||||||
this.fileBrowserData = null;
|
this.fileBrowserData = null;
|
||||||
@@ -3536,6 +3539,7 @@ class CodemanApp {
|
|||||||
this.clearCountdownTimers(sessionId);
|
this.clearCountdownTimers(sessionId);
|
||||||
this.closeSessionLogViewerWindows(sessionId);
|
this.closeSessionLogViewerWindows(sessionId);
|
||||||
this.closeSessionImagePopups(sessionId);
|
this.closeSessionImagePopups(sessionId);
|
||||||
|
this.closeSessionAttachmentCards(sessionId);
|
||||||
this.closeSessionSubagentWindows(sessionId, true);
|
this.closeSessionSubagentWindows(sessionId, true);
|
||||||
|
|
||||||
// Clean up idle timer
|
// Clean up idle timer
|
||||||
|
|||||||
+208
-3
@@ -2465,8 +2465,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.saveAppSettingsToStorage(settings);
|
this.saveAppSettingsToStorage(settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
async openFilePreview(filePath) {
|
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||||
if (!this.activeSessionId || !filePath) return;
|
if (!sessionId || !filePath) return;
|
||||||
|
|
||||||
const overlay = this.$('filePreviewOverlay');
|
const overlay = this.$('filePreviewOverlay');
|
||||||
const titleEl = this.$('filePreviewTitle');
|
const titleEl = this.$('filePreviewTitle');
|
||||||
@@ -2481,8 +2481,36 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
||||||
footerEl.textContent = '';
|
footerEl.textContent = '';
|
||||||
|
|
||||||
|
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||||
|
|
||||||
|
// Registered attachment: render straight from its by-id routes — images and
|
||||||
|
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||||
|
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||||
|
if (attachmentId) {
|
||||||
|
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||||
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||||
|
footerEl.textContent = ext.toUpperCase();
|
||||||
|
if (IMAGE_EXTS.has(ext)) {
|
||||||
|
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||||
|
} else if (ext === 'pdf') {
|
||||||
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
} else if (ext === 'docx' || ext === 'pptx') {
|
||||||
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/raw`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load attachment');
|
||||||
|
const text = await res.text();
|
||||||
|
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||||
|
} catch (err) {
|
||||||
|
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||||
if (!res.ok) throw new Error('Failed to load file');
|
if (!res.ok) throw new Error('Failed to load file');
|
||||||
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
@@ -2520,6 +2548,183 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.filePreviewContent = '';
|
this.filePreviewContent = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Attachment Cards (detected documents/images)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// SSE `attachment:detected` consumer: surface a dismissible card for the file.
|
||||||
|
_onAttachmentDetected(data) {
|
||||||
|
console.log('[Attachment Detected]', data);
|
||||||
|
this.addAttachmentCard(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lazily create the floating stack the cards live in (appended to <body>).
|
||||||
|
ensureAttachmentCardStack() {
|
||||||
|
let stack = this.attachmentCardStack || document.getElementById('attachmentCardStack');
|
||||||
|
if (!stack) {
|
||||||
|
stack = document.createElement('div');
|
||||||
|
stack.id = 'attachmentCardStack';
|
||||||
|
stack.className = 'attachment-card-stack';
|
||||||
|
document.body.appendChild(stack);
|
||||||
|
}
|
||||||
|
this.attachmentCardStack = stack;
|
||||||
|
return stack;
|
||||||
|
},
|
||||||
|
|
||||||
|
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
|
||||||
|
const url = attachmentId
|
||||||
|
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||||
|
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttachmentCard(attachmentEvent) {
|
||||||
|
const {
|
||||||
|
sessionId,
|
||||||
|
relativePath,
|
||||||
|
fileName,
|
||||||
|
timestamp,
|
||||||
|
size,
|
||||||
|
attachmentType,
|
||||||
|
extension,
|
||||||
|
attachmentId,
|
||||||
|
rawUrl,
|
||||||
|
previewUrl,
|
||||||
|
thumbnailUrl,
|
||||||
|
} = attachmentEvent;
|
||||||
|
const filePath = relativePath || fileName;
|
||||||
|
const cardId = attachmentId || `${sessionId}-${timestamp}-${fileName}`;
|
||||||
|
|
||||||
|
if (this.attachmentCards.has(cardId)) {
|
||||||
|
const existing = this.attachmentCards.get(cardId);
|
||||||
|
existing.element.focus?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_CARDS = 10;
|
||||||
|
if (this.attachmentCards.size >= MAX_ATTACHMENT_CARDS) {
|
||||||
|
const oldestId = this.attachmentCards.keys().next().value;
|
||||||
|
if (oldestId) this.closeAttachmentCard(oldestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = this.ensureAttachmentCardStack();
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
const sessionName = session?.name || sessionId.substring(0, 8);
|
||||||
|
const attachmentRawUrl =
|
||||||
|
rawUrl ||
|
||||||
|
(attachmentId
|
||||||
|
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||||
|
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||||
|
const attachmentPreviewUrl =
|
||||||
|
previewUrl ||
|
||||||
|
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
|
||||||
|
const attachmentThumbnailUrl =
|
||||||
|
thumbnailUrl ||
|
||||||
|
(attachmentId
|
||||||
|
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||||
|
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
|
||||||
|
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
|
||||||
|
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
|
||||||
|
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = `attachment-card attachment-${escapeHtml(attachmentType || 'file')}`;
|
||||||
|
card.tabIndex = 0;
|
||||||
|
card.dataset.attachmentId = cardId;
|
||||||
|
card.dataset.previewUrl = attachmentPreviewUrl || '';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="attachment-thumbnail">
|
||||||
|
${attachmentThumbnailUrl ? `<img class="attachment-thumbnail-img" src="${escapeHtml(attachmentThumbnailUrl)}" alt="">` : ''}
|
||||||
|
<div class="attachment-thumbnail-fallback ${attachmentThumbnailUrl ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="attachment-card-main">
|
||||||
|
<div class="attachment-file-name" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</div>
|
||||||
|
<div class="attachment-file-meta">
|
||||||
|
<span>${escapeHtml(sessionName)}</span>
|
||||||
|
<span>${this.formatFileSize(size || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="attachment-actions">
|
||||||
|
<button type="button" class="attachment-preview-btn">Preview</button>
|
||||||
|
<a href="${escapeHtml(downloadUrl)}">Download</a>
|
||||||
|
<button type="button" class="attachment-open-btn">Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="attachment-close-btn" title="Dismiss">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const attachmentThumbnailImg = card.querySelector('.attachment-thumbnail-img');
|
||||||
|
if (attachmentThumbnailImg) {
|
||||||
|
attachmentThumbnailImg.onerror = () => {
|
||||||
|
attachmentThumbnailImg.remove();
|
||||||
|
card.querySelector('.attachment-thumbnail-fallback')?.classList.add('visible');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
card.querySelector('.attachment-preview-btn')?.addEventListener('click', () => {
|
||||||
|
this.openFilePreview(filePath, sessionId, attachmentId || null);
|
||||||
|
});
|
||||||
|
card.querySelector('.attachment-open-btn')?.addEventListener('click', () => {
|
||||||
|
this.openAttachmentInNewTab(sessionId, filePath, attachmentId || null);
|
||||||
|
});
|
||||||
|
card.querySelector('.attachment-close-btn')?.addEventListener('click', () => {
|
||||||
|
this.closeAttachmentCard(cardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
stack.prepend(card);
|
||||||
|
this.attachmentCards.set(cardId, { element: card, sessionId, filePath });
|
||||||
|
this._refreshAttachmentClearAll();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Centralized show/hide for the stack's "Clear all" control. Both addAttachmentCard and
|
||||||
|
// closeAttachmentCard call this so the control appears on the 2nd card and hides at <=1.
|
||||||
|
_refreshAttachmentClearAll() {
|
||||||
|
const stack = this.attachmentCardStack;
|
||||||
|
if (!stack) return;
|
||||||
|
let control = stack.querySelector('.attachment-clear-all');
|
||||||
|
if (this.attachmentCards.size < 2) {
|
||||||
|
if (control) control.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!control) {
|
||||||
|
control = document.createElement('button');
|
||||||
|
control.type = 'button';
|
||||||
|
control.className = 'attachment-clear-all';
|
||||||
|
control.textContent = 'Clear all';
|
||||||
|
control.title = 'Dismiss all attachment cards';
|
||||||
|
control.addEventListener('click', () => this.closeAllAttachmentCards());
|
||||||
|
stack.prepend(control);
|
||||||
|
}
|
||||||
|
control.hidden = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAttachmentCard(attachmentId) {
|
||||||
|
const cardData = this.attachmentCards.get(attachmentId);
|
||||||
|
if (!cardData) return;
|
||||||
|
cardData.element.remove();
|
||||||
|
this.attachmentCards.delete(attachmentId);
|
||||||
|
if (this.attachmentCardStack && this.attachmentCards.size === 0) {
|
||||||
|
this.attachmentCardStack.remove();
|
||||||
|
this.attachmentCardStack = null;
|
||||||
|
} else {
|
||||||
|
this._refreshAttachmentClearAll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAllAttachmentCards() {
|
||||||
|
for (const attachmentId of [...this.attachmentCards.keys()]) {
|
||||||
|
this.closeAttachmentCard(attachmentId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSessionAttachmentCards(sessionId) {
|
||||||
|
const toClose = [];
|
||||||
|
for (const [attachmentId, data] of this.attachmentCards) {
|
||||||
|
if (data.sessionId === sessionId) toClose.push(attachmentId);
|
||||||
|
}
|
||||||
|
for (const attachmentId of toClose) {
|
||||||
|
this.closeAttachmentCard(attachmentId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
copyFilePreviewContent() {
|
copyFilePreviewContent() {
|
||||||
if (this.filePreviewContent) {
|
if (this.filePreviewContent) {
|
||||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||||
|
|||||||
@@ -9057,3 +9057,172 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-card-stack {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: calc(var(--toolbar-height) + 18px);
|
||||||
|
z-index: 1900;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(540px, calc(100vw - 32px));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-clear-all {
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(19, 19, 22, 0.96);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-clear-all:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-clear-all[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px minmax(0, 1fr) 24px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(19, 19, 22, 0.96);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumbnail {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 96px;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8f8fb;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumbnail-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumbnail-fallback {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #20202a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumbnail-fallback.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-type-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #20202a;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-name {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-actions button,
|
||||||
|
.attachment-actions a {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-actions button:hover,
|
||||||
|
.attachment-actions a:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-close-btn {
|
||||||
|
align-self: start;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-close-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PDF/Office previews render in an iframe inside the preview body. */
|
||||||
|
.file-preview-body iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ import { fileStreamManager } from '../../file-stream-manager.js';
|
|||||||
import {
|
import {
|
||||||
AttachmentRegistrationError,
|
AttachmentRegistrationError,
|
||||||
attachmentRegistry,
|
attachmentRegistry,
|
||||||
|
isSupportedAttachmentExtension,
|
||||||
registerExternalAttachment,
|
registerExternalAttachment,
|
||||||
type AttachmentRecord,
|
type AttachmentRecord,
|
||||||
} from '../../attachment-registry.js';
|
} from '../../attachment-registry.js';
|
||||||
|
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||||
|
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||||
import { isSensitivePath } from '../sensitive-path.js';
|
import { isSensitivePath } from '../sensitive-path.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||||
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
@@ -159,7 +162,80 @@ async function resolveServableAttachmentPath(
|
|||||||
return resolved ? pathToCheck : record.filePath;
|
return resolved ? pathToCheck : record.filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
/**
|
||||||
|
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
||||||
|
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
||||||
|
* redirect those to the raw route instead.
|
||||||
|
*/
|
||||||
|
async function serveConvertedPreview(
|
||||||
|
reply: FastifyReply,
|
||||||
|
resolvedPath: string,
|
||||||
|
fileName: string,
|
||||||
|
extension: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (extension !== 'docx' && extension !== 'pptx') {
|
||||||
|
reply
|
||||||
|
.code(400)
|
||||||
|
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension);
|
||||||
|
if (!previewPath) {
|
||||||
|
reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(previewPath);
|
||||||
|
reply.header('Content-Type', 'application/pdf');
|
||||||
|
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
reply.header('Content-Length', content.length);
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
|
reply.send(content);
|
||||||
|
} catch (err) {
|
||||||
|
reply
|
||||||
|
.code(500)
|
||||||
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */
|
||||||
|
async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise<void> {
|
||||||
|
const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension);
|
||||||
|
if (!thumbnail) {
|
||||||
|
reply.code(204).send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Type', thumbnail.contentType);
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
|
reply.send(thumbnail.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a session's working dir from the live session, falling back to the
|
||||||
|
* persisted record so preview/thumbnail requests keep working for a session
|
||||||
|
* that has since detached. Sends a 404 and returns undefined when unknown.
|
||||||
|
*/
|
||||||
|
function getKnownSessionWorkingDir(
|
||||||
|
ctx: SessionPort & ConfigPort,
|
||||||
|
sessionId: string,
|
||||||
|
reply: FastifyReply
|
||||||
|
): string | undefined {
|
||||||
|
const liveSession = ctx.sessions.get(sessionId);
|
||||||
|
if (liveSession) return liveSession.workingDir;
|
||||||
|
|
||||||
|
const stored = ctx.store.getSession(sessionId);
|
||||||
|
if (stored) return stored.workingDir;
|
||||||
|
|
||||||
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||||
// File tree listing
|
// File tree listing
|
||||||
app.get('/api/sessions/:id/files', async (req) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
@@ -515,6 +591,97 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
||||||
|
// convert server-side; PDF/PNG/text redirect to the raw route.
|
||||||
|
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
||||||
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||||
|
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||||
|
if (!workingDir) return;
|
||||||
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||||
|
if (!record) return;
|
||||||
|
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
||||||
|
if (!servePath) return;
|
||||||
|
|
||||||
|
// Only Office formats need server-side conversion; PDF/PNG and text formats
|
||||||
|
// (md/txt) preview directly from their raw bytes.
|
||||||
|
if (record.extension !== 'docx' && record.extension !== 'pptx') {
|
||||||
|
reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve a first-page thumbnail of a registered attachment by id.
|
||||||
|
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
||||||
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||||
|
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||||
|
if (!workingDir) return;
|
||||||
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||||
|
if (!record) return;
|
||||||
|
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
||||||
|
if (!servePath) return;
|
||||||
|
await serveThumbnail(reply, servePath, record.extension);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
||||||
|
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
||||||
|
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const { path: filePath } = req.query as { path?: string };
|
||||||
|
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||||
|
if (!workingDir) return;
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateSessionFilePath(workingDir, filePath);
|
||||||
|
if (!validated) {
|
||||||
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { resolvedPath } = validated;
|
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
if (ext !== 'docx' && ext !== 'pptx') {
|
||||||
|
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await serveConvertedPreview(reply, resolvedPath, filePath, ext);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve a first-page thumbnail for a workspace-relative path.
|
||||||
|
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const { path: filePath } = req.query as { path?: string };
|
||||||
|
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||||
|
if (!workingDir) return;
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateSessionFilePath(workingDir, filePath);
|
||||||
|
if (!validated) {
|
||||||
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (!isSupportedAttachmentExtension(ext)) {
|
||||||
|
reply
|
||||||
|
.code(400)
|
||||||
|
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await serveThumbnail(reply, validated.resolvedPath, ext);
|
||||||
|
});
|
||||||
|
|
||||||
// Stream file content via tail -f (SSE endpoint)
|
// Stream file content via tail -f (SSE endpoint)
|
||||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
+7
-2
@@ -60,7 +60,7 @@ import {
|
|||||||
type SubagentToolResult,
|
type SubagentToolResult,
|
||||||
} from '../subagent-watcher.js';
|
} from '../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../image-watcher.js';
|
import { imageWatcher } from '../image-watcher.js';
|
||||||
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
import { TeamWatcher } from '../team-watcher.js';
|
import { TeamWatcher } from '../team-watcher.js';
|
||||||
import { TunnelManager } from '../tunnel-manager.js';
|
import { TunnelManager } from '../tunnel-manager.js';
|
||||||
@@ -440,7 +440,12 @@ export class WebServer extends EventEmitter {
|
|||||||
this.imageWatcherHandlers = {
|
this.imageWatcherHandlers = {
|
||||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||||
attachmentDetected: (event: AttachmentDetectedEvent) =>
|
attachmentDetected: (event: AttachmentDetectedEvent) =>
|
||||||
this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }),
|
this.broadcast(SseEvent.AttachmentDetected, {
|
||||||
|
...event,
|
||||||
|
source: event.source || 'detected',
|
||||||
|
thumbnailUrl:
|
||||||
|
event.thumbnailUrl || buildFileThumbnailRoute(event.sessionId, event.relativePath || event.fileName),
|
||||||
|
}),
|
||||||
error: (error: Error, sessionId?: string) => {
|
error: (error: Error, sessionId?: string) => {
|
||||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { clearDocumentPreviewCache, getOfficePreviewPdfPath } from '../src/document-preview-cache.js';
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||||
|
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
stat: vi.fn(),
|
||||||
|
mkdir: vi.fn(async () => undefined),
|
||||||
|
copyFile: vi.fn(async () => undefined),
|
||||||
|
mkdtemp: vi.fn(async () => '/tmp/codeman-document-preview-cache/work-test'),
|
||||||
|
readdir: vi.fn(async () => ['deck.pdf']),
|
||||||
|
rename: vi.fn(async () => undefined),
|
||||||
|
rm: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedExecFile = vi.mocked(execFile);
|
||||||
|
const mockedStat = vi.mocked(fs.stat);
|
||||||
|
const mockedRename = vi.mocked(fs.rename);
|
||||||
|
|
||||||
|
describe('document-preview-cache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
clearDocumentPreviewCache();
|
||||||
|
|
||||||
|
let cachedPdfExists = false;
|
||||||
|
mockedStat.mockImplementation(async (path) => {
|
||||||
|
const pathText = String(path);
|
||||||
|
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||||
|
if (!cachedPdfExists) throw new Error('ENOENT');
|
||||||
|
return { size: 4096, isFile: () => true } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { size: 1845494, mtimeMs: 12345, isFile: () => true } as never;
|
||||||
|
});
|
||||||
|
mockedRename.mockImplementation(async () => {
|
||||||
|
cachedPdfExists = true;
|
||||||
|
});
|
||||||
|
mockedExecFile.mockImplementation((cmd, _args, _options, callback) => {
|
||||||
|
if (cmd === 'powershell.exe') {
|
||||||
|
cachedPdfExists = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||||
|
return {} as never;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent Office preview conversions and reuses the cached PDF', async () => {
|
||||||
|
const [firstPath, secondPath] = await Promise.all([
|
||||||
|
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||||
|
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||||
|
]);
|
||||||
|
const thirdPath = await getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx');
|
||||||
|
|
||||||
|
expect(firstPath).toBeTruthy();
|
||||||
|
expect(secondPath).toBe(firstPath);
|
||||||
|
expect(thirdPath).toBe(firstPath);
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||||
|
'soffice',
|
||||||
|
expect.arrayContaining([
|
||||||
|
'--headless',
|
||||||
|
'--convert-to',
|
||||||
|
'pdf',
|
||||||
|
expect.stringMatching(/^-env:UserInstallation=file:/),
|
||||||
|
]),
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers Microsoft Word for DOCX files on Windows-mounted paths', async () => {
|
||||||
|
const result = await getOfficePreviewPdfPath(
|
||||||
|
'/mnt/c/Users/aakhter/Documents/codeman-inline-viewer-test.docx',
|
||||||
|
'docx'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain('/mnt/c/Users/aakhter/AppData/Local/Temp/codeman-document-preview-cache/');
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||||
|
'powershell.exe',
|
||||||
|
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-EncodedCommand']),
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
const powerShellCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'powershell.exe');
|
||||||
|
const encodedCommand = powerShellCall?.[1]?.at(-1);
|
||||||
|
const decodedCommand = Buffer.from(String(encodedCommand), 'base64').toString('utf16le');
|
||||||
|
expect(decodedCommand).toContain('$source = ');
|
||||||
|
expect(decodedCommand).toContain('C:\\Users\\aakhter\\AppData\\Local\\Temp\\codeman-document-preview-cache\\');
|
||||||
|
expect(decodedCommand).toContain('.docx');
|
||||||
|
expect(decodedCommand).toContain('$output = ');
|
||||||
|
expect(decodedCommand).toContain('.pdf');
|
||||||
|
expect(mockedExecFile).not.toHaveBeenCalledWith(
|
||||||
|
'soffice',
|
||||||
|
expect.any(Array),
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { generateFirstPageThumbnail } from '../src/document-thumbnailer.js';
|
||||||
|
import { clearDocumentPreviewCache } from '../src/document-preview-cache.js';
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||||
|
callback(null, { stdout: '', stderr: '' });
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||||
|
readFile: vi.fn(async () => Buffer.from('png')),
|
||||||
|
readdir: vi.fn(async () => ['converted.pdf']),
|
||||||
|
mkdir: vi.fn(async () => undefined),
|
||||||
|
mkdtemp: vi.fn(async () => '/tmp/codeman-thumb-test'),
|
||||||
|
rename: vi.fn(async () => undefined),
|
||||||
|
rm: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedExecFile = vi.mocked(execFile);
|
||||||
|
const mockedStat = vi.mocked(fs.stat);
|
||||||
|
const mockedReadFile = vi.mocked(fs.readFile);
|
||||||
|
const mockedReaddir = vi.mocked(fs.readdir);
|
||||||
|
const mockedMkdir = vi.mocked(fs.mkdir);
|
||||||
|
const mockedMkdtemp = vi.mocked(fs.mkdtemp);
|
||||||
|
const mockedRename = vi.mocked(fs.rename);
|
||||||
|
const mockedRm = vi.mocked(fs.rm);
|
||||||
|
|
||||||
|
describe('document-thumbnailer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
clearDocumentPreviewCache();
|
||||||
|
mockedStat.mockImplementation(async (path) => {
|
||||||
|
const pathText = String(path);
|
||||||
|
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||||
|
throw new Error('ENOENT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { size: 500 * 1024 * 1024, mtimeMs: 12345, isFile: () => true } as never;
|
||||||
|
});
|
||||||
|
mockedReadFile.mockResolvedValue(Buffer.from('large thumbnail') as never);
|
||||||
|
mockedReaddir.mockResolvedValue(['converted.pdf'] as never);
|
||||||
|
mockedMkdir.mockResolvedValue(undefined as never);
|
||||||
|
mockedMkdtemp.mockResolvedValue('/tmp/codeman-thumb-test' as never);
|
||||||
|
mockedRename.mockResolvedValue(undefined as never);
|
||||||
|
mockedRm.mockResolvedValue(undefined as never);
|
||||||
|
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||||
|
callback(null, { stdout: '', stderr: '' });
|
||||||
|
return {} as never;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders first-page thumbnails for large documents without an app-level size cap', async () => {
|
||||||
|
const result = await generateFirstPageThumbnail('/tmp/large-deck.pdf', 'pdf');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
content: Buffer.from('large thumbnail'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||||
|
'pdftoppm',
|
||||||
|
expect.arrayContaining(['-png', '-singlefile', '-f', '1', '-l', '1']),
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
||||||
|
mockedMkdtemp.mockImplementation(async (prefix) =>
|
||||||
|
String(prefix).includes('codeman-document-preview-cache')
|
||||||
|
? '/tmp/codeman-document-preview-cache/work-test'
|
||||||
|
: '/tmp/codeman-thumb-pdf-test'
|
||||||
|
);
|
||||||
|
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||||
|
callback(null, { stdout: '', stderr: '' });
|
||||||
|
return {} as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await generateFirstPageThumbnail('/tmp/deck.pptx', 'pptx');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
content: Buffer.from('large thumbnail'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
const pdftoppmCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'pdftoppm');
|
||||||
|
expect(pdftoppmCall?.[1]).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining('codeman-document-preview-cache')])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
|
|||||||
// ========== Image Detection ==========
|
// ========== Image Detection ==========
|
||||||
|
|
||||||
describe('image detection', () => {
|
describe('image detection', () => {
|
||||||
it('should emit image:detected (popup) for .png files', () => {
|
it('should emit attachment:detected for .png files', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
watcher.on('image:detected', handler);
|
watcher.on('attachment:detected', handler);
|
||||||
|
|
||||||
watcher.watchSession('session-1', '/home/user/project');
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||||
@@ -161,11 +161,14 @@ describe('ImageWatcher', () => {
|
|||||||
expect(event.fileName).toBe('screenshot.png');
|
expect(event.fileName).toBe('screenshot.png');
|
||||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||||
expect(event.relativePath).toBe('screenshot.png');
|
expect(event.relativePath).toBe('screenshot.png');
|
||||||
|
expect(event.extension).toBe('png');
|
||||||
|
expect(event.attachmentType).toBe('image');
|
||||||
|
expect(event.size).toBe(2048);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
|
it('should not emit legacy image:detected for .png attachment cards', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
watcher.on('attachment:detected', handler);
|
watcher.on('image:detected', handler);
|
||||||
|
|
||||||
watcher.watchSession('session-1', '/home/user/project');
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||||
|
|||||||
Reference in New Issue
Block a user