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,
|
ensureHtmlThumbnail,
|
||||||
scheduleHtmlThumbnail,
|
scheduleHtmlThumbnail,
|
||||||
} from './mindspace-thumbnails.mjs';
|
} 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 { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
|
||||||
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
|
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
|
||||||
|
|
||||||
@@ -828,10 +828,21 @@ export function createAssetService(pool, options = {}) {
|
|||||||
[assetId],
|
[assetId],
|
||||||
);
|
);
|
||||||
const html = await fs.readFile(assetPath, 'utf8');
|
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, {
|
return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, {
|
||||||
title: asset.displayName,
|
title: asset.displayName,
|
||||||
subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML',
|
subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML',
|
||||||
contentStorageKey: versions[0]?.storage_key,
|
contentStorageKey: versions[0]?.storage_key,
|
||||||
|
contentBaseDir,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
pageThumbnailKey,
|
pageThumbnailKey,
|
||||||
writeThumbnail,
|
writeThumbnail,
|
||||||
bufferToImageDataUri,
|
bufferToImageDataUri,
|
||||||
|
resolveWorkspaceHtmlContentBaseDir,
|
||||||
} from './mindspace-thumbnails.mjs';
|
} from './mindspace-thumbnails.mjs';
|
||||||
import { resolvePublishDir } from './user-publish.mjs';
|
import { resolvePublishDir } from './user-publish.mjs';
|
||||||
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
||||||
@@ -951,6 +952,10 @@ export function createPageService(pool, options = {}) {
|
|||||||
title,
|
title,
|
||||||
subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
|
subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
|
||||||
contentStorageKey: ctx.row.storage_key,
|
contentStorageKey: ctx.row.storage_key,
|
||||||
|
contentBaseDir: resolveWorkspaceHtmlContentBaseDir(
|
||||||
|
ctx.workspacePublishDir,
|
||||||
|
ctx.workspaceHtmlRelativePath,
|
||||||
|
),
|
||||||
force: true,
|
force: true,
|
||||||
...metaOverrides,
|
...metaOverrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -451,6 +451,30 @@ export function isModernFeedThumbnail(svg) {
|
|||||||
return /width="540" height="720"/.test(svg) && /filter id="grain"/.test(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 = {}) {
|
export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
|
||||||
const signals = extractCoverSignals(html, meta);
|
const signals = extractCoverSignals(html, meta);
|
||||||
const coverDataUri = await resolveCoverDataUri({
|
const coverDataUri = await resolveCoverDataUri({
|
||||||
@@ -467,7 +491,13 @@ export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta
|
|||||||
export async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
|
export async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
|
||||||
const existing = await readThumbnailIfExists(storageRoot, storageKey);
|
const existing = await readThumbnailIfExists(storageRoot, storageKey);
|
||||||
if (existing && isModernFeedThumbnail(existing) && !meta.force) {
|
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);
|
return generateHtmlThumbnail(storageRoot, storageKey, html, meta);
|
||||||
}
|
}
|
||||||
@@ -480,17 +510,34 @@ export async function ensurePageThumbnail({
|
|||||||
workspacePublishDir = null,
|
workspacePublishDir = null,
|
||||||
workspaceHtmlRelativePath = null,
|
workspaceHtmlRelativePath = null,
|
||||||
}) {
|
}) {
|
||||||
|
const contentBaseDir =
|
||||||
|
meta.contentBaseDir ??
|
||||||
|
resolveWorkspaceHtmlContentBaseDir(workspacePublishDir, workspaceHtmlRelativePath);
|
||||||
|
const enrichedMeta = contentBaseDir ? { ...meta, contentBaseDir } : meta;
|
||||||
|
|
||||||
if (workspacePublishDir && workspaceHtmlRelativePath) {
|
if (workspacePublishDir && workspaceHtmlRelativePath) {
|
||||||
const sidecar = await readThumbnailIfExists(
|
const sidecar = await readThumbnailIfExists(
|
||||||
workspacePublishDir,
|
workspacePublishDir,
|
||||||
workspaceThumbnailRelativePath(workspaceHtmlRelativePath),
|
workspaceThumbnailRelativePath(workspaceHtmlRelativePath),
|
||||||
);
|
);
|
||||||
if (sidecar && isModernFeedThumbnail(sidecar)) {
|
if (sidecar && isModernFeedThumbnail(sidecar)) {
|
||||||
await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar);
|
if (thumbnailHasEmbeddedPhoto(sidecar)) {
|
||||||
return 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) {
|
export async function readThumbnailIfExists(storageRoot, storageKey) {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
generateHtmlThumbnail,
|
generateHtmlThumbnail,
|
||||||
isModernFeedThumbnail,
|
isModernFeedThumbnail,
|
||||||
resolveCoverDataUri,
|
resolveCoverDataUri,
|
||||||
|
resolveWorkspaceHtmlContentBaseDir,
|
||||||
shouldUseScenicBackground,
|
shouldUseScenicBackground,
|
||||||
|
thumbnailHasEmbeddedPhoto,
|
||||||
} from './mindspace-thumbnails.mjs';
|
} from './mindspace-thumbnails.mjs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
@@ -163,3 +165,38 @@ test('resolveCoverDataUri rejects paths outside content base', async () => {
|
|||||||
});
|
});
|
||||||
assert.equal(dataUri, null);
|
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 {
|
try {
|
||||||
const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId);
|
const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId);
|
||||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
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);
|
res.setHeader('X-Request-Id', req.requestId);
|
||||||
return res.send(svg);
|
return res.send(svg);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2614,7 +2614,7 @@ api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId);
|
const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId);
|
||||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
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);
|
res.setHeader('X-Request-Id', req.requestId);
|
||||||
return res.send(svg);
|
return res.send(svg);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ export function SpaceChatPanel({
|
|||||||
openRecharge,
|
openRecharge,
|
||||||
retryConnect,
|
retryConnect,
|
||||||
} = chat;
|
} = 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -75,6 +85,14 @@ export function SpaceChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-chat-panel-actions">
|
<div className="space-chat-panel-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn space-chat-panel-new-session"
|
||||||
|
disabled={busy || memoryLoading}
|
||||||
|
onClick={handleNewSession}
|
||||||
|
>
|
||||||
|
新会话
|
||||||
|
</button>
|
||||||
{!hideOpenFullChat ? (
|
{!hideOpenFullChat ? (
|
||||||
<button type="button" className="ghost-btn" onClick={onOpenFullChat}>
|
<button type="button" className="ghost-btn" onClick={onOpenFullChat}>
|
||||||
打开完整聊天
|
打开完整聊天
|
||||||
|
|||||||
@@ -306,6 +306,12 @@ export function usePageEditSubChat({
|
|||||||
}
|
}
|
||||||
}, [connectSubSession, start]);
|
}, [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);
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -335,6 +341,7 @@ export function usePageEditSubChat({
|
|||||||
dismissNotice,
|
dismissNotice,
|
||||||
retryConnect,
|
retryConnect,
|
||||||
openRecharge: () => setNotice('余额不足,请充值后继续使用'),
|
openRecharge: () => setNotice('余额不足,请充值后继续使用'),
|
||||||
|
newSession,
|
||||||
close,
|
close,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6045,7 +6045,7 @@ body,
|
|||||||
border-radius: 24px 24px 0 0;
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user