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:
+12
-1
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}>
|
||||
打开完整聊天
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user