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 <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-16 17:57:00 -07:00
parent 6d99d762da
commit ab0718938e
8 changed files with 133 additions and 8 deletions
+12 -1
View File
@@ -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,
});
};
+5
View File
@@ -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,
};
+51 -4
View File
@@ -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) {
+37
View File
@@ -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 = `<!doctype html><html><head>
<title>OA 报告</title>
<meta name="mindspace-cover" content='{"cover":"hero.png","tag":"报告"}' />
</head><body></body></html>`;
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,/);
});
+2 -2
View File
@@ -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) {
+18
View File
@@ -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({
</div>
</div>
<div className="space-chat-panel-actions">
<button
type="button"
className="ghost-btn space-chat-panel-new-session"
disabled={busy || memoryLoading}
onClick={handleNewSession}
>
</button>
{!hideOpenFullChat ? (
<button type="button" className="ghost-btn" onClick={onOpenFullChat}>
+7
View File
@@ -306,6 +306,12 @@ export function usePageEditSubChat({
}
}, [connectSubSession, start]);
const newSession = useCallback(async () => {
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
await close({ merge: false });
await start();
}, [chatState, close, start]);
const startedRef = useRef(false);
useEffect(() => {
@@ -335,6 +341,7 @@ export function usePageEditSubChat({
dismissNotice,
retryConnect,
openRecharge: () => setNotice('余额不足,请充值后继续使用'),
newSession,
close,
};
}
+1 -1
View File
@@ -6045,7 +6045,7 @@ body,
border-radius: 24px 24px 0 0;
}
.space-chat-panel-actions .ghost-btn {
.space-chat-panel-actions .ghost-btn:not(.space-chat-panel-new-session) {
display: none;
}
}