From ab0718938e4874ee221f3f5cb395555c42fcde37 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 16 Jun 2026 17:57:00 -0700 Subject: [PATCH] Fix OA workspace cover thumbnails and add space chat new session. Resolve mindspace-cover images from workspace directories when generating feed thumbnails, refresh stale SVGs missing embedded photos, and disable thumbnail API caching so fixes take effect immediately. Also add a new session control to the space chat panel, including on mobile. Co-authored-by: Cursor --- mindspace-assets.mjs | 13 +++++++- mindspace-pages.mjs | 5 +++ mindspace-thumbnails.mjs | 55 ++++++++++++++++++++++++++++--- mindspace-thumbnails.test.mjs | 37 +++++++++++++++++++++ server.mjs | 4 +-- src/components/SpaceChatPanel.tsx | 18 ++++++++++ src/hooks/usePageEditSubChat.ts | 7 ++++ src/index.css | 2 +- 8 files changed, 133 insertions(+), 8 deletions(-) diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index bbc1f70..1bc85d5 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -8,7 +8,7 @@ import { ensureHtmlThumbnail, scheduleHtmlThumbnail, } from './mindspace-thumbnails.mjs'; -import { mirrorAssetToZone, removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs'; +import { mirrorAssetToZone, removeZoneMirror, resolveUserWorkspaceRoot, resolveZoneFilePath } from './user-space.mjs'; import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; @@ -828,10 +828,21 @@ export function createAssetService(pool, options = {}) { [assetId], ); const html = await fs.readFile(assetPath, 'utf8'); + const contentBaseDir = + asset.sourceType === 'workspace' && h5Root + ? path.dirname( + resolveZoneFilePath( + resolveUserWorkspaceRoot(h5Root, { id: userId }), + asset.categoryCode, + asset.filename, + ), + ) + : undefined; return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, { title: asset.displayName, subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML', contentStorageKey: versions[0]?.storage_key, + contentBaseDir, }); }; diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 723993e..e0821ba 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -10,6 +10,7 @@ import { pageThumbnailKey, writeThumbnail, bufferToImageDataUri, + resolveWorkspaceHtmlContentBaseDir, } from './mindspace-thumbnails.mjs'; import { resolvePublishDir } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; @@ -951,6 +952,10 @@ export function createPageService(pool, options = {}) { title, subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`, contentStorageKey: ctx.row.storage_key, + contentBaseDir: resolveWorkspaceHtmlContentBaseDir( + ctx.workspacePublishDir, + ctx.workspaceHtmlRelativePath, + ), force: true, ...metaOverrides, }; diff --git a/mindspace-thumbnails.mjs b/mindspace-thumbnails.mjs index c0b83cd..ec2db5d 100644 --- a/mindspace-thumbnails.mjs +++ b/mindspace-thumbnails.mjs @@ -451,6 +451,30 @@ export function isModernFeedThumbnail(svg) { return /width="540" height="720"/.test(svg) && /filter id="grain"/.test(svg); } +export function thumbnailHasEmbeddedPhoto(svg) { + return /href="data:image\//.test(String(svg ?? '')); +} + +export function resolveWorkspaceHtmlContentBaseDir(workspacePublishDir, workspaceHtmlRelativePath) { + if (!workspacePublishDir || !workspaceHtmlRelativePath) return undefined; + const dir = path.posix.dirname(String(workspaceHtmlRelativePath).replace(/^\/+/, '')); + return dir === '.' ? workspacePublishDir : path.join(workspacePublishDir, dir); +} + +async function shouldRegenerateThumbnailForCover(storageRoot, html, meta, existing) { + if (!existing || !isModernFeedThumbnail(existing) || meta.force) return Boolean(meta.force); + const signals = extractCoverSignals(html, meta); + if (!signals.image) return false; + if (thumbnailHasEmbeddedPhoto(existing)) return false; + const coverDataUri = await resolveCoverDataUri({ + storageRoot, + contentStorageKey: meta.contentStorageKey, + contentBaseDir: meta.contentBaseDir, + imageUrl: signals.image, + }); + return Boolean(coverDataUri); +} + export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { const signals = extractCoverSignals(html, meta); const coverDataUri = await resolveCoverDataUri({ @@ -467,7 +491,13 @@ export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta export async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { const existing = await readThumbnailIfExists(storageRoot, storageKey); if (existing && isModernFeedThumbnail(existing) && !meta.force) { - return existing; + const needsCoverRefresh = await shouldRegenerateThumbnailForCover( + storageRoot, + html, + meta, + existing, + ); + if (!needsCoverRefresh) return existing; } return generateHtmlThumbnail(storageRoot, storageKey, html, meta); } @@ -480,17 +510,34 @@ export async function ensurePageThumbnail({ workspacePublishDir = null, workspaceHtmlRelativePath = null, }) { + const contentBaseDir = + meta.contentBaseDir ?? + resolveWorkspaceHtmlContentBaseDir(workspacePublishDir, workspaceHtmlRelativePath); + const enrichedMeta = contentBaseDir ? { ...meta, contentBaseDir } : meta; + if (workspacePublishDir && workspaceHtmlRelativePath) { const sidecar = await readThumbnailIfExists( workspacePublishDir, workspaceThumbnailRelativePath(workspaceHtmlRelativePath), ); if (sidecar && isModernFeedThumbnail(sidecar)) { - await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar); - return sidecar; + if (thumbnailHasEmbeddedPhoto(sidecar)) { + await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar); + return sidecar; + } + const needsCoverRefresh = await shouldRegenerateThumbnailForCover( + workspacePublishDir, + html, + enrichedMeta, + sidecar, + ); + if (!needsCoverRefresh) { + await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar); + return sidecar; + } } } - return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, meta); + return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, enrichedMeta); } export async function readThumbnailIfExists(storageRoot, storageKey) { diff --git a/mindspace-thumbnails.test.mjs b/mindspace-thumbnails.test.mjs index 3916fac..a1d8c6a 100644 --- a/mindspace-thumbnails.test.mjs +++ b/mindspace-thumbnails.test.mjs @@ -8,7 +8,9 @@ import { generateHtmlThumbnail, isModernFeedThumbnail, resolveCoverDataUri, + resolveWorkspaceHtmlContentBaseDir, shouldUseScenicBackground, + thumbnailHasEmbeddedPhoto, } from './mindspace-thumbnails.mjs'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -163,3 +165,38 @@ test('resolveCoverDataUri rejects paths outside content base', async () => { }); assert.equal(dataUri, null); }); + +test('resolveWorkspaceHtmlContentBaseDir resolves nested html paths', () => { + assert.equal( + resolveWorkspaceHtmlContentBaseDir('/workspace', 'oa/report.html'), + path.join('/workspace', 'oa'), + ); + assert.equal(resolveWorkspaceHtmlContentBaseDir('/workspace', 'page.html'), '/workspace'); +}); + +test('ensureHtmlThumbnail refreshes cached thumbnail when cover image becomes resolvable', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-thumb-refresh-')); + const htmlDir = path.join(root, 'oa'); + await fs.mkdir(htmlDir, { recursive: true }); + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', + 'base64', + ); + await fs.writeFile(path.join(htmlDir, 'hero.png'), png); + const html = ` + OA 报告 + + `; + const key = 'users/u1/pages/p1/thumbnail.svg'; + const cached = buildFeedThumbnailSvg(extractCoverSignals(html, { title: 'OA 报告' })); + assert.equal(thumbnailHasEmbeddedPhoto(cached), false); + await fs.mkdir(path.dirname(path.join(root, key)), { recursive: true }); + await fs.writeFile(path.join(root, key), cached, 'utf8'); + + const svg = await ensureHtmlThumbnail(root, key, html, { + title: 'OA 报告', + contentBaseDir: htmlDir, + }); + assert.equal(thumbnailHasEmbeddedPhoto(svg), true); + assert.match(svg, /href="data:image\/png;base64,/); +}); diff --git a/server.mjs b/server.mjs index 2b799b3..a859ff0 100644 --- a/server.mjs +++ b/server.mjs @@ -1942,7 +1942,7 @@ api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => { try { const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); - res.set('Cache-Control', 'private, max-age=300'); + res.set('Cache-Control', 'private, no-cache'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { @@ -2614,7 +2614,7 @@ api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { try { const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); - res.set('Cache-Control', 'private, max-age=300'); + res.set('Cache-Control', 'private, no-cache'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { diff --git a/src/components/SpaceChatPanel.tsx b/src/components/SpaceChatPanel.tsx index 914cca1..7843974 100644 --- a/src/components/SpaceChatPanel.tsx +++ b/src/components/SpaceChatPanel.tsx @@ -48,6 +48,16 @@ export function SpaceChatPanel({ openRecharge, retryConnect, } = chat; + const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting'; + const memoryLoading = chatBridge ? false : mainChat.memoryLoading; + + const handleNewSession = () => { + if (chatBridge) { + void chatBridge.newSession(); + return; + } + void mainChat.newSession(); + }; useEffect(() => { if (!open) return; @@ -75,6 +85,14 @@ export function SpaceChatPanel({
+ {!hideOpenFullChat ? (