0ae7a6a677
Finish 同步与首次访问时将私有资产 API 图片复制到 public/.tmp-images, 避免其他用户转发链接后因无登录态无法加载页面内图片。 Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import {
|
|
analyzeChatMessageForSave,
|
|
buildChatSavePreviewFrameUrl,
|
|
buildChatSaveThumbnailUrl,
|
|
buildWorkspaceAssetUrl,
|
|
buildWorkspaceBaseHref,
|
|
buildWorkspaceThumbnailUrl,
|
|
extractStaticPageLinks,
|
|
findPublishHtml,
|
|
injectHtmlBaseHref,
|
|
materializePrivateAssetsInWorkspaceHtml,
|
|
materializePrivateAssetsInPublicHtmlFiles,
|
|
repairMissingHtmlAssetReferences,
|
|
resolveClosestHtmlRelativePath,
|
|
resolveChatSaveAnalysis,
|
|
sanitizeMessageContentForSave,
|
|
} from './mindspace-chat-save.mjs';
|
|
|
|
const USER_ID = 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e';
|
|
|
|
test('extractStaticPageLinks finds user-owned MindSpace html links', () => {
|
|
const content = `
|
|
页面已完成!
|
|
https://m.tkmind.cn/MindSpace/${USER_ID}/mapo-tofu.html
|
|
`;
|
|
const links = extractStaticPageLinks(content, { userId: USER_ID });
|
|
assert.equal(links.length, 1);
|
|
assert.equal(links[0].filename, 'mapo-tofu.html');
|
|
assert.equal(links[0].relativePath, 'public/mapo-tofu.html');
|
|
assert.equal(links[0].publicUrl, `https://m.tkmind.cn/MindSpace/${USER_ID}/public/mapo-tofu.html`);
|
|
});
|
|
|
|
test('extractStaticPageLinks ignores other users', () => {
|
|
const content = 'https://m.tkmind.cn/MindSpace/other-id/report.html';
|
|
const links = extractStaticPageLinks(content, { userId: USER_ID });
|
|
assert.equal(links.length, 0);
|
|
});
|
|
|
|
test('extractStaticPageLinks accepts legacy username urls when username provided', () => {
|
|
const content = 'https://goo.tkmind.cn/MindSpace/john/report.html';
|
|
const links = extractStaticPageLinks(content, { username: 'john' });
|
|
assert.equal(links.length, 1);
|
|
assert.equal(links[0].relativePath, 'public/report.html');
|
|
assert.equal(links[0].publicUrl, 'https://goo.tkmind.cn/MindSpace/john/public/report.html');
|
|
});
|
|
|
|
test('extractStaticPageLinks accepts uuid links when both user id and username are provided', () => {
|
|
const content = `https://goo.tkmind.cn/MindSpace/${USER_ID}/public/report.html`;
|
|
const links = extractStaticPageLinks(content, { userId: USER_ID, username: 'john' });
|
|
assert.equal(links.length, 1);
|
|
assert.equal(links[0].relativePath, 'public/report.html');
|
|
});
|
|
|
|
test('extractStaticPageLinks accepts legacy username links when user id is available', () => {
|
|
const content = 'https://goo.tkmind.cn/MindSpace/john/report.html';
|
|
const links = extractStaticPageLinks(content, { userId: USER_ID, username: 'john' });
|
|
assert.equal(links.length, 1);
|
|
assert.equal(links[0].publicUrl, 'https://goo.tkmind.cn/MindSpace/john/public/report.html');
|
|
});
|
|
|
|
test('analyzeChatMessageForSave prefers static html mode', () => {
|
|
const analysis = analyzeChatMessageForSave({
|
|
content: `链接 https://m.tkmind.cn/MindSpace/${USER_ID}/report.html`,
|
|
userId: USER_ID,
|
|
username: 'john',
|
|
h5Root: '/tmp/h5',
|
|
});
|
|
assert.equal(analysis.contentMode, 'static_html');
|
|
assert.equal(analysis.filename, 'report.html');
|
|
assert.match(analysis.suggestedTitle, /report/i);
|
|
});
|
|
|
|
test('analyzeChatMessageForSave falls back to markdown article', () => {
|
|
const analysis = analyzeChatMessageForSave({
|
|
content: '这是一段普通 AI 回答,没有页面链接。',
|
|
userId: USER_ID,
|
|
username: 'john',
|
|
h5Root: '/tmp/h5',
|
|
});
|
|
assert.equal(analysis.contentMode, 'markdown');
|
|
assert.equal(analysis.links.length, 0);
|
|
assert.match(analysis.suggestedTitle, /普通 AI 回答/);
|
|
});
|
|
|
|
test('buildWorkspaceAssetUrl and buildWorkspaceThumbnailUrl', () => {
|
|
assert.equal(
|
|
buildWorkspaceAssetUrl(USER_ID, 'mapo-tofu.html'),
|
|
`/MindSpace/${USER_ID}/mapo-tofu.html`,
|
|
);
|
|
assert.equal(
|
|
buildWorkspaceThumbnailUrl(USER_ID, 'reports/guide.html'),
|
|
`/MindSpace/${USER_ID}/reports/guide.thumbnail.svg`,
|
|
);
|
|
});
|
|
|
|
test('buildChatSavePreviewUrls use local api routes', () => {
|
|
assert.match(
|
|
buildChatSavePreviewFrameUrl({
|
|
sessionId: 'sess-1',
|
|
messageId: 'msg-1',
|
|
selectedLinkIndex: 2,
|
|
}),
|
|
/^\/api\/mindspace\/v1\/pages\/chat-save-preview\?/,
|
|
);
|
|
assert.match(
|
|
buildChatSaveThumbnailUrl({
|
|
sessionId: 'sess-1',
|
|
messageId: 'msg-1',
|
|
previewTitle: '标题',
|
|
}),
|
|
/chat-save-thumbnail\?.*preview_title=%E6%A0%87%E9%A2%98/,
|
|
);
|
|
});
|
|
|
|
test('findPublishHtml resolves nested public html by basename', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-'));
|
|
const publishDir = path.join(root, 'MindSpace', USER_ID, 'public');
|
|
await fs.mkdir(publishDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(publishDir, 'welcome.html'),
|
|
'<!DOCTYPE html><html><head><title>Welcome</title></head><body>hi</body></html>',
|
|
'utf8',
|
|
);
|
|
const loaded = await findPublishHtml(root, USER_ID, 'welcome.html');
|
|
assert.equal(loaded.relativePath, 'public/welcome.html');
|
|
assert.match(loaded.content, /Welcome/);
|
|
});
|
|
|
|
test('findPublishHtml resolves close html filename typos when match is unique', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-typo-'));
|
|
const publishDir = path.join(root, 'MindSpace', USER_ID, 'public');
|
|
await fs.mkdir(publishDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(publishDir, 'memind-deep-analysis.html'),
|
|
'<!DOCTYPE html><html><head><title>Deep Analysis</title></head><body>ok</body></html>',
|
|
'utf8',
|
|
);
|
|
const loaded = await findPublishHtml(root, USER_ID, 'public/memind-dep-analysis.html');
|
|
assert.equal(loaded.relativePath, 'public/memind-deep-analysis.html');
|
|
assert.match(loaded.content, /Deep Analysis/);
|
|
});
|
|
|
|
test('resolveClosestHtmlRelativePath ignores ambiguous typo matches', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-ambiguous-'));
|
|
await fs.mkdir(path.join(root, 'public'), { recursive: true });
|
|
await fs.writeFile(path.join(root, 'public', 'mindmap-plan.html'), '<html></html>', 'utf8');
|
|
await fs.writeFile(path.join(root, 'public', 'mindmap-play.html'), '<html></html>', 'utf8');
|
|
const resolved = await resolveClosestHtmlRelativePath(root, 'public/mindmap-plab.html');
|
|
assert.equal(resolved, null);
|
|
});
|
|
|
|
test('injectHtmlBaseHref adds base tag for relative assets', () => {
|
|
const html = '<html><head><title>x</title></head><body></body></html>';
|
|
const next = injectHtmlBaseHref(html, buildWorkspaceBaseHref(USER_ID, 'public/page.html'));
|
|
assert.match(next, /<base href="\/MindSpace\//);
|
|
});
|
|
|
|
test('sanitizeMessageContentForSave repairs duplicated streaming urls', () => {
|
|
const raw =
|
|
'httpshttps://://gg22.t.tkmkmindind.cn.cn/M/MindSpace/a6fb1e97/public/public/w/welcomeelcome-v-v22.html.html';
|
|
const cleaned = sanitizeMessageContentForSave(raw);
|
|
assert.doesNotMatch(cleaned, /httpshttps/);
|
|
assert.match(cleaned, /https:\/\//);
|
|
});
|
|
|
|
test('resolveChatSaveAnalysis falls back to local html filename hints', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-hint-'));
|
|
const publishDir = path.join(root, 'MindSpace', USER_ID, 'public');
|
|
await fs.mkdir(publishDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(publishDir, 'welcome-v2.html'),
|
|
'<!DOCTYPE html><html><head><title>Welcome v2</title></head><body>hi</body></html>',
|
|
'utf8',
|
|
);
|
|
const corrupted =
|
|
'请看 welcomeelcome-v-v22.html.html,链接可能坏了 httpshttps://://broken.example/MindSpace/other/report.html';
|
|
const result = await resolveChatSaveAnalysis({
|
|
content: corrupted,
|
|
userId: USER_ID,
|
|
username: 'john',
|
|
h5Root: root,
|
|
});
|
|
assert.equal(result.analysis.contentMode, 'static_html');
|
|
assert.equal(result.resolvedHtml?.relativePath, 'public/welcome-v2.html');
|
|
});
|
|
|
|
test('materializePrivateAssetsInWorkspaceHtml copies images to public/.tmp-images', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-chat-save-materialize-'));
|
|
const storageRoot = path.join(root, 'storage');
|
|
const assetId = '11111111-1111-4111-8111-111111111111';
|
|
const userId = USER_ID;
|
|
const storageKey = `users/${userId}/assets/${assetId}/versions/v1`;
|
|
const sourcePath = path.join(storageRoot, storageKey);
|
|
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
|
await fs.writeFile(sourcePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
|
|
const publishDir = path.join(root, 'MindSpace', userId, 'public');
|
|
await fs.mkdir(publishDir, { recursive: true });
|
|
const htmlRelativePath = 'public/look.html';
|
|
const htmlPath = path.join(root, 'MindSpace', userId, htmlRelativePath);
|
|
const html = `<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`;
|
|
await fs.writeFile(htmlPath, html, 'utf8');
|
|
|
|
const pool = {
|
|
query: async () => [
|
|
[{ id: assetId, mime_type: 'image/jpeg', original_filename: 'hero.jpg', storage_key: storageKey }],
|
|
],
|
|
};
|
|
|
|
const result = await materializePrivateAssetsInWorkspaceHtml({
|
|
pool,
|
|
storageRoot,
|
|
h5Root: root,
|
|
userId,
|
|
html,
|
|
htmlRelativePath,
|
|
writeBack: true,
|
|
});
|
|
|
|
assert.equal(result.changed, true);
|
|
assert.match(result.html, /\.tmp-images\/11111111-1111-4111-8111-111111111111\.jpg/);
|
|
await fs.stat(path.join(publishDir, '.tmp-images', `${assetId}.jpg`));
|
|
});
|
|
|
|
test('materializePrivateAssetsInPublicHtmlFiles rewrites referenced public html files', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-chat-save-materialize-batch-'));
|
|
const storageRoot = path.join(root, 'storage');
|
|
const assetId = '44444444-4444-4444-8444-444444444444';
|
|
const userId = USER_ID;
|
|
const storageKey = `users/${userId}/assets/${assetId}/versions/v1`;
|
|
const sourcePath = path.join(storageRoot, storageKey);
|
|
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
|
await fs.writeFile(sourcePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
|
|
const publishDir = path.join(root, 'MindSpace', userId);
|
|
const htmlRelativePath = 'public/share.html';
|
|
const htmlPath = path.join(publishDir, htmlRelativePath);
|
|
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
|
|
const html = `<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`;
|
|
await fs.writeFile(htmlPath, html, 'utf8');
|
|
|
|
const pool = {
|
|
query: async () => [
|
|
[{ id: assetId, mime_type: 'image/jpeg', original_filename: 'hero.jpg', storage_key: storageKey }],
|
|
],
|
|
};
|
|
|
|
const result = await materializePrivateAssetsInPublicHtmlFiles({
|
|
pool,
|
|
storageRoot,
|
|
h5Root: root,
|
|
userId,
|
|
publishDir,
|
|
relativePaths: [htmlRelativePath],
|
|
});
|
|
|
|
assert.equal(result.materialized.length, 1);
|
|
assert.equal(result.materialized[0].relativePath, htmlRelativePath);
|
|
const saved = await fs.readFile(htmlPath, 'utf8');
|
|
assert.match(saved, /\.tmp-images\/44444444-4444-4444-8444-444444444444\.jpg/);
|
|
});
|
|
|
|
test('repairMissingHtmlAssetReferences swaps invalid html asset ids from chat message', async () => {
|
|
const validId = '22222222-2222-4222-8222-222222222222';
|
|
const invalidId = '33333333-3333-4333-8333-333333333333';
|
|
const html = `<img src="/api/mindspace/v1/assets/${invalidId}/download?inline=1">`;
|
|
const sourceContent = `[图片1]: /api/mindspace/v1/assets/${validId}/download?inline=1`;
|
|
const pool = {
|
|
query: async (_sql, params) => [[{ id: validId }]],
|
|
};
|
|
const result = await repairMissingHtmlAssetReferences({
|
|
pool,
|
|
userId: USER_ID,
|
|
sourceContent,
|
|
html,
|
|
});
|
|
assert.equal(result.changed, true);
|
|
assert.match(result.html, new RegExp(validId));
|
|
assert.doesNotMatch(result.html, new RegExp(invalidId));
|
|
});
|