feat: complete mindspace conversation packages
This commit is contained in:
@@ -351,6 +351,27 @@ MindSpace Domain
|
||||
- H5 增加 package view: 图片、文件、页面、公开链接按对话聚合展示。
|
||||
- 对历史数据做 best-effort 回填。
|
||||
|
||||
当前开发分支进展:
|
||||
|
||||
- 已新增 conversation package 表/注册层/API/manifest 导出和 manifest 校验脚本。
|
||||
- H5 已增加“对话包”查看入口,隐藏内部 message UUID,只展示用户可理解的产物信息。
|
||||
- `public/*.html` 已接入 Finish 后登记和历史会话懒回填,可在 package 中看到公开页面。
|
||||
- 上传图片/文件已支持 `sessionId/messageId` provenance;全新会话首条图片上传会在 agent run 返回真实 `sessionId` 后按 `messageId` 后置认领进 package。
|
||||
- 公开页长图下载已登记为 `long_image` artifact,PNG 同步写入 conversation package 私有对象,canonical URL 保持公开页长图下载入口。
|
||||
- Finish 后 `public` workspace 同步已携带同轮 `messageId`,让生成文件、图片、docx companion 进入 package 时具备消息级 provenance。
|
||||
- 聊天 docx 下载已抽出 package 登记模块并加入测试,确保 docx 私有对象、artifact、manifest 刷新一致。
|
||||
- 发布入口已覆盖 chat-sourced page 的 publication companion 流程,确认 `public_html`、同目录 `docx`、`long_image` 会在真实 `publish()` 调用中进入同一 package。
|
||||
- H5 package view 已增加按产物类型筛选、计数、空包引导和加载失败重试提示。
|
||||
- H5 package view 已增加按消息维度筛选,用户可从某一轮请求反查对应产物,且不展示内部 message UUID。
|
||||
- Manifest 已包含 `messages[]` / `agentRuns[]` provenance 聚合,便于追溯某条消息或某次 agent run 产生了哪些 artifact。
|
||||
- 已增加 package manifest 一致性校验脚本,覆盖 artifact URL / storage object 的可读性。
|
||||
- 读取 package 前已增加历史会话 best-effort 扫描回填,覆盖 `h5_page_records`、`h5_publish_records`、`h5_upload_sessions` 中已有 `source_session_id` 的页面、发布页、companion 文件和已完成上传。
|
||||
- Package 读取前的 backfill/hydrate 编排已收拢进 `MindSpaceService` facade,`server.mjs` 路由层只负责鉴权后调用 facade。
|
||||
|
||||
剩余开发项:
|
||||
|
||||
- P2 当前分支实现已完成;进入提交前需保持 guard、build、全量测试和回归 smoke 通过。
|
||||
|
||||
验收:
|
||||
|
||||
- 一次对话中上传图片、生成 HTML、发布页面后,package 页面能看到所有产物。
|
||||
|
||||
@@ -759,6 +759,103 @@ export function createAssetService(pool, options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const claimUploadArtifactsForConversation = async (userId, { sessionId, messageId } = {}) => {
|
||||
const normalizedSessionId = String(sessionId ?? '').trim();
|
||||
const normalizedMessageId = String(messageId ?? '').trim();
|
||||
if (!normalizedSessionId || !normalizedMessageId) return { claimedCount: 0 };
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const [rows] = await conn.query(
|
||||
`SELECT
|
||||
u.*,
|
||||
a.id AS asset_id,
|
||||
a.mime_type AS asset_mime_type,
|
||||
a.original_filename AS asset_original_filename,
|
||||
a.display_name AS asset_display_name,
|
||||
a.size_bytes AS asset_size_bytes,
|
||||
a.updated_at AS asset_updated_at,
|
||||
v.storage_key AS asset_storage_key,
|
||||
c.category_code
|
||||
FROM h5_upload_sessions u
|
||||
JOIN h5_assets a
|
||||
ON a.id = u.completed_asset_id AND a.user_id = u.user_id
|
||||
LEFT JOIN h5_asset_versions v
|
||||
ON v.id = a.current_version_id AND v.asset_id = a.id
|
||||
LEFT JOIN h5_space_categories c
|
||||
ON c.id = a.category_id
|
||||
WHERE u.user_id = ?
|
||||
AND u.source_message_id = ?
|
||||
AND u.status = 'completed'
|
||||
AND (u.source_session_id IS NULL OR u.source_session_id = '')
|
||||
LIMIT 20
|
||||
FOR UPDATE`,
|
||||
[userId, normalizedMessageId],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
await conn.commit();
|
||||
return { claimedCount: 0 };
|
||||
}
|
||||
await conn.query(
|
||||
`UPDATE h5_upload_sessions
|
||||
SET source_session_id = ?
|
||||
WHERE user_id = ?
|
||||
AND source_message_id = ?
|
||||
AND status = 'completed'
|
||||
AND (source_session_id IS NULL OR source_session_id = '')`,
|
||||
[normalizedSessionId, userId, normalizedMessageId],
|
||||
);
|
||||
await conn.commit();
|
||||
|
||||
const now = Date.now();
|
||||
let claimedCount = 0;
|
||||
for (const row of rows) {
|
||||
const upload = {
|
||||
...row,
|
||||
source_session_id: normalizedSessionId,
|
||||
source_message_id: normalizedMessageId,
|
||||
detected_mime_type: row.asset_mime_type,
|
||||
actual_size: row.asset_size_bytes,
|
||||
};
|
||||
const asset = {
|
||||
id: row.asset_id,
|
||||
user_id: userId,
|
||||
categoryCode: row.category_code,
|
||||
mimeType: row.asset_mime_type,
|
||||
filename: row.asset_original_filename,
|
||||
displayName: row.asset_display_name,
|
||||
sizeBytes: asNumber(row.asset_size_bytes),
|
||||
updatedAt: asNumber(row.asset_updated_at),
|
||||
};
|
||||
const artifact = await registerUploadArtifactForConversation({
|
||||
registry: conversationPackageRegistry,
|
||||
userId,
|
||||
upload,
|
||||
asset,
|
||||
assetId: row.asset_id,
|
||||
storageKey: row.asset_storage_key,
|
||||
canonicalUrl: publicUrlForAsset({
|
||||
id: row.asset_id,
|
||||
user_id: userId,
|
||||
category_code: row.category_code,
|
||||
mime_type: row.asset_mime_type,
|
||||
storage_key: row.asset_storage_key,
|
||||
original_filename: row.asset_original_filename,
|
||||
}),
|
||||
now,
|
||||
});
|
||||
if (artifact) claimedCount += 1;
|
||||
}
|
||||
return { claimedCount };
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelUpload = async (userId, uploadId) => {
|
||||
const conn = await pool.getConnection();
|
||||
let storageKey;
|
||||
@@ -1278,6 +1375,7 @@ export function createAssetService(pool, options = {}) {
|
||||
createUpload,
|
||||
writeUploadContent,
|
||||
completeUpload,
|
||||
claimUploadArtifactsForConversation,
|
||||
cancelUpload,
|
||||
createChatAsset,
|
||||
listAssets,
|
||||
|
||||
+143
-1
@@ -67,6 +67,33 @@ function createMockPool(state) {
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('FROM h5_upload_sessions u') && sql.includes('FOR UPDATE')) {
|
||||
if (sql.includes('u.source_message_id = ?')) {
|
||||
const rows = state.uploads
|
||||
.filter(
|
||||
(item) =>
|
||||
item.user_id === params[0] &&
|
||||
item.source_message_id === params[1] &&
|
||||
item.status === 'completed' &&
|
||||
!item.source_session_id,
|
||||
)
|
||||
.map((upload) => {
|
||||
const asset = state.assets.find((item) => item.id === upload.completed_asset_id);
|
||||
const version = state.versions.find((item) => item.id === asset?.current_version_id);
|
||||
const category = state.categories.find((item) => item.id === asset?.category_id);
|
||||
return {
|
||||
...upload,
|
||||
asset_id: asset.id,
|
||||
asset_mime_type: asset.mime_type,
|
||||
asset_original_filename: asset.original_filename,
|
||||
asset_display_name: asset.display_name,
|
||||
asset_size_bytes: asset.size_bytes,
|
||||
asset_updated_at: asset.updated_at ?? 1,
|
||||
asset_storage_key: version.storage_key,
|
||||
category_code: category?.category_code ?? 'public',
|
||||
};
|
||||
});
|
||||
return [rows];
|
||||
}
|
||||
const upload = state.uploads.find(
|
||||
(item) => item.id === params[0] && item.user_id === params[1],
|
||||
);
|
||||
@@ -78,10 +105,12 @@ function createMockPool(state) {
|
||||
state.assets.push({
|
||||
id: params[0],
|
||||
user_id: params[1],
|
||||
category_id: params[3],
|
||||
status: params[13],
|
||||
risk_level: params[11],
|
||||
current_version_id: params[8],
|
||||
original_filename: params[6],
|
||||
display_name: params[7],
|
||||
mime_type: params[5],
|
||||
size_bytes: params[9],
|
||||
});
|
||||
@@ -102,7 +131,7 @@ function createMockPool(state) {
|
||||
space.used_bytes += params[1];
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes("status = 'completed'")) {
|
||||
if (sql.includes("SET status = 'completed'")) {
|
||||
const upload = state.uploads.find(
|
||||
(item) => item.id === params[2] && item.user_id === params[3],
|
||||
);
|
||||
@@ -111,6 +140,33 @@ function createMockPool(state) {
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('JOIN h5_asset_versions v')) {
|
||||
if (sql.includes('u.source_message_id = ?')) {
|
||||
const rows = state.uploads
|
||||
.filter(
|
||||
(item) =>
|
||||
item.user_id === params[0] &&
|
||||
item.source_message_id === params[1] &&
|
||||
item.status === 'completed' &&
|
||||
!item.source_session_id,
|
||||
)
|
||||
.map((upload) => {
|
||||
const asset = state.assets.find((item) => item.id === upload.completed_asset_id);
|
||||
const version = state.versions.find((item) => item.id === asset?.current_version_id);
|
||||
const category = state.categories.find((item) => item.id === asset?.category_id);
|
||||
return {
|
||||
...upload,
|
||||
asset_id: asset.id,
|
||||
asset_mime_type: asset.mime_type,
|
||||
asset_original_filename: asset.original_filename,
|
||||
asset_display_name: asset.display_name,
|
||||
asset_size_bytes: asset.size_bytes,
|
||||
asset_updated_at: asset.updated_at ?? 1,
|
||||
asset_storage_key: version.storage_key,
|
||||
category_code: category?.category_code ?? 'public',
|
||||
};
|
||||
});
|
||||
return [rows];
|
||||
}
|
||||
const asset = state.assets.find(
|
||||
(item) => item.id === params[0] && item.user_id === params[1],
|
||||
);
|
||||
@@ -134,6 +190,20 @@ function createMockPool(state) {
|
||||
},
|
||||
]];
|
||||
}
|
||||
if (sql.includes('SET source_session_id = ?')) {
|
||||
state.uploads
|
||||
.filter(
|
||||
(item) =>
|
||||
item.user_id === params[1] &&
|
||||
item.source_message_id === params[2] &&
|
||||
item.status === 'completed' &&
|
||||
!item.source_session_id,
|
||||
)
|
||||
.forEach((item) => {
|
||||
item.source_session_id = params[0];
|
||||
});
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes("status IN ('reserved', 'uploaded') AND expires_at")) {
|
||||
return [state.uploads.filter((item) => item.expires_at <= params[0])];
|
||||
}
|
||||
@@ -341,6 +411,78 @@ test('registerUploadArtifactForConversation skips uploads without source session
|
||||
);
|
||||
});
|
||||
|
||||
test('claimUploadArtifactsForConversation attaches pre-session uploads to package', async () => {
|
||||
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-'));
|
||||
const calls = [];
|
||||
const state = {
|
||||
categories: [{ id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }],
|
||||
spaces: [{ id: 'space-1', user_id: 'user-1', quota_bytes: 1000, used_bytes: 0, reserved_bytes: 0 }],
|
||||
uploads: [
|
||||
{
|
||||
id: 'upload-1',
|
||||
user_id: 'user-1',
|
||||
space_id: 'space-1',
|
||||
category_id: 'cat-public',
|
||||
filename: 'first.png',
|
||||
status: 'completed',
|
||||
completed_asset_id: 'asset-1',
|
||||
source_session_id: null,
|
||||
source_message_id: 'message-1',
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
user_id: 'user-1',
|
||||
category_id: 'cat-public',
|
||||
current_version_id: 'version-1',
|
||||
original_filename: 'users/user-1/public/images/first.png',
|
||||
display_name: 'first.png',
|
||||
mime_type: 'image/png',
|
||||
size_bytes: 123,
|
||||
updated_at: 1000,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
id: 'version-1',
|
||||
asset_id: 'asset-1',
|
||||
storage_key: 'users/user-1/public/images/first.png',
|
||||
scan_status: 'passed',
|
||||
},
|
||||
],
|
||||
};
|
||||
const service = createAssetService(createMockPool(state), {
|
||||
storageRoot,
|
||||
conversationPackageRegistry: {
|
||||
async ensurePackage(input) {
|
||||
calls.push(['ensurePackage', input]);
|
||||
return { id: 'cp_session-1' };
|
||||
},
|
||||
async recordArtifact(input) {
|
||||
calls.push(['recordArtifact', input]);
|
||||
return input;
|
||||
},
|
||||
async writeManifestForSession(input) {
|
||||
calls.push(['writeManifestForSession', input]);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.claimUploadArtifactsForConversation('user-1', {
|
||||
sessionId: 'session-1',
|
||||
messageId: 'message-1',
|
||||
});
|
||||
|
||||
assert.equal(result.claimedCount, 1);
|
||||
assert.equal(state.uploads[0].source_session_id, 'session-1');
|
||||
assert.deepEqual(calls[0], ['ensurePackage', { userId: 'user-1', sessionId: 'session-1', now: calls[0][1].now }]);
|
||||
assert.equal(calls[1][1].artifactKind, 'input_image');
|
||||
assert.equal(calls[1][1].messageId, 'message-1');
|
||||
assert.equal(calls[1][1].assetId, 'asset-1');
|
||||
});
|
||||
|
||||
test('completeUpload publishes public images to the public temp image library', async () => {
|
||||
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-'));
|
||||
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-'));
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
export async function registerChatDocxArtifactForConversation({
|
||||
registry,
|
||||
user,
|
||||
bundle,
|
||||
requestBody = {},
|
||||
filename,
|
||||
buffer,
|
||||
now = Date.now(),
|
||||
}) {
|
||||
if (!registry || !user?.id || !filename || !Buffer.isBuffer(buffer)) return null;
|
||||
const sessionId = String(requestBody.session_id ?? requestBody.sessionId ?? '').trim();
|
||||
const messageId = String(requestBody.message_id ?? requestBody.messageId ?? '').trim();
|
||||
if (!sessionId || !messageId) return null;
|
||||
const selectedLinkIndex = Number(requestBody.selected_link_index ?? requestBody.selectedLinkIndex ?? 0);
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${user.id}:${sessionId}:${messageId}:${selectedLinkIndex}:${filename}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
const artifactId = `ca_docx_${hash}`;
|
||||
const relativePath = `artifacts/${artifactId}/${filename}`;
|
||||
const { packageRecord, writeResult } = await registry.putObjectForSession({
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
title: bundle?.source?.session?.name ?? null,
|
||||
relativePath,
|
||||
body: buffer,
|
||||
});
|
||||
const canonicalUrl =
|
||||
`/api/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}` +
|
||||
`/artifacts/${encodeURIComponent(artifactId)}/download`;
|
||||
const artifact = await registry.recordArtifact({
|
||||
id: artifactId,
|
||||
packageId: packageRecord.id,
|
||||
artifactKind: 'docx',
|
||||
messageId,
|
||||
displayName: filename,
|
||||
mimeType: DOCX_MIME_TYPE,
|
||||
sizeBytes: buffer.length,
|
||||
storageKey: writeResult.key,
|
||||
canonicalUrl,
|
||||
sortOrder: now,
|
||||
now,
|
||||
});
|
||||
await registry.writeManifestForSession({
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
});
|
||||
return artifact;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
registerChatDocxArtifactForConversation,
|
||||
} from './mindspace-chat-docx-package.mjs';
|
||||
|
||||
test('registerChatDocxArtifactForConversation writes docx object and artifact manifest entry', async () => {
|
||||
const calls = [];
|
||||
const buffer = Buffer.from('docx-body');
|
||||
const registry = {
|
||||
async putObjectForSession(input) {
|
||||
calls.push(['putObjectForSession', input]);
|
||||
return {
|
||||
packageRecord: { id: 'cp_session-1' },
|
||||
writeResult: {
|
||||
key: `users/user-1/conversations/session-1/${input.relativePath}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
async recordArtifact(input) {
|
||||
calls.push(['recordArtifact', input]);
|
||||
return input;
|
||||
},
|
||||
async writeManifestForSession(input) {
|
||||
calls.push(['writeManifestForSession', input]);
|
||||
return input;
|
||||
},
|
||||
};
|
||||
|
||||
const artifact = await registerChatDocxArtifactForConversation({
|
||||
registry,
|
||||
user: { id: 'user-1' },
|
||||
bundle: { source: { session: { name: 'Docx session' } } },
|
||||
requestBody: {
|
||||
session_id: 'session-1',
|
||||
message_id: 'message-1',
|
||||
selected_link_index: 2,
|
||||
},
|
||||
filename: '研究报告.docx',
|
||||
buffer,
|
||||
now: 1000,
|
||||
});
|
||||
|
||||
assert.equal(artifact.artifactKind, 'docx');
|
||||
assert.equal(artifact.messageId, 'message-1');
|
||||
assert.equal(artifact.mimeType, DOCX_MIME_TYPE);
|
||||
assert.equal(artifact.sizeBytes, buffer.length);
|
||||
assert.match(artifact.id, /^ca_docx_[a-f0-9]{16}$/);
|
||||
assert.match(
|
||||
artifact.canonicalUrl,
|
||||
/^\/api\/mindspace\/v1\/conversation-packages\/session-1\/artifacts\/ca_docx_[a-f0-9]{16}\/download$/,
|
||||
);
|
||||
assert.deepEqual(calls[0][1], {
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
title: 'Docx session',
|
||||
relativePath: `artifacts/${artifact.id}/研究报告.docx`,
|
||||
body: buffer,
|
||||
});
|
||||
assert.deepEqual(calls.at(-1), [
|
||||
'writeManifestForSession',
|
||||
{ userId: 'user-1', sessionId: 'session-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerChatDocxArtifactForConversation skips missing session or message source', async () => {
|
||||
const registry = {
|
||||
async putObjectForSession() {
|
||||
assert.fail('should not write object without source');
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await registerChatDocxArtifactForConversation({
|
||||
registry,
|
||||
user: { id: 'user-1' },
|
||||
requestBody: { session_id: 'session-1' },
|
||||
filename: 'report.docx',
|
||||
buffer: Buffer.from('docx'),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { assetInternals } from './mindspace-assets.mjs';
|
||||
import { pageInternals } from './mindspace-pages.mjs';
|
||||
import { publicationInternals } from './mindspace-publications.mjs';
|
||||
import { resolvePublishDir } from './user-publish.mjs';
|
||||
|
||||
const BACKFILL_LIMIT = 200;
|
||||
|
||||
function asNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
async function readUtf8IfExists(storageRoot, storageKey) {
|
||||
if (!storageRoot || !storageKey) return '';
|
||||
try {
|
||||
return await fs.readFile(path.resolve(storageRoot, storageKey), 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function backfillConversationPackageArtifacts({
|
||||
pool,
|
||||
registry,
|
||||
storageRoot,
|
||||
h5Root,
|
||||
user,
|
||||
sessionId,
|
||||
now = Date.now(),
|
||||
} = {}) {
|
||||
const userId = String(user?.id ?? '').trim();
|
||||
const normalizedSessionId = String(sessionId ?? '').trim();
|
||||
if (!pool || !registry || !userId || !normalizedSessionId) {
|
||||
return { pages: 0, publications: 0, uploads: 0 };
|
||||
}
|
||||
|
||||
let pages = 0;
|
||||
const [pageRows] = await pool.query(
|
||||
`SELECT p.id, p.title, p.source_message_id, p.current_version_id,
|
||||
pv.version_no, pv.content_asset_id,
|
||||
av.mime_type, av.size_bytes, av.storage_key
|
||||
FROM h5_page_records p
|
||||
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id AND pv.page_id = p.id
|
||||
LEFT JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
|
||||
WHERE p.user_id = ? AND p.source_session_id = ? AND p.status <> 'deleted'
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT ?`,
|
||||
[userId, normalizedSessionId, BACKFILL_LIMIT],
|
||||
);
|
||||
for (const row of pageRows) {
|
||||
const artifact = await pageInternals.registerPageArtifactForConversation({
|
||||
registry,
|
||||
userId,
|
||||
source: { sessionId: normalizedSessionId, messageId: row.source_message_id ?? null },
|
||||
page: {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
currentVersionId: row.current_version_id,
|
||||
sourceMessageId: row.source_message_id ?? null,
|
||||
},
|
||||
contentAssetId: row.content_asset_id ?? null,
|
||||
contentMimeType: row.mime_type ?? null,
|
||||
contentBytes: asNumber(row.size_bytes),
|
||||
storageKey: row.storage_key ?? null,
|
||||
versionNo: row.version_no,
|
||||
now,
|
||||
});
|
||||
if (artifact) pages += 1;
|
||||
}
|
||||
|
||||
let publications = 0;
|
||||
const [publicationRows] = await pool.query(
|
||||
`SELECT pr.id, pr.page_id, pr.public_url, pr.published_at,
|
||||
p.title, p.source_message_id,
|
||||
pv.bundle_asset_id,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path,
|
||||
av.storage_key, av.size_bytes
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
|
||||
JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.page_id = p.id
|
||||
LEFT JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
|
||||
WHERE pr.user_id = ? AND p.source_session_id = ? AND pr.status IN ('online', 'offline')
|
||||
ORDER BY pr.published_at DESC
|
||||
LIMIT ?`,
|
||||
[userId, normalizedSessionId, BACKFILL_LIMIT],
|
||||
);
|
||||
const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
|
||||
for (const row of publicationRows) {
|
||||
const artifact = await publicationInternals.registerPublicationArtifactForConversation({
|
||||
registry,
|
||||
userId,
|
||||
page: {
|
||||
page_id: row.page_id,
|
||||
title: row.title,
|
||||
source_session_id: normalizedSessionId,
|
||||
source_message_id: row.source_message_id ?? null,
|
||||
source_relative_path: row.source_relative_path ?? null,
|
||||
},
|
||||
publication: {
|
||||
id: row.id,
|
||||
publicUrl: row.public_url,
|
||||
publishedAt: row.published_at,
|
||||
},
|
||||
bundleAssetId: row.bundle_asset_id ?? null,
|
||||
storageKey: row.storage_key ?? null,
|
||||
htmlBytes: asNumber(row.size_bytes),
|
||||
html: await readUtf8IfExists(storageRoot, row.storage_key),
|
||||
publishDir,
|
||||
now,
|
||||
});
|
||||
if (artifact) publications += 1;
|
||||
}
|
||||
|
||||
let uploads = 0;
|
||||
const [uploadRows] = await pool.query(
|
||||
`SELECT u.id, u.filename, u.source_message_id, u.completed_asset_id,
|
||||
u.detected_mime_type, u.actual_size,
|
||||
a.mime_type, a.original_filename, a.display_name, a.size_bytes,
|
||||
v.storage_key
|
||||
FROM h5_upload_sessions u
|
||||
JOIN h5_assets a ON a.id = u.completed_asset_id AND a.user_id = u.user_id
|
||||
LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id AND v.asset_id = a.id
|
||||
WHERE u.user_id = ? AND u.source_session_id = ? AND u.status = 'completed'
|
||||
ORDER BY COALESCE(u.completed_at, u.created_at) DESC
|
||||
LIMIT ?`,
|
||||
[userId, normalizedSessionId, BACKFILL_LIMIT],
|
||||
);
|
||||
for (const row of uploadRows) {
|
||||
const artifact = await assetInternals.registerUploadArtifactForConversation({
|
||||
registry,
|
||||
userId,
|
||||
upload: {
|
||||
filename: row.filename,
|
||||
source_session_id: normalizedSessionId,
|
||||
source_message_id: row.source_message_id ?? null,
|
||||
detected_mime_type: row.detected_mime_type ?? row.mime_type ?? null,
|
||||
actual_size: row.actual_size ?? row.size_bytes ?? null,
|
||||
},
|
||||
asset: {
|
||||
id: row.completed_asset_id,
|
||||
mimeType: row.mime_type ?? row.detected_mime_type ?? null,
|
||||
filename: row.original_filename ?? row.filename,
|
||||
displayName: row.display_name ?? row.filename,
|
||||
sizeBytes: asNumber(row.size_bytes ?? row.actual_size),
|
||||
},
|
||||
assetId: row.completed_asset_id,
|
||||
storageKey: row.storage_key ?? null,
|
||||
canonicalUrl: `/api/mindspace/v1/assets/${encodeURIComponent(row.completed_asset_id)}/download`,
|
||||
now,
|
||||
});
|
||||
if (artifact) uploads += 1;
|
||||
}
|
||||
|
||||
return { pages, publications, uploads };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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 { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs';
|
||||
|
||||
test('backfillConversationPackageArtifacts registers pages publications and completed uploads', async () => {
|
||||
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'conversation-backfill-storage-'));
|
||||
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'conversation-backfill-h5-'));
|
||||
const publicationStorageKey = 'users/user-1/publications/pub-1/index.html';
|
||||
await fs.mkdir(path.dirname(path.join(storageRoot, publicationStorageKey)), { recursive: true });
|
||||
await fs.writeFile(path.join(storageRoot, publicationStorageKey), '<!doctype html><title>Published</title>');
|
||||
await fs.mkdir(path.join(h5Root, 'MindSpace', 'user-1', 'public'), { recursive: true });
|
||||
await fs.writeFile(path.join(h5Root, 'MindSpace', 'user-1', 'public', 'published.docx'), 'docx');
|
||||
const calls = [];
|
||||
const registry = {
|
||||
async ensurePackage(input) {
|
||||
calls.push(['ensurePackage', input]);
|
||||
return { id: 'cp-session-1' };
|
||||
},
|
||||
async recordArtifact(input) {
|
||||
calls.push(['recordArtifact', input]);
|
||||
return input;
|
||||
},
|
||||
async writeManifestForSession(input) {
|
||||
calls.push(['writeManifestForSession', input]);
|
||||
return input;
|
||||
},
|
||||
};
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
assert.equal(params[0], 'user-1');
|
||||
assert.equal(params[1], 'session-1');
|
||||
if (sql.includes('FROM h5_page_records p') && sql.includes('LEFT JOIN h5_page_versions')) {
|
||||
return [[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Draft page',
|
||||
source_message_id: 'message-1',
|
||||
current_version_id: 'version-1',
|
||||
version_no: 3,
|
||||
content_asset_id: 'asset-page',
|
||||
mime_type: 'text/html',
|
||||
size_bytes: 456,
|
||||
storage_key: 'users/user-1/pages/page-1/versions/v1.html',
|
||||
},
|
||||
]];
|
||||
}
|
||||
if (sql.includes('FROM h5_publish_records pr')) {
|
||||
return [[
|
||||
{
|
||||
id: 'pub-1',
|
||||
page_id: 'page-1',
|
||||
public_url: 'https://m.tkmind.cn/u/john/pages/published',
|
||||
published_at: 3000,
|
||||
title: 'Published',
|
||||
source_message_id: 'message-1',
|
||||
bundle_asset_id: 'asset-pub',
|
||||
source_relative_path: 'public/published.html',
|
||||
storage_key: publicationStorageKey,
|
||||
size_bytes: 789,
|
||||
},
|
||||
]];
|
||||
}
|
||||
if (sql.includes('FROM h5_upload_sessions u')) {
|
||||
return [[
|
||||
{
|
||||
id: 'upload-1',
|
||||
filename: 'input.png',
|
||||
source_message_id: 'message-2',
|
||||
completed_asset_id: 'asset-upload',
|
||||
detected_mime_type: 'image/png',
|
||||
actual_size: 123,
|
||||
mime_type: 'image/png',
|
||||
original_filename: 'input.png',
|
||||
display_name: 'input.png',
|
||||
size_bytes: 123,
|
||||
storage_key: 'users/user-1/public/images/input.png',
|
||||
},
|
||||
]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await backfillConversationPackageArtifacts({
|
||||
pool,
|
||||
registry,
|
||||
storageRoot,
|
||||
h5Root,
|
||||
user: { id: 'user-1' },
|
||||
sessionId: 'session-1',
|
||||
now: 1000,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { pages: 1, publications: 1, uploads: 1 });
|
||||
const kinds = calls
|
||||
.filter(([name]) => name === 'recordArtifact')
|
||||
.map(([, input]) => input.artifactKind)
|
||||
.sort();
|
||||
assert.deepEqual(kinds, ['docx', 'input_image', 'page', 'public_html']);
|
||||
const upload = calls.find(([, input]) => input?.artifactKind === 'input_image')?.[1];
|
||||
assert.equal(upload.messageId, 'message-2');
|
||||
assert.equal(upload.canonicalUrl, '/api/mindspace/v1/assets/asset-upload/download');
|
||||
} finally {
|
||||
await fs.rm(storageRoot, { recursive: true, force: true });
|
||||
await fs.rm(h5Root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export function createGetConversationPackageHandler({
|
||||
ensureMindSpaceEnabled = () => true,
|
||||
sendData = defaultSendData,
|
||||
mindSpaceError = defaultRouteError,
|
||||
beforeReadManifest,
|
||||
} = {}) {
|
||||
return async function getConversationPackage(req, res) {
|
||||
const activeRegistry = resolveRegistry(getRegistry ?? registry);
|
||||
@@ -38,6 +39,9 @@ export function createGetConversationPackageHandler({
|
||||
if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) {
|
||||
return res.status(403).json({ message: '无权访问该会话' });
|
||||
}
|
||||
if (typeof beforeReadManifest === 'function') {
|
||||
await beforeReadManifest({ req, userId: req.currentUser.id, sessionId });
|
||||
}
|
||||
const manifest = await activeRegistry.readManifestForSession({
|
||||
userId: req.currentUser.id,
|
||||
sessionId,
|
||||
@@ -57,6 +61,7 @@ export function createDownloadConversationPackageManifestHandler({
|
||||
getUserAuth,
|
||||
ensureMindSpaceEnabled = () => true,
|
||||
mindSpaceError = defaultRouteError,
|
||||
beforeReadManifest,
|
||||
} = {}) {
|
||||
return async function downloadConversationPackageManifest(req, res) {
|
||||
const activeRegistry = resolveRegistry(getRegistry ?? registry);
|
||||
@@ -72,6 +77,9 @@ export function createDownloadConversationPackageManifestHandler({
|
||||
if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) {
|
||||
return res.status(403).json({ message: '无权访问该会话' });
|
||||
}
|
||||
if (typeof beforeReadManifest === 'function') {
|
||||
await beforeReadManifest({ req, userId: req.currentUser.id, sessionId });
|
||||
}
|
||||
const manifest = await activeRegistry.readManifestForSession({
|
||||
userId: req.currentUser.id,
|
||||
sessionId,
|
||||
|
||||
@@ -38,6 +38,7 @@ function createResponseRecorder() {
|
||||
}
|
||||
|
||||
test('GET conversation package returns manifest for owned sessions', async () => {
|
||||
const hookCalls = [];
|
||||
const handler = createGetConversationPackageHandler({
|
||||
registry: {
|
||||
async readManifestForSession(input) {
|
||||
@@ -52,15 +53,24 @@ test('GET conversation package returns manifest for owned sessions', async () =>
|
||||
return true;
|
||||
},
|
||||
},
|
||||
beforeReadManifest(input) {
|
||||
hookCalls.push({
|
||||
userId: input.userId,
|
||||
sessionId: input.sessionId,
|
||||
req: input.req,
|
||||
});
|
||||
},
|
||||
});
|
||||
const req = { currentUser: { id: 'user-1' }, params: { sessionId: 'session-1' } };
|
||||
const res = createResponseRecorder();
|
||||
|
||||
await handler({ currentUser: { id: 'user-1' }, params: { sessionId: 'session-1' } }, res);
|
||||
await handler(req, res);
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.deepEqual(res.body, {
|
||||
data: { packageId: 'cp_session-1', artifacts: [{ artifactId: 'ca_1', kind: 'public_html' }] },
|
||||
});
|
||||
assert.deepEqual(hookCalls, [{ userId: 'user-1', sessionId: 'session-1', req }]);
|
||||
});
|
||||
|
||||
test('GET conversation package blocks sessions owned by another user', async () => {
|
||||
@@ -75,6 +85,9 @@ test('GET conversation package blocks sessions owned by another user', async ()
|
||||
return false;
|
||||
},
|
||||
},
|
||||
beforeReadManifest() {
|
||||
assert.fail('beforeReadManifest should not run for unauthorized sessions');
|
||||
},
|
||||
});
|
||||
const res = createResponseRecorder();
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
function issue(code, message, details = {}) {
|
||||
return { code, message, ...details };
|
||||
}
|
||||
|
||||
function isReadableUrl(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
return (
|
||||
text.startsWith('http://') ||
|
||||
text.startsWith('https://') ||
|
||||
text.startsWith('/api/') ||
|
||||
text.startsWith('/MindSpace/')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStorageKey(value) {
|
||||
return String(value ?? '').trim().replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
export async function verifyConversationPackageManifest(manifest, options = {}) {
|
||||
const issues = [];
|
||||
const statObject = typeof options.statObject === 'function' ? options.statObject : null;
|
||||
const packageId = String(manifest?.packageId ?? '').trim();
|
||||
const storagePrefix = normalizeStorageKey(manifest?.storagePrefix);
|
||||
const artifacts = Array.isArray(manifest?.artifacts) ? manifest.artifacts : null;
|
||||
|
||||
if (!packageId) issues.push(issue('missing_package_id', 'manifest.packageId is required'));
|
||||
if (!String(manifest?.userId ?? '').trim()) issues.push(issue('missing_user_id', 'manifest.userId is required'));
|
||||
if (!String(manifest?.sessionId ?? '').trim()) {
|
||||
issues.push(issue('missing_session_id', 'manifest.sessionId is required'));
|
||||
}
|
||||
if (!storagePrefix) issues.push(issue('missing_storage_prefix', 'manifest.storagePrefix is required'));
|
||||
if (!artifacts) issues.push(issue('invalid_artifacts', 'manifest.artifacts must be an array'));
|
||||
|
||||
const seenArtifactIds = new Set();
|
||||
for (const artifact of artifacts ?? []) {
|
||||
const artifactId = String(artifact?.artifactId ?? '').trim();
|
||||
if (!artifactId) {
|
||||
issues.push(issue('missing_artifact_id', 'artifact.artifactId is required'));
|
||||
continue;
|
||||
}
|
||||
if (seenArtifactIds.has(artifactId)) {
|
||||
issues.push(issue('duplicate_artifact_id', `duplicate artifact id: ${artifactId}`, { artifactId }));
|
||||
}
|
||||
seenArtifactIds.add(artifactId);
|
||||
|
||||
const canonicalUrl = String(artifact?.canonicalUrl ?? '').trim();
|
||||
const storageKey = normalizeStorageKey(artifact?.storageKey);
|
||||
if (!canonicalUrl && !storageKey) {
|
||||
issues.push(
|
||||
issue('artifact_without_backing_reference', 'artifact must have canonicalUrl or storageKey', { artifactId }),
|
||||
);
|
||||
}
|
||||
if (canonicalUrl && !isReadableUrl(canonicalUrl)) {
|
||||
issues.push(issue('invalid_canonical_url', `artifact canonicalUrl is not readable: ${canonicalUrl}`, {
|
||||
artifactId,
|
||||
canonicalUrl,
|
||||
}));
|
||||
}
|
||||
if (storageKey && storagePrefix && !storageKey.startsWith(`${storagePrefix}/`)) {
|
||||
issues.push(issue('storage_key_outside_package', 'artifact storageKey is outside manifest.storagePrefix', {
|
||||
artifactId,
|
||||
storageKey,
|
||||
storagePrefix,
|
||||
}));
|
||||
}
|
||||
if (storageKey && statObject) {
|
||||
const stat = await statObject(storageKey);
|
||||
if (!stat?.exists) {
|
||||
issues.push(issue('missing_backing_object', `artifact backing object is missing: ${storageKey}`, {
|
||||
artifactId,
|
||||
storageKey,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
export const conversationPackageVerifyInternals = {
|
||||
isReadableUrl,
|
||||
normalizeStorageKey,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
conversationPackageVerifyInternals,
|
||||
verifyConversationPackageManifest,
|
||||
} from './mindspace-conversation-package-verify.mjs';
|
||||
|
||||
test('verifyConversationPackageManifest accepts readable urls and package storage objects', async () => {
|
||||
const result = await verifyConversationPackageManifest(
|
||||
{
|
||||
packageId: 'cp_session-1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
storagePrefix: 'users/user-1/conversations/session-1',
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'ca_page',
|
||||
kind: 'page',
|
||||
canonicalUrl: '/api/mindspace/v1/pages/page-1/preview',
|
||||
},
|
||||
{
|
||||
artifactId: 'ca_docx',
|
||||
kind: 'docx',
|
||||
storageKey: 'users/user-1/conversations/session-1/artifacts/ca_docx/report.docx',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
async statObject(key) {
|
||||
return { key, exists: true };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.issues, []);
|
||||
});
|
||||
|
||||
test('verifyConversationPackageManifest reports missing or unsafe artifact references', async () => {
|
||||
const result = await verifyConversationPackageManifest(
|
||||
{
|
||||
packageId: 'cp_session-1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
storagePrefix: 'users/user-1/conversations/session-1',
|
||||
artifacts: [
|
||||
{ artifactId: 'ca_missing', kind: 'generated_file' },
|
||||
{ artifactId: 'ca_missing', kind: 'generated_file', canonicalUrl: 'file:///tmp/secret.txt' },
|
||||
{
|
||||
artifactId: 'ca_escape',
|
||||
kind: 'docx',
|
||||
storageKey: 'users/user-1/other/session-1/report.docx',
|
||||
},
|
||||
{
|
||||
artifactId: 'ca_gone',
|
||||
kind: 'docx',
|
||||
storageKey: 'users/user-1/conversations/session-1/report.docx',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
async statObject(key) {
|
||||
return { key, exists: key !== 'users/user-1/conversations/session-1/report.docx' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(
|
||||
result.issues.map((item) => item.code),
|
||||
[
|
||||
'artifact_without_backing_reference',
|
||||
'duplicate_artifact_id',
|
||||
'invalid_canonical_url',
|
||||
'storage_key_outside_package',
|
||||
'missing_backing_object',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('conversation package verify internals classify supported URLs', () => {
|
||||
assert.equal(conversationPackageVerifyInternals.isReadableUrl('https://m.tkmind.cn/MindSpace/u/public/a.html'), true);
|
||||
assert.equal(conversationPackageVerifyInternals.isReadableUrl('/api/mindspace/v1/assets/a/download'), true);
|
||||
assert.equal(conversationPackageVerifyInternals.isReadableUrl('/MindSpace/u/public/a.html'), true);
|
||||
assert.equal(conversationPackageVerifyInternals.isReadableUrl('file:///tmp/a.html'), false);
|
||||
});
|
||||
@@ -16,6 +16,9 @@ export const CONVERSATION_ARTIFACT_KINDS = Object.freeze([
|
||||
'pdf',
|
||||
]);
|
||||
|
||||
const MYSQL_SIGNED_INT_MAX = 2147483647;
|
||||
const MYSQL_SIGNED_INT_MIN = -2147483648;
|
||||
|
||||
function requiredString(value, field) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) {
|
||||
@@ -38,6 +41,18 @@ function optionalNumber(value) {
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function safeSortOrder(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
let normalized = Math.trunc(number);
|
||||
if (normalized > MYSQL_SIGNED_INT_MAX && Math.abs(normalized) >= 1_000_000_000_000) {
|
||||
normalized = Math.trunc(normalized / 1000);
|
||||
}
|
||||
if (normalized > MYSQL_SIGNED_INT_MAX) return MYSQL_SIGNED_INT_MAX;
|
||||
if (normalized < MYSQL_SIGNED_INT_MIN) return MYSQL_SIGNED_INT_MIN;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function safeStorageSegment(value, field) {
|
||||
const normalized = requiredString(value, field);
|
||||
const encoded = encodeURIComponent(normalized);
|
||||
@@ -117,7 +132,7 @@ export function createConversationArtifact(input) {
|
||||
sizeBytes: optionalNumber(input?.sizeBytes),
|
||||
storageKey: optionalString(input?.storageKey),
|
||||
canonicalUrl: optionalString(input?.canonicalUrl),
|
||||
sortOrder: Number.isFinite(Number(input?.sortOrder)) ? Number(input.sortOrder) : 0,
|
||||
sortOrder: safeSortOrder(input?.sortOrder),
|
||||
createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : now,
|
||||
};
|
||||
}
|
||||
@@ -163,6 +178,27 @@ function groupedManifestEntries(artifacts, keyField, idField) {
|
||||
return [...groups.values()].sort((a, b) => a.createdAt - b.createdAt || a[idField].localeCompare(b[idField]));
|
||||
}
|
||||
|
||||
function provenanceManifestEntries(artifacts, keyField, idField) {
|
||||
const groups = new Map();
|
||||
for (const artifact of artifacts) {
|
||||
const key = artifact[keyField];
|
||||
if (!key) continue;
|
||||
const existing = groups.get(key) ?? {
|
||||
[idField]: key,
|
||||
artifactIds: [],
|
||||
kinds: [],
|
||||
createdAt: artifact.createdAt,
|
||||
updatedAt: artifact.createdAt,
|
||||
};
|
||||
existing.artifactIds.push(artifact.id);
|
||||
if (!existing.kinds.includes(artifact.artifactKind)) existing.kinds.push(artifact.artifactKind);
|
||||
existing.createdAt = Math.min(existing.createdAt, artifact.createdAt);
|
||||
existing.updatedAt = Math.max(existing.updatedAt, artifact.createdAt);
|
||||
groups.set(key, existing);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => a.createdAt - b.createdAt || a[idField].localeCompare(b[idField]));
|
||||
}
|
||||
|
||||
export function buildConversationPackageManifest({ packageRecord, artifacts = [] }) {
|
||||
const record = createConversationPackageRecord(packageRecord);
|
||||
const normalizedArtifacts = artifacts
|
||||
@@ -184,6 +220,8 @@ export function buildConversationPackageManifest({ packageRecord, artifacts = []
|
||||
artifacts: normalizedArtifacts.map(artifactManifestEntry),
|
||||
pages: groupedManifestEntries(normalizedArtifacts, 'pageId', 'pageId'),
|
||||
publications: groupedManifestEntries(normalizedArtifacts, 'publicationId', 'publicationId'),
|
||||
messages: provenanceManifestEntries(normalizedArtifacts, 'messageId', 'messageId'),
|
||||
agentRuns: provenanceManifestEntries(normalizedArtifacts, 'agentRunId', 'agentRunId'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,5 +229,7 @@ export const mindspaceConversationPackageInternals = {
|
||||
requiredString,
|
||||
optionalNumber,
|
||||
optionalString,
|
||||
provenanceManifestEntries,
|
||||
safeStorageSegment,
|
||||
safeSortOrder,
|
||||
};
|
||||
|
||||
@@ -98,6 +98,24 @@ test('createConversationArtifact preserves unknown size as null', () => {
|
||||
assert.equal(artifact.sizeBytes, null);
|
||||
});
|
||||
|
||||
test('createConversationArtifact keeps sort order inside MySQL INT range', () => {
|
||||
const artifact = createConversationArtifact({
|
||||
packageId: 'cp_1',
|
||||
artifactKind: 'public_html',
|
||||
sortOrder: 1783003000123,
|
||||
now: 1000,
|
||||
});
|
||||
assert.equal(artifact.sortOrder, 1783003000);
|
||||
|
||||
const clamped = createConversationArtifact({
|
||||
packageId: 'cp_1',
|
||||
artifactKind: 'public_html',
|
||||
sortOrder: Number.MAX_SAFE_INTEGER,
|
||||
now: 1000,
|
||||
});
|
||||
assert.equal(clamped.sortOrder, 2147483647);
|
||||
});
|
||||
|
||||
test('buildConversationPackageManifest sorts artifacts and emits stable public shape', () => {
|
||||
const manifest = buildConversationPackageManifest({
|
||||
packageRecord: {
|
||||
@@ -113,6 +131,8 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
|
||||
artifactKind: 'page',
|
||||
displayName: '项目周报',
|
||||
pageId: 'page-1',
|
||||
messageId: 'msg-2',
|
||||
agentRunId: 'run-1',
|
||||
sortOrder: 2,
|
||||
createdAt: 3000,
|
||||
},
|
||||
@@ -120,6 +140,7 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
|
||||
id: 'ca_first',
|
||||
artifactKind: 'input_image',
|
||||
displayName: 'cover.png',
|
||||
messageId: 'msg-1',
|
||||
sortOrder: 1,
|
||||
createdAt: 2000,
|
||||
},
|
||||
@@ -129,6 +150,8 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
|
||||
displayName: 'report.html',
|
||||
pageId: 'page-1',
|
||||
publicationId: 'pub-1',
|
||||
messageId: 'msg-2',
|
||||
agentRunId: 'run-1',
|
||||
canonicalUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/report.html',
|
||||
sortOrder: 3,
|
||||
createdAt: 4000,
|
||||
@@ -146,6 +169,7 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
|
||||
assert.deepEqual(manifest.artifacts[0], {
|
||||
artifactId: 'ca_first',
|
||||
kind: 'input_image',
|
||||
messageId: 'msg-1',
|
||||
displayName: 'cover.png',
|
||||
sortOrder: 1,
|
||||
createdAt: 2000,
|
||||
@@ -170,4 +194,29 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
|
||||
updatedAt: 4000,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(manifest.messages, [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
artifactIds: ['ca_first'],
|
||||
kinds: ['input_image'],
|
||||
createdAt: 2000,
|
||||
updatedAt: 2000,
|
||||
},
|
||||
{
|
||||
messageId: 'msg-2',
|
||||
artifactIds: ['ca_late', 'ca_public'],
|
||||
kinds: ['page', 'public_html'],
|
||||
createdAt: 3000,
|
||||
updatedAt: 4000,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(manifest.agentRuns, [
|
||||
{
|
||||
agentRunId: 'run-1',
|
||||
artifactIds: ['ca_late', 'ca_public'],
|
||||
kinds: ['page', 'public_html'],
|
||||
createdAt: 3000,
|
||||
updatedAt: 4000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
|
||||
*/
|
||||
|
||||
const PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i;
|
||||
const PUBLIC_HTML_PATH_PATTERN_GLOBAL = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/gi;
|
||||
const PUBLIC_DOWNLOAD_ATTR_PATTERN = /(?:href|src)=["']([^"'#?\s]+)["']/gi;
|
||||
const DEFAULT_DOWNLOAD_SYNC_MIN_SIZE = 1024;
|
||||
|
||||
@@ -181,6 +182,17 @@ function messageText(message) {
|
||||
return [...textParts, displayText].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function messageId(message) {
|
||||
const id =
|
||||
message?.id ??
|
||||
message?.message_id ??
|
||||
message?.metadata?.messageId ??
|
||||
message?.metadata?.message_id ??
|
||||
null;
|
||||
const normalized = String(id ?? '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function normalizePublicHtmlRelativePath(relativePath) {
|
||||
const parts = String(relativePath ?? '')
|
||||
.replace(/^\/+/, '')
|
||||
@@ -192,6 +204,83 @@ export function normalizePublicHtmlRelativePath(relativePath) {
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function publicHtmlFileExists(publishDir, relativePath) {
|
||||
if (!publishDir) return false;
|
||||
const root = path.resolve(String(publishDir ?? ''));
|
||||
const normalized = normalizePublicHtmlRelativePath(relativePath);
|
||||
if (!root || !normalized.toLowerCase().endsWith('.html')) return false;
|
||||
const target = path.resolve(root, normalized);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false;
|
||||
return fs.existsSync(target) && fs.statSync(target).isFile();
|
||||
}
|
||||
|
||||
export function collectOwnPublicHtmlRelativePaths({
|
||||
messages,
|
||||
currentUser,
|
||||
publishDir,
|
||||
materialized = [],
|
||||
skipped = [],
|
||||
} = {}) {
|
||||
const paths = new Set();
|
||||
const add = (relativePath) => {
|
||||
const normalized = normalizePublicHtmlRelativePath(relativePath);
|
||||
if (normalized && publicHtmlFileExists(publishDir, normalized)) paths.add(normalized);
|
||||
};
|
||||
for (const relativePath of [...materialized, ...skipped]) add(relativePath);
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
const text = messageText(message);
|
||||
if (!text) continue;
|
||||
for (const link of extractStaticPageLinks(text, {
|
||||
userId: currentUser?.id,
|
||||
username: currentUser?.username ?? null,
|
||||
})) {
|
||||
add(link.relativePath);
|
||||
}
|
||||
for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) {
|
||||
add(match[1]);
|
||||
}
|
||||
}
|
||||
return [...paths].sort();
|
||||
}
|
||||
|
||||
export function collectOwnPublicHtmlArtifactRefs({
|
||||
messages,
|
||||
currentUser,
|
||||
publishDir,
|
||||
materialized = [],
|
||||
skipped = [],
|
||||
} = {}) {
|
||||
const refs = new Map();
|
||||
const add = (relativePath, sourceMessageId = null) => {
|
||||
const normalized = normalizePublicHtmlRelativePath(relativePath);
|
||||
if (!normalized || !publicHtmlFileExists(publishDir, normalized)) return;
|
||||
const existing = refs.get(normalized);
|
||||
refs.set(normalized, {
|
||||
relativePath: normalized,
|
||||
messageId: existing?.messageId ?? sourceMessageId ?? null,
|
||||
});
|
||||
};
|
||||
for (const relativePath of [...materialized, ...skipped]) add(relativePath);
|
||||
for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir })) {
|
||||
add(artifact.relativePath, artifact.messageId ?? null);
|
||||
}
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
const sourceMessageId = messageId(message);
|
||||
const text = messageText(message);
|
||||
if (!text) continue;
|
||||
for (const link of extractStaticPageLinks(text, {
|
||||
userId: currentUser?.id,
|
||||
username: currentUser?.username ?? null,
|
||||
})) {
|
||||
add(link.relativePath, sourceMessageId);
|
||||
}
|
||||
for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) {
|
||||
add(match[1], sourceMessageId);
|
||||
}
|
||||
}
|
||||
return [...refs.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
}
|
||||
|
||||
function isWriteLikeHtmlTool(name, args) {
|
||||
const action = String(args?.action ?? '').toLowerCase();
|
||||
const normalizedName = String(name ?? '').trim();
|
||||
@@ -241,6 +330,7 @@ function applyPublicHtmlEdit(existing, args) {
|
||||
export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = null } = {}) {
|
||||
const artifacts = new Map();
|
||||
for (const message of messages) {
|
||||
const sourceMessageId = messageId(message);
|
||||
for (const item of message?.content ?? []) {
|
||||
if (item?.type !== 'toolRequest') continue;
|
||||
const toolCall = item.toolCall?.value;
|
||||
@@ -262,7 +352,7 @@ export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = nu
|
||||
content = args.new_str;
|
||||
}
|
||||
if (content == null) continue;
|
||||
artifacts.set(relativePath, { relativePath, content });
|
||||
artifacts.set(relativePath, { relativePath, content, messageId: sourceMessageId });
|
||||
}
|
||||
}
|
||||
return [...artifacts.values()];
|
||||
@@ -386,6 +476,7 @@ export async function syncPublicHtmlAfterFinish({
|
||||
currentUser,
|
||||
publishDir,
|
||||
syncWorkspaceAssets,
|
||||
registerPublicHtmlArtifacts,
|
||||
sessionId = null,
|
||||
} = {}) {
|
||||
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
|
||||
@@ -394,10 +485,30 @@ export async function syncPublicHtmlAfterFinish({
|
||||
|
||||
const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir });
|
||||
const docxSync = syncPublicDocxDownloads({ publishDir });
|
||||
const publicHtmlArtifactRefs = collectOwnPublicHtmlArtifactRefs({
|
||||
messages,
|
||||
currentUser,
|
||||
publishDir,
|
||||
materialized,
|
||||
skipped,
|
||||
});
|
||||
const publicHtmlRelativePaths = publicHtmlArtifactRefs.map((ref) => ref.relativePath);
|
||||
const sourceMessageId = publicHtmlArtifactRefs.find((ref) => ref.messageId)?.messageId ?? null;
|
||||
let synced = false;
|
||||
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
|
||||
await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public', sourceSessionId: sessionId });
|
||||
await syncWorkspaceAssets(currentUser.id, {
|
||||
categoryCode: 'public',
|
||||
sourceSessionId: sessionId,
|
||||
sourceMessageId,
|
||||
});
|
||||
synced = true;
|
||||
}
|
||||
return { materialized, skipped, synced, docxSync };
|
||||
if (typeof registerPublicHtmlArtifacts === 'function' && currentUser?.id && sessionId) {
|
||||
await registerPublicHtmlArtifacts(currentUser.id, {
|
||||
sessionId,
|
||||
relativePaths: publicHtmlRelativePaths,
|
||||
artifactRefs: publicHtmlArtifactRefs,
|
||||
});
|
||||
}
|
||||
return { materialized, skipped, synced, docxSync, publicHtmlRelativePaths, publicHtmlArtifactRefs };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import path from 'node:path';
|
||||
import {
|
||||
hasRecentOwnPublicHtmlReference,
|
||||
isStubPublicHtmlContent,
|
||||
collectOwnPublicHtmlArtifactRefs,
|
||||
collectOwnPublicHtmlRelativePaths,
|
||||
materializeMissingPublicHtmlWrites,
|
||||
materializePublicHtmlWritesFromSessionEvent,
|
||||
syncPublicDocxDownloads,
|
||||
@@ -123,6 +125,7 @@ test('materializeMissingPublicHtmlWrites writes html from sandbox write_file too
|
||||
try {
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-public-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
@@ -158,6 +161,7 @@ test('syncPublicHtmlAfterFinish forwards session source to workspace sync', asyn
|
||||
const calls = [];
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-public-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
@@ -186,7 +190,115 @@ test('syncPublicHtmlAfterFinish forwards session source to workspace sync', asyn
|
||||
});
|
||||
|
||||
assert.equal(result.synced, true);
|
||||
assert.deepEqual(calls, [[CURRENT_USER.id, { categoryCode: 'public', sourceSessionId: 'session-1' }]]);
|
||||
assert.deepEqual(calls, [[
|
||||
CURRENT_USER.id,
|
||||
{
|
||||
categoryCode: 'public',
|
||||
sourceSessionId: 'session-1',
|
||||
sourceMessageId: 'msg-public-1',
|
||||
},
|
||||
]]);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('syncPublicHtmlAfterFinish registers existing public html artifacts for the session', async () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publishDir, 'public/123.html'), '<!doctype html><title>一二三</title>');
|
||||
const calls = [];
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-public-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[一二三](https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/123.html)`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await syncPublicHtmlAfterFinish({
|
||||
messages,
|
||||
currentUser: CURRENT_USER,
|
||||
publishDir,
|
||||
sessionId: 'session-1',
|
||||
syncWorkspaceAssets: async () => {},
|
||||
registerPublicHtmlArtifacts: async (...args) => {
|
||||
calls.push(args);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result.publicHtmlRelativePaths, ['public/123.html']);
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
CURRENT_USER.id,
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
relativePaths: ['public/123.html'],
|
||||
artifactRefs: [{ relativePath: 'public/123.html', messageId: 'msg-public-1' }],
|
||||
},
|
||||
],
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('collectOwnPublicHtmlArtifactRefs includes source message ids when available', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publishDir, 'public/own.html'), '<!doctype html><title>Own</title>');
|
||||
const refs = collectOwnPublicHtmlArtifactRefs({
|
||||
publishDir,
|
||||
currentUser: CURRENT_USER,
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-msg-1',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/own.html`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(refs, [{ relativePath: 'public/own.html', messageId: 'assistant-msg-1' }]);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('collectOwnPublicHtmlRelativePaths ignores missing and other-user public html links', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publishDir, 'public/own.html'), '<!doctype html><title>Own</title>');
|
||||
const paths = collectOwnPublicHtmlRelativePaths({
|
||||
publishDir,
|
||||
currentUser: CURRENT_USER,
|
||||
messages: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: [
|
||||
`https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/own.html`,
|
||||
`https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/missing.html`,
|
||||
'https://m.tkmind.cn/MindSpace/other-user/public/other.html',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(paths, ['public/own.html']);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -980,6 +980,13 @@ export function createPublicationService(pool, options = {}) {
|
||||
return {
|
||||
html: await refreshPublishedHtmlDownloadLinks(row, html),
|
||||
publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
|
||||
ownerId: row.owner_id,
|
||||
pageSource: {
|
||||
pageId: row.page_id,
|
||||
title: row.title,
|
||||
sourceSessionId: row.source_session_id ?? null,
|
||||
sourceMessageId: row.source_message_id ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1005,7 +1012,7 @@ export function createPublicationService(pool, options = {}) {
|
||||
|
||||
const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pr.*, u.id AS owner_id, p.title, av.storage_key
|
||||
`SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, av.storage_key
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_users u ON u.id = pr.user_id
|
||||
JOIN h5_page_records p ON p.id = pr.page_id
|
||||
@@ -1022,7 +1029,7 @@ export function createPublicationService(pool, options = {}) {
|
||||
const resolvePrivateLink = async (token, viewerId, requestMeta) => {
|
||||
const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex');
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pr.*, u.id AS owner_id, p.title, av.storage_key
|
||||
`SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, av.storage_key
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_users u ON u.id = pr.user_id
|
||||
JOIN h5_page_records p ON p.id = pr.page_id
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { publicationInternals } from './mindspace-publications.mjs';
|
||||
import { createPublicationService, publicationInternals } from './mindspace-publications.mjs';
|
||||
|
||||
test('normalizes safe long-page slugs', () => {
|
||||
assert.equal(publicationInternals.normalizeSlug(' product-intro-2026 '), 'product-intro-2026');
|
||||
@@ -150,6 +150,55 @@ test('buildPublicationThumbnailFallback points private resources at the public p
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePublic returns page provenance for package artifact registration', async () => {
|
||||
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-public-resolve-'));
|
||||
const storageKey = 'users/user-1/publications/pub-1/index.html';
|
||||
await fs.mkdir(path.dirname(path.join(storageRoot, storageKey)), { recursive: true });
|
||||
await fs.writeFile(path.join(storageRoot, storageKey), '<!doctype html><title>发布页</title>');
|
||||
const service = createPublicationService(
|
||||
{
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_publish_records pr') && sql.includes('COALESCE(u.slug, u.username)')) {
|
||||
return [[
|
||||
{
|
||||
id: 'pub-1',
|
||||
user_id: 'user-1',
|
||||
owner_id: 'user-1',
|
||||
page_id: 'page-1',
|
||||
page_version_id: 'version-1',
|
||||
title: '发布页',
|
||||
source_session_id: 'session-1',
|
||||
source_message_id: 'message-1',
|
||||
url_slug: 'published',
|
||||
public_url: 'https://m.tkmind.cn/u/john/pages/published',
|
||||
access_mode: 'public',
|
||||
status: 'online',
|
||||
view_count: 0,
|
||||
published_at: 3000,
|
||||
storage_key: storageKey,
|
||||
},
|
||||
]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
},
|
||||
{
|
||||
storageRoot,
|
||||
idFactory: () => 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.resolvePublic('john', 'published', null, null, {});
|
||||
|
||||
assert.equal(result.ownerId, 'user-1');
|
||||
assert.deepEqual(result.pageSource, {
|
||||
pageId: 'page-1',
|
||||
title: '发布页',
|
||||
sourceSessionId: 'session-1',
|
||||
sourceMessageId: 'message-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('prepareHtmlPublishContent inlines owned private image assets before scanning', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-'));
|
||||
const storageKey = 'users/user-1/assets/asset-1/current.png';
|
||||
@@ -348,6 +397,121 @@ test('registerPublicationArtifactForConversation records public companions', asy
|
||||
assert.match(docx.canonicalUrl, /\/MindSpace\/user-1\/public\/report\.docx$/);
|
||||
});
|
||||
|
||||
test('publish registers public html and companion artifacts for chat-sourced pages', async () => {
|
||||
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-flow-'));
|
||||
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-root-'));
|
||||
const pageStorageKey = 'users/user-1/pages/page-1/versions/v1.html';
|
||||
const pageHtml = '<!doctype html><title>Report</title><main>发布内容</main>';
|
||||
await fs.mkdir(path.dirname(path.join(storageRoot, pageStorageKey)), { recursive: true });
|
||||
await fs.writeFile(path.join(storageRoot, pageStorageKey), pageHtml);
|
||||
const publishDir = path.join(h5Root, 'MindSpace', 'user-1');
|
||||
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
|
||||
await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx');
|
||||
await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png');
|
||||
const registryCalls = [];
|
||||
let idCounter = 0;
|
||||
const pageRow = {
|
||||
page_id: 'page-1',
|
||||
title: 'Report',
|
||||
summary: '',
|
||||
page_type: 'html',
|
||||
template_id: 'static-html',
|
||||
current_version_id: 'version-1',
|
||||
source_session_id: 'session-1',
|
||||
source_message_id: 'message-1',
|
||||
user_id: 'user-1',
|
||||
space_id: 'space-1',
|
||||
page_version_id: 'version-1',
|
||||
version_no: 1,
|
||||
bundle_asset_id: null,
|
||||
storage_key: pageStorageKey,
|
||||
source_relative_path: 'public/report.html',
|
||||
};
|
||||
const pool = {
|
||||
async query(sql, params = []) {
|
||||
if (sql.includes('FROM h5_page_records p') && sql.includes('JOIN h5_page_versions')) {
|
||||
assert.deepEqual(params, ['page-1', 'user-1', 'version-1']);
|
||||
return [[pageRow]];
|
||||
}
|
||||
if (sql.includes('FROM h5_publish_records pr') && sql.includes('pr.page_id <>')) {
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('FROM h5_users')) {
|
||||
return [[{ public_slug: 'john' }]];
|
||||
}
|
||||
if (sql.includes('FROM h5_assets') || sql.includes('FROM h5_asset_versions')) {
|
||||
return [[]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
async getConnection() {
|
||||
return {
|
||||
async beginTransaction() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {},
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
|
||||
return [[{ quota_bytes: 1_000_000, used_bytes: 0, reserved_bytes: 0 }]];
|
||||
}
|
||||
if (sql.includes('COUNT(DISTINCT page_id)')) {
|
||||
return [[{ public_page_used: 0, page_already_online: 0 }]];
|
||||
}
|
||||
if (sql.includes('FROM h5_space_categories')) {
|
||||
return [[{ id: 'cat-public' }]];
|
||||
}
|
||||
if (sql.includes('SELECT id, page_version_id FROM h5_publish_records')) {
|
||||
return [[]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const service = createPublicationService(pool, {
|
||||
storageRoot,
|
||||
h5Root,
|
||||
idFactory: () => `id-${++idCounter}`,
|
||||
conversationPackageRegistry: {
|
||||
async ensurePackage(input) {
|
||||
registryCalls.push(['ensurePackage', input]);
|
||||
return { id: 'cp-session-1' };
|
||||
},
|
||||
async recordArtifact(input) {
|
||||
registryCalls.push(['recordArtifact', input]);
|
||||
return input;
|
||||
},
|
||||
async writeManifestForSession(input) {
|
||||
registryCalls.push(['writeManifestForSession', input]);
|
||||
return input;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const publication = await service.publish('user-1', 'page-1', {
|
||||
pageVersionId: 'version-1',
|
||||
accessMode: 'public',
|
||||
urlSlug: 'report',
|
||||
acknowledgedFindingIds: [],
|
||||
});
|
||||
|
||||
assert.equal(publication.id, 'id-4');
|
||||
const artifactKinds = registryCalls
|
||||
.filter(([name]) => name === 'recordArtifact')
|
||||
.map(([, input]) => input.artifactKind)
|
||||
.sort();
|
||||
assert.deepEqual(artifactKinds, ['docx', 'long_image', 'public_html']);
|
||||
const companion = registryCalls.find(([, input]) => input?.artifactKind === 'docx')?.[1];
|
||||
assert.equal(companion.messageId, 'message-1');
|
||||
assert.equal(companion.publicationId, 'id-4');
|
||||
assert.equal(registryCalls.at(-1)[0], 'writeManifestForSession');
|
||||
} finally {
|
||||
await fs.rm(storageRoot, { recursive: true, force: true });
|
||||
await fs.rm(h5Root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('registerPublicationArtifactForConversation skips pages without source session', async () => {
|
||||
assert.equal(
|
||||
await publicationInternals.registerPublicationArtifactForConversation({
|
||||
|
||||
+19
-1
@@ -28,7 +28,13 @@ function packageStorageKey(packageRecord, relativePath) {
|
||||
return `${record.storagePrefix}/${relative}`;
|
||||
}
|
||||
|
||||
export function createMindSpaceServiceFacade({ storageAdapter, publicBaseUrl } = {}) {
|
||||
export function createMindSpaceServiceFacade({
|
||||
storageAdapter,
|
||||
publicBaseUrl,
|
||||
conversationPackageBackfill,
|
||||
conversationPackagePublicHtmlHydrator,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const storage = requireStorageAdapter(storageAdapter);
|
||||
|
||||
return {
|
||||
@@ -93,6 +99,18 @@ export function createMindSpaceServiceFacade({ storageAdapter, publicBaseUrl } =
|
||||
const storagePrefix = cleanPrefix ? `${record.storagePrefix}/${cleanPrefix}` : record.storagePrefix;
|
||||
return storage.listObjects(storagePrefix);
|
||||
},
|
||||
|
||||
async prepareConversationPackageRead({ user, sessionId } = {}) {
|
||||
if (typeof conversationPackageBackfill === 'function') {
|
||||
await conversationPackageBackfill({ user, sessionId }).catch((error) => {
|
||||
logger?.warn?.('[MindSpace] conversation package DB backfill failed:', error?.message ?? error);
|
||||
});
|
||||
}
|
||||
if (typeof conversationPackagePublicHtmlHydrator === 'function') {
|
||||
return conversationPackagePublicHtmlHydrator({ user, sessionId });
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -125,3 +125,38 @@ test('MindSpace service facade writes manifest.json inside the conversation pack
|
||||
['ca_report'],
|
||||
);
|
||||
});
|
||||
|
||||
test('MindSpace service facade prepares conversation package reads through injected hooks', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-service-'));
|
||||
const calls = [];
|
||||
const warnings = [];
|
||||
const service = createMindSpaceServiceFacade({
|
||||
storageAdapter: createLocalMindSpaceStorageAdapter(root),
|
||||
publicBaseUrl: 'https://m.tkmind.cn',
|
||||
conversationPackageBackfill: async (input) => {
|
||||
calls.push(['backfill', input]);
|
||||
throw new Error('transient db issue');
|
||||
},
|
||||
conversationPackagePublicHtmlHydrator: async (input) => {
|
||||
calls.push(['hydrate', input]);
|
||||
return ['artifact-1'];
|
||||
},
|
||||
logger: {
|
||||
warn: (...args) => warnings.push(args),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.prepareConversationPackageRead({
|
||||
user: { id: 'user-1' },
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['artifact-1']);
|
||||
assert.deepEqual(
|
||||
calls.map(([name]) => name),
|
||||
['backfill', 'hydrate'],
|
||||
);
|
||||
assert.equal(calls[0][1].sessionId, 'session-1');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0][0], '[MindSpace] conversation package DB backfill failed:');
|
||||
});
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@
|
||||
"build:portal-runtime": "node scripts/build-portal-runtime.mjs",
|
||||
"check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only",
|
||||
"check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
|
||||
"check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
|
||||
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
|
||||
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
|
||||
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { verifyConversationPackageManifest } from '../mindspace-conversation-package-verify.mjs';
|
||||
import { createLocalMindSpaceStorageAdapter } from '../mindspace-storage-adapter.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Usage: node scripts/check-conversation-package-manifest.mjs --manifest <manifest.json> [--storage-root <dir>]',
|
||||
'',
|
||||
'Validates that a conversation package manifest has required metadata and every artifact has',
|
||||
'a readable canonicalUrl or a storageKey under manifest.storagePrefix.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let manifestPath = null;
|
||||
let storageRoot = null;
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--manifest' && argv[i + 1]) {
|
||||
manifestPath = path.resolve(argv[i + 1]);
|
||||
i += 1;
|
||||
} else if (argv[i] === '--storage-root' && argv[i + 1]) {
|
||||
storageRoot = path.resolve(argv[i + 1]);
|
||||
i += 1;
|
||||
} else if (argv[i] === '--help' || argv[i] === '-h') {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (!manifestPath) {
|
||||
console.error(usage());
|
||||
process.exit(2);
|
||||
}
|
||||
return { manifestPath, storageRoot };
|
||||
}
|
||||
|
||||
const { manifestPath, storageRoot } = parseArgs(process.argv);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
||||
const storageAdapter = storageRoot ? createLocalMindSpaceStorageAdapter(storageRoot) : null;
|
||||
const result = await verifyConversationPackageManifest(manifest, {
|
||||
statObject: storageAdapter ? (key) => storageAdapter.statObject(key) : null,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`OK: conversation package manifest ${path.relative(repoRoot, manifestPath) || manifestPath}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Found ${result.issues.length} conversation package manifest issue(s):`);
|
||||
for (const item of result.issues) {
|
||||
const suffix = item.artifactId ? ` [artifact ${item.artifactId}]` : '';
|
||||
console.error(`- ${item.code}${suffix}: ${item.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
+219
-37
@@ -48,6 +48,7 @@ import {
|
||||
createDownloadConversationPackageManifestHandler,
|
||||
createGetConversationPackageHandler,
|
||||
} from './mindspace-conversation-package-routes.mjs';
|
||||
import { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs';
|
||||
import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs';
|
||||
import { createMindSpaceServiceFacade } from './mindspace-service.mjs';
|
||||
import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs';
|
||||
@@ -55,7 +56,11 @@ import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
|
||||
import { createAssetAgentService } from './mindspace-asset-agent.mjs';
|
||||
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
|
||||
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
|
||||
import { createPublicationService, rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs';
|
||||
import {
|
||||
createPublicationService,
|
||||
publicationInternals,
|
||||
rewriteWorkspacePublicAssetReferences,
|
||||
} from './mindspace-publications.mjs';
|
||||
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
|
||||
import { createPlazaEventService } from './plaza-events.mjs';
|
||||
import { createPlazaRecommendService } from './plaza-recommend.mjs';
|
||||
@@ -96,7 +101,9 @@ import {
|
||||
resolveStaticHtmlContent,
|
||||
} from './mindspace-chat-save.mjs';
|
||||
import {
|
||||
collectOwnPublicHtmlArtifactRefs,
|
||||
materializePublicHtmlWritesFromSessionEvent,
|
||||
normalizePublicHtmlRelativePath,
|
||||
syncPublicHtmlAfterFinish,
|
||||
} from './mindspace-public-finish-sync.mjs';
|
||||
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
|
||||
@@ -119,6 +126,10 @@ import {
|
||||
renderLongImageBuffer,
|
||||
} from './mindspace-long-image.mjs';
|
||||
import { generateDocxBuffer } from './mindspace-docx-export.mjs';
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
registerChatDocxArtifactForConversation,
|
||||
} from './mindspace-chat-docx-package.mjs';
|
||||
import { scanContent } from './mindspace-content-scan.mjs';
|
||||
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
|
||||
import { createRechargeService } from './billing-recharge.mjs';
|
||||
@@ -258,6 +269,7 @@ let conversationMemoryService = null;
|
||||
let mindSpace = null;
|
||||
let mindSpaceAssets = null;
|
||||
let mindSpaceAudit = null;
|
||||
let mindSpaceServiceFacade = null;
|
||||
let mindSpaceConversationPackageRegistry = null;
|
||||
let mindSpacePages = null;
|
||||
let mindSpacePageLiveEdit = null;
|
||||
@@ -309,12 +321,23 @@ async function bootstrapUserAuth() {
|
||||
});
|
||||
const mindSpaceStorageRoot =
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||||
mindSpaceServiceFacade = createMindSpaceServiceFacade({
|
||||
storageAdapter: createLocalMindSpaceStorageAdapter(mindSpaceStorageRoot),
|
||||
publicBaseUrl: resolvePublicBaseUrl(),
|
||||
conversationPackageBackfill: ({ user, sessionId }) =>
|
||||
backfillConversationPackageArtifacts({
|
||||
pool,
|
||||
registry: mindSpaceConversationPackageRegistry,
|
||||
storageRoot: mindSpaceStorageRoot,
|
||||
h5Root: __dirname,
|
||||
user,
|
||||
sessionId,
|
||||
}),
|
||||
conversationPackagePublicHtmlHydrator: hydratePublicHtmlArtifactsForConversationPackage,
|
||||
});
|
||||
mindSpaceConversationPackageRegistry = createConversationPackageRegistry({
|
||||
store: createConversationPackageStore(pool),
|
||||
service: createMindSpaceServiceFacade({
|
||||
storageAdapter: createLocalMindSpaceStorageAdapter(mindSpaceStorageRoot),
|
||||
publicBaseUrl: resolvePublicBaseUrl(),
|
||||
}),
|
||||
service: mindSpaceServiceFacade,
|
||||
});
|
||||
mindSpaceAssets = createAssetService(pool, {
|
||||
h5Root: __dirname,
|
||||
@@ -2498,6 +2521,11 @@ api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationP
|
||||
ensureMindSpaceEnabled,
|
||||
sendData,
|
||||
mindSpaceError,
|
||||
beforeReadManifest: ({ req, sessionId }) =>
|
||||
mindSpaceServiceFacade?.prepareConversationPackageRead({
|
||||
user: req.currentUser,
|
||||
sessionId,
|
||||
}),
|
||||
}));
|
||||
|
||||
api.get(
|
||||
@@ -2507,6 +2535,11 @@ api.get(
|
||||
getUserAuth: () => userAuth,
|
||||
ensureMindSpaceEnabled,
|
||||
mindSpaceError,
|
||||
beforeReadManifest: ({ req, sessionId }) =>
|
||||
mindSpaceServiceFacade?.prepareConversationPackageRead({
|
||||
user: req.currentUser,
|
||||
sessionId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2564,6 +2597,19 @@ api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/mindspace/v1/conversation-packages/:sessionId/claim-uploads', async (req, res) => {
|
||||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
|
||||
try {
|
||||
const result = await mindSpaceAssets.claimUploadArtifactsForConversation(req.currentUser.id, {
|
||||
sessionId: req.params.sessionId,
|
||||
messageId: req.body?.message_id,
|
||||
});
|
||||
return sendData(res, req, result);
|
||||
} catch (error) {
|
||||
return mindSpaceError(res, req, error);
|
||||
}
|
||||
});
|
||||
|
||||
api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => {
|
||||
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||
try {
|
||||
@@ -2898,7 +2944,6 @@ async function resolveExistingSavedPage(userId, { sessionId, messageId, relative
|
||||
}
|
||||
|
||||
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
|
||||
const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
const pageSyncInFlight = new Map();
|
||||
|
||||
@@ -3018,58 +3063,174 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function registerChatDocxArtifactForConversation({
|
||||
user,
|
||||
bundle,
|
||||
requestBody = {},
|
||||
filename,
|
||||
buffer,
|
||||
async function registerPublishedLongImageArtifactForConversation({
|
||||
result,
|
||||
image,
|
||||
canonicalUrl,
|
||||
}) {
|
||||
if (!mindSpaceConversationPackageRegistry || !user?.id || !filename || !Buffer.isBuffer(buffer)) return;
|
||||
const sessionId = String(requestBody.session_id ?? requestBody.sessionId ?? '').trim();
|
||||
const messageId = String(requestBody.message_id ?? requestBody.messageId ?? '').trim();
|
||||
if (!sessionId || !messageId) return;
|
||||
const ownerId = String(result?.ownerId ?? '').trim();
|
||||
const pageSource = result?.pageSource ?? {};
|
||||
const sessionId = String(pageSource.sourceSessionId ?? '').trim();
|
||||
const messageId = String(pageSource.sourceMessageId ?? '').trim();
|
||||
const publicationId = String(result?.publication?.id ?? '').trim();
|
||||
if (
|
||||
!mindSpaceConversationPackageRegistry ||
|
||||
!ownerId ||
|
||||
!sessionId ||
|
||||
!publicationId ||
|
||||
!Buffer.isBuffer(image)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const selectedLinkIndex = Number(requestBody.selected_link_index ?? requestBody.selectedLinkIndex ?? 0);
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${user.id}:${sessionId}:${messageId}:${selectedLinkIndex}:${filename}`)
|
||||
.update(`${ownerId}:${sessionId}:${publicationId}:long-image`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
const artifactId = `ca_docx_${hash}`;
|
||||
const artifactId = `ca_long_image_${hash}`;
|
||||
const filename = `${publicationId || 'published-page'}.long.png`;
|
||||
const relativePath = `artifacts/${artifactId}/${filename}`;
|
||||
const now = Date.now();
|
||||
const { packageRecord, writeResult } =
|
||||
await mindSpaceConversationPackageRegistry.putObjectForSession({
|
||||
userId: user.id,
|
||||
userId: ownerId,
|
||||
sessionId,
|
||||
title: bundle?.source?.session?.name ?? null,
|
||||
title: pageSource.title ?? null,
|
||||
relativePath,
|
||||
body: buffer,
|
||||
body: image,
|
||||
});
|
||||
const canonicalUrl =
|
||||
`/api/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}` +
|
||||
`/artifacts/${encodeURIComponent(artifactId)}/download`;
|
||||
await mindSpaceConversationPackageRegistry.recordArtifact({
|
||||
id: artifactId,
|
||||
packageId: packageRecord.id,
|
||||
artifactKind: 'docx',
|
||||
messageId,
|
||||
artifactKind: 'long_image',
|
||||
role: 'assistant',
|
||||
pageId: pageSource.pageId ?? result?.publication?.pageId ?? null,
|
||||
publicationId,
|
||||
messageId: messageId || null,
|
||||
displayName: filename,
|
||||
mimeType: DOCX_MIME_TYPE,
|
||||
sizeBytes: buffer.length,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: image.length,
|
||||
storageKey: writeResult.key,
|
||||
canonicalUrl,
|
||||
sortOrder: Date.now(),
|
||||
sortOrder: now,
|
||||
now,
|
||||
});
|
||||
await mindSpaceConversationPackageRegistry.writeManifestForSession({
|
||||
userId: user.id,
|
||||
userId: ownerId,
|
||||
sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error);
|
||||
console.warn('[MindSpace] failed to record published long image artifact:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerPublicHtmlArtifactsForConversation({
|
||||
user,
|
||||
publishDir,
|
||||
sessionId,
|
||||
title = null,
|
||||
relativePaths = [],
|
||||
artifactRefs = [],
|
||||
}) {
|
||||
if (
|
||||
!mindSpaceConversationPackageRegistry ||
|
||||
!user?.id ||
|
||||
!publishDir ||
|
||||
!sessionId ||
|
||||
((!Array.isArray(relativePaths) || relativePaths.length === 0) &&
|
||||
(!Array.isArray(artifactRefs) || artifactRefs.length === 0))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const publishRoot = path.resolve(publishDir);
|
||||
const packageTitle = String(title ?? '').trim() || null;
|
||||
const existingManifest = await mindSpaceConversationPackageRegistry
|
||||
.readManifestForSession({ userId: user.id, sessionId })
|
||||
.catch(() => null);
|
||||
const packageRecord = existingManifest?.packageId && existingManifest.title
|
||||
? { id: existingManifest.packageId }
|
||||
: await mindSpaceConversationPackageRegistry.ensurePackage({
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
title: packageTitle ?? existingManifest?.title ?? null,
|
||||
});
|
||||
const recorded = [];
|
||||
const now = Date.now();
|
||||
const refs =
|
||||
Array.isArray(artifactRefs) && artifactRefs.length > 0
|
||||
? artifactRefs
|
||||
: relativePaths.map((relativePath) => ({ relativePath }));
|
||||
for (const ref of refs) {
|
||||
const relativePath = typeof ref === 'string' ? ref : ref?.relativePath;
|
||||
const normalized = normalizePublicHtmlRelativePath(relativePath);
|
||||
if (!normalized || !normalized.toLowerCase().endsWith('.html')) continue;
|
||||
const absolutePath = path.resolve(publishRoot, normalized);
|
||||
if (absolutePath !== publishRoot && !absolutePath.startsWith(`${publishRoot}${path.sep}`)) continue;
|
||||
let stat = null;
|
||||
try {
|
||||
stat = await fsPromises.stat(absolutePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${user.id}:${sessionId}:${normalized}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
const artifactId = `ca_public_html_${hash}`;
|
||||
await mindSpaceConversationPackageRegistry.recordArtifact({
|
||||
id: artifactId,
|
||||
packageId: packageRecord.id,
|
||||
artifactKind: 'public_html',
|
||||
role: 'assistant',
|
||||
messageId: typeof ref?.messageId === 'string' && ref.messageId.trim() ? ref.messageId.trim() : null,
|
||||
displayName: path.posix.basename(normalized),
|
||||
mimeType: 'text/html',
|
||||
sizeBytes: stat.size,
|
||||
canonicalUrl: buildPublicUrl(resolvePublicBaseUrl(), user.id, normalized),
|
||||
sortOrder: Number.isFinite(stat.mtimeMs) ? Math.round(stat.mtimeMs) : now,
|
||||
now,
|
||||
});
|
||||
recorded.push({ artifactId, relativePath: normalized });
|
||||
}
|
||||
if (recorded.length > 0) {
|
||||
await mindSpaceConversationPackageRegistry.writeManifestForSession({
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
return recorded;
|
||||
} catch (error) {
|
||||
console.warn('[MindSpace] failed to record public html artifacts:', error?.message ?? error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydratePublicHtmlArtifactsForConversationPackage({
|
||||
user,
|
||||
sessionId,
|
||||
}) {
|
||||
if (!sessionSnapshotService?.isEnabled?.() || !user?.id || !sessionId) return [];
|
||||
const publishDir = resolvePublishDir(__dirname, { id: user.id });
|
||||
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
|
||||
const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
|
||||
const artifactRefs = collectOwnPublicHtmlArtifactRefs({
|
||||
messages,
|
||||
currentUser: user,
|
||||
publishDir,
|
||||
});
|
||||
return registerPublicHtmlArtifactsForConversation({
|
||||
user,
|
||||
publishDir,
|
||||
sessionId,
|
||||
title: snapshot?.session?.name,
|
||||
artifactRefs,
|
||||
});
|
||||
}
|
||||
|
||||
api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => {
|
||||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||
try {
|
||||
@@ -3255,11 +3416,14 @@ async function handleChatSaveDocx(req, res) {
|
||||
.slice(0, 60) || 'mindspace-document';
|
||||
const filename = `${filenameBase}.docx`;
|
||||
await registerChatDocxArtifactForConversation({
|
||||
registry: mindSpaceConversationPackageRegistry,
|
||||
user: req.currentUser,
|
||||
bundle,
|
||||
requestBody: req.body,
|
||||
filename,
|
||||
buffer,
|
||||
}).catch((error) => {
|
||||
console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error);
|
||||
});
|
||||
res.set('Content-Type', DOCX_MIME_TYPE);
|
||||
res.set(
|
||||
@@ -4524,6 +4688,14 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
|
||||
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
|
||||
: null,
|
||||
registerPublicHtmlArtifacts: (_userId, options) =>
|
||||
registerPublicHtmlArtifactsForConversation({
|
||||
user: req.currentUser,
|
||||
publishDir,
|
||||
sessionId: options?.sessionId,
|
||||
relativePaths: options?.relativePaths,
|
||||
artifactRefs: options?.artifactRefs,
|
||||
}),
|
||||
});
|
||||
if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) {
|
||||
console.warn(
|
||||
@@ -4604,6 +4776,12 @@ api.use(
|
||||
);
|
||||
|
||||
app.use('/api', api);
|
||||
// Express routing is case-insensitive by default, so the lowercase /mindspace API
|
||||
// mount would otherwise capture public /MindSpace/... page URLs.
|
||||
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
|
||||
await userAuthReady;
|
||||
return serveUserPublishFile(req, res, next);
|
||||
});
|
||||
app.use('/mindspace', api);
|
||||
|
||||
function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
|
||||
@@ -5061,6 +5239,15 @@ async function sendPublishedPage(req, res, result, { embed = false, raw = false
|
||||
try {
|
||||
const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost');
|
||||
const image = await renderLongImageBuffer({ url: rawUrl.toString() });
|
||||
const longImageUrl = new URL(
|
||||
appendQueryParam(sharePath || originalPath, 'download', 'long-image'),
|
||||
origin || 'http://localhost',
|
||||
).toString();
|
||||
await registerPublishedLongImageArtifactForConversation({
|
||||
result,
|
||||
image,
|
||||
canonicalUrl: longImageUrl,
|
||||
});
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
@@ -5588,11 +5775,6 @@ async function serveUserPublishFile(req, res, next) {
|
||||
await sendPublishFile(req, res, resolvedPath);
|
||||
}
|
||||
|
||||
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
|
||||
await userAuthReady;
|
||||
return serveUserPublishFile(req, res, next);
|
||||
});
|
||||
|
||||
app.use('/temp', (req, res) => {
|
||||
res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`);
|
||||
});
|
||||
|
||||
@@ -749,6 +749,20 @@ export async function uploadMindSpaceAsset(
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimMindSpaceConversationUploads(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
): Promise<{ claimedCount: number }> {
|
||||
const result = await apiFetch<{ data: { claimedCount: number } }>(
|
||||
`/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}/claim-uploads`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message_id: messageId }),
|
||||
},
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function uploadFileContent(
|
||||
url: string,
|
||||
file: File,
|
||||
|
||||
+180
-58
@@ -73,6 +73,7 @@ type PendingChatImage = {
|
||||
previewUrl: string;
|
||||
sizeBytes: number;
|
||||
uploadedUrl: string | null;
|
||||
sourceMessageId: string | null;
|
||||
uploadProgress: number | null;
|
||||
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
|
||||
};
|
||||
@@ -195,8 +196,17 @@ export function ChatPanel({
|
||||
session: Session | null;
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise<void>;
|
||||
onUploadImage?: (file: File, onProgress?: (progress: number) => void) => Promise<string>;
|
||||
onSubmit: (
|
||||
text: string,
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
options?: { messageId?: string },
|
||||
) => void | Promise<void>;
|
||||
onUploadImage?: (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
options?: { messageId?: string },
|
||||
) => Promise<string>;
|
||||
onLoadOlderMessages?: () => void | Promise<void>;
|
||||
onStop: () => void | Promise<void>;
|
||||
onApproveTool: (allow: boolean) => void | Promise<void>;
|
||||
@@ -221,6 +231,8 @@ export function ChatPanel({
|
||||
const [packageLoading, setPackageLoading] = useState(false);
|
||||
const [conversationPackage, setConversationPackage] = useState<MindSpaceConversationPackage | null>(null);
|
||||
const [packageError, setPackageError] = useState<string | null>(null);
|
||||
const [packageFilter, setPackageFilter] = useState<MindSpaceConversationArtifact['kind'] | 'all'>('all');
|
||||
const [packageMessageFilter, setPackageMessageFilter] = useState<string | 'all'>('all');
|
||||
const [randomPrompt] = useState(
|
||||
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
|
||||
);
|
||||
@@ -291,6 +303,8 @@ export function ChatPanel({
|
||||
setPackageLoading(false);
|
||||
setConversationPackage(null);
|
||||
setPackageError(null);
|
||||
setPackageFilter('all');
|
||||
setPackageMessageFilter('all');
|
||||
}, [session?.id]);
|
||||
|
||||
const loadConversationPackage = useCallback(async () => {
|
||||
@@ -300,6 +314,12 @@ export function ChatPanel({
|
||||
try {
|
||||
const data = await getMindSpaceConversationPackage(session.id);
|
||||
setConversationPackage(data);
|
||||
setPackageFilter((current) =>
|
||||
current === 'all' || data.artifacts.some((artifact) => artifact.kind === current) ? current : 'all',
|
||||
);
|
||||
setPackageMessageFilter((current) =>
|
||||
current === 'all' || (data.messages ?? []).some((message) => message.messageId === current) ? current : 'all',
|
||||
);
|
||||
} catch (err) {
|
||||
setConversationPackage(null);
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
@@ -437,6 +457,32 @@ export function ChatPanel({
|
||||
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
|
||||
const canSubmit = input.trim() || pendingImages.length > 0;
|
||||
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
|
||||
const packageArtifacts = conversationPackage?.artifacts ?? [];
|
||||
const packageFilterOptions = Array.from(
|
||||
packageArtifacts.reduce((counts, artifact) => {
|
||||
counts.set(artifact.kind, (counts.get(artifact.kind) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map<MindSpaceConversationArtifact['kind'], number>()),
|
||||
).sort(([left], [right]) => packageArtifactLabel(left).localeCompare(packageArtifactLabel(right), 'zh-CN'));
|
||||
const packageMessageOptions = (conversationPackage?.messages ?? [])
|
||||
.filter((message) => message.artifactIds.length > 0)
|
||||
.map((message, index) => ({
|
||||
id: message.messageId,
|
||||
label: `消息 ${index + 1}`,
|
||||
count: message.artifactIds.length,
|
||||
artifactIds: new Set(message.artifactIds),
|
||||
}));
|
||||
const selectedMessageArtifacts =
|
||||
packageMessageFilter === 'all'
|
||||
? null
|
||||
: packageMessageOptions.find((message) => message.id === packageMessageFilter)?.artifactIds ?? null;
|
||||
const filteredPackageArtifacts =
|
||||
packageArtifacts.filter((artifact) => {
|
||||
const kindMatched = packageFilter === 'all' || artifact.kind === packageFilter;
|
||||
const messageMatched = !selectedMessageArtifacts || selectedMessageArtifacts.has(artifact.artifactId);
|
||||
return kindMatched && messageMatched;
|
||||
});
|
||||
const packageGroups = groupPackageArtifacts(filteredPackageArtifacts);
|
||||
|
||||
const revokePendingImage = (item: PendingChatImage) => {
|
||||
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
|
||||
@@ -505,7 +551,12 @@ export function ChatPanel({
|
||||
setImageError('当前会话暂不支持图片发送');
|
||||
return;
|
||||
}
|
||||
const sentImages = [...pendingImages];
|
||||
const existingSourceMessageId = pendingImages.find((item) => item.sourceMessageId)?.sourceMessageId;
|
||||
const outgoingMessageId = existingSourceMessageId ?? crypto.randomUUID();
|
||||
const sentImages = pendingImages.map((item) => ({
|
||||
...item,
|
||||
sourceMessageId: item.sourceMessageId ?? outgoingMessageId,
|
||||
}));
|
||||
let uploadedImages = sentImages;
|
||||
|
||||
suppressVoiceUpdateRef.current = true;
|
||||
@@ -524,28 +575,39 @@ export function ChatPanel({
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? { ...candidate, uploadStatus: 'uploading', uploadProgress: 0 }
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: 0,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
const uploadedUrl = await onUploadImage!(item.file, (progress) => {
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: Math.round(progress * 100),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
});
|
||||
const uploadedUrl = await onUploadImage!(
|
||||
item.file,
|
||||
(progress) => {
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: Math.round(progress * 100),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
},
|
||||
{ messageId: item.sourceMessageId ?? outgoingMessageId },
|
||||
);
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadedUrl,
|
||||
uploadStatus: 'uploaded',
|
||||
uploadProgress: 100,
|
||||
@@ -564,7 +626,7 @@ export function ChatPanel({
|
||||
}));
|
||||
const imagesToSend = uploadedUrls.filter(Boolean);
|
||||
const previewImagesToSend = imagesToSend;
|
||||
await onSubmit(trimmed, imagesToSend, previewImagesToSend);
|
||||
await onSubmit(trimmed, imagesToSend, previewImagesToSend, { messageId: outgoingMessageId });
|
||||
uploadedImages.forEach(revokePendingImage);
|
||||
setPendingImages([]);
|
||||
} catch (err) {
|
||||
@@ -607,6 +669,7 @@ export function ChatPanel({
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
sizeBytes: file.size,
|
||||
uploadedUrl: null,
|
||||
sourceMessageId: null,
|
||||
uploadProgress: null,
|
||||
uploadStatus: 'queued' as const,
|
||||
}));
|
||||
@@ -639,6 +702,9 @@ export function ChatPanel({
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
sizeBytes: file.size,
|
||||
uploadedUrl: null,
|
||||
sourceMessageId: null,
|
||||
uploadProgress: null,
|
||||
uploadStatus: 'queued' as const,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
};
|
||||
@@ -774,55 +840,111 @@ export function ChatPanel({
|
||||
</button>
|
||||
</div>
|
||||
<div className="conversation-package-meta">
|
||||
<span>{conversationPackage?.artifacts.length ?? 0} 个项目</span>
|
||||
<span>{packageArtifacts.length} 个项目</span>
|
||||
{conversationPackage?.uri && <code>{conversationPackage.uri}</code>}
|
||||
</div>
|
||||
{packageLoading && <div className="conversation-package-state">正在整理这个对话里的文件…</div>}
|
||||
{!packageLoading && packageError && (
|
||||
<div className="conversation-package-state conversation-package-state-muted">{packageError}</div>
|
||||
)}
|
||||
{!packageLoading && !packageError && conversationPackage && conversationPackage.artifacts.length === 0 && (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
这个对话暂时没有图片、文件或页面。
|
||||
<strong>对话包暂时打不开</strong>
|
||||
<span>{packageError}</span>
|
||||
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!packageLoading && !packageError && conversationPackage && conversationPackage.artifacts.length > 0 && (
|
||||
<div className="conversation-package-groups">
|
||||
{groupPackageArtifacts(conversationPackage.artifacts).map(([label, artifacts]) => (
|
||||
<div className="conversation-package-group" key={label}>
|
||||
<h3>{label}</h3>
|
||||
<div className="conversation-package-items">
|
||||
{artifacts.map((artifact) => {
|
||||
const sizeLabel = formatPackageArtifactSize(artifact.sizeBytes);
|
||||
return (
|
||||
<a
|
||||
key={artifact.artifactId}
|
||||
className="conversation-package-item"
|
||||
href={artifact.canonicalUrl || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-disabled={!artifact.canonicalUrl}
|
||||
onClick={(event) => {
|
||||
if (!artifact.canonicalUrl) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className="conversation-package-item-kind">{packageArtifactLabel(artifact.kind)}</span>
|
||||
<strong>{artifact.displayName || artifact.canonicalUrl || artifact.artifactId}</strong>
|
||||
<span>
|
||||
{[
|
||||
artifact.mimeType,
|
||||
sizeLabel,
|
||||
artifact.messageId ? `消息 ${artifact.messageId}` : null,
|
||||
].filter(Boolean).join(' · ') || '已记录到对话包'}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length === 0 && (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>这个对话还没有文件</strong>
|
||||
<span>发送图片、保存页面、生成公开页或下载文档后,这里会自动聚合成一个对话文件夹。</span>
|
||||
</div>
|
||||
)}
|
||||
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length > 0 && (
|
||||
<>
|
||||
<div className="conversation-package-filters" aria-label="筛选对话包文件">
|
||||
<button
|
||||
type="button"
|
||||
className={packageFilter === 'all' ? 'is-active' : ''}
|
||||
onClick={() => setPackageFilter('all')}
|
||||
>
|
||||
全部 <span>{packageArtifacts.length}</span>
|
||||
</button>
|
||||
{packageFilterOptions.map(([kind, count]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={kind}
|
||||
className={packageFilter === kind ? 'is-active' : ''}
|
||||
onClick={() => setPackageFilter(kind)}
|
||||
>
|
||||
{packageArtifactLabel(kind)} <span>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{packageMessageOptions.length > 0 && (
|
||||
<div className="conversation-package-message-filters" aria-label="按消息筛选对话包文件">
|
||||
<span>按消息</span>
|
||||
<button
|
||||
type="button"
|
||||
className={packageMessageFilter === 'all' ? 'is-active' : ''}
|
||||
onClick={() => setPackageMessageFilter('all')}
|
||||
>
|
||||
全部消息
|
||||
</button>
|
||||
{packageMessageOptions.map((message) => (
|
||||
<button
|
||||
type="button"
|
||||
key={message.id}
|
||||
className={packageMessageFilter === message.id ? 'is-active' : ''}
|
||||
onClick={() => setPackageMessageFilter(message.id)}
|
||||
>
|
||||
{message.label} <span>{message.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filteredPackageArtifacts.length === 0 ? (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>当前筛选没有项目</strong>
|
||||
<span>切换到“全部”或“全部消息”可以查看当前对话包里的其它产物。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="conversation-package-groups">
|
||||
{packageGroups.map(([label, artifacts]) => (
|
||||
<div className="conversation-package-group" key={label}>
|
||||
<h3>{label}</h3>
|
||||
<div className="conversation-package-items">
|
||||
{artifacts.map((artifact) => {
|
||||
const sizeLabel = formatPackageArtifactSize(artifact.sizeBytes);
|
||||
return (
|
||||
<a
|
||||
key={artifact.artifactId}
|
||||
className="conversation-package-item"
|
||||
href={artifact.canonicalUrl || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-disabled={!artifact.canonicalUrl}
|
||||
onClick={(event) => {
|
||||
if (!artifact.canonicalUrl) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className="conversation-package-item-kind">{packageArtifactLabel(artifact.kind)}</span>
|
||||
<strong>{artifact.displayName || artifact.canonicalUrl || artifact.artifactId}</strong>
|
||||
<span>
|
||||
{[
|
||||
artifact.mimeType,
|
||||
sizeLabel,
|
||||
].filter(Boolean).join(' · ') || '已记录到对话包'}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="conversation-package-actions">
|
||||
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
|
||||
刷新
|
||||
|
||||
@@ -294,10 +294,12 @@ export function ChatView({
|
||||
session={session}
|
||||
capabilities={capabilities}
|
||||
grantedSkills={grantedSkills}
|
||||
onSubmit={(text, imageUrls, previewImageUrls) =>
|
||||
void submit(text, undefined, imageUrls, previewImageUrls)
|
||||
onSubmit={(text, imageUrls, previewImageUrls, options) =>
|
||||
void submit(text, { messageId: options?.messageId }, imageUrls, previewImageUrls)
|
||||
}
|
||||
onUploadImage={(file, onProgress, options) =>
|
||||
uploadChatImage(file, onProgress, { messageId: options?.messageId })
|
||||
}
|
||||
onUploadImage={uploadChatImage}
|
||||
onLoadOlderMessages={loadOlderMessages}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
|
||||
@@ -138,12 +138,14 @@ export function SpaceChatPanel({
|
||||
session={session}
|
||||
capabilities={capabilities ?? undefined}
|
||||
grantedSkills={grantedSkills}
|
||||
onSubmit={(text, imageUrls, previewImageUrls) =>
|
||||
onSubmit={(text, imageUrls, previewImageUrls, options) =>
|
||||
chatBridge
|
||||
? void submit(text, context, imageUrls, previewImageUrls)
|
||||
: void submit(text, { mindspaceContext: context }, imageUrls, previewImageUrls)
|
||||
? void submit(text, { ...context, messageId: options?.messageId }, imageUrls, previewImageUrls)
|
||||
: void submit(text, { mindspaceContext: context, messageId: options?.messageId }, imageUrls, previewImageUrls)
|
||||
}
|
||||
onUploadImage={(file, onProgress, options) =>
|
||||
uploadChatImage(file, onProgress, { messageId: options?.messageId })
|
||||
}
|
||||
onUploadImage={uploadChatImage}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
onPageSaved={onPageSaved}
|
||||
|
||||
@@ -130,6 +130,7 @@ export function usePageEditSubChat({
|
||||
const uploadChatImage = useCallback(async (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
options: { messageId?: string | null } = {},
|
||||
): Promise<string> => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('只支持图片文件');
|
||||
@@ -138,6 +139,8 @@ export function usePageEditSubChat({
|
||||
const asset = await uploadMindSpaceAsset(categoryId, file, {
|
||||
maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
onProgress,
|
||||
sessionId: sessionRef.current?.id ?? null,
|
||||
messageId: options.messageId ?? null,
|
||||
});
|
||||
return buildAbsoluteAssetImageUrl({
|
||||
id: asset.id,
|
||||
@@ -305,7 +308,7 @@ export function usePageEditSubChat({
|
||||
const submit = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
context: MindSpaceChatContext,
|
||||
context: MindSpaceChatContext & { messageId?: string },
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
) => {
|
||||
@@ -327,6 +330,7 @@ export function usePageEditSubChat({
|
||||
const userPrefix = buildUserAddressPrefix(user);
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}`;
|
||||
const userMessage = buildUserMessage(trimmed, {
|
||||
id: context.messageId,
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
displayText: trimmed,
|
||||
imageUrls: normalizedImageUrls,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
applyLocalLlmFallback,
|
||||
bootstrapProjectMemory,
|
||||
cancelRequest,
|
||||
claimMindSpaceConversationUploads,
|
||||
confirmTool,
|
||||
createAgentRun,
|
||||
deleteChatSession,
|
||||
@@ -572,6 +573,7 @@ export function useTKMindChat(
|
||||
const uploadChatImage = useCallback(async (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
options: { messageId?: string | null } = {},
|
||||
): Promise<string> => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('只支持图片文件');
|
||||
@@ -581,6 +583,7 @@ export function useTKMindChat(
|
||||
maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
onProgress,
|
||||
sessionId: sessionRef.current?.id ?? null,
|
||||
messageId: options.messageId ?? null,
|
||||
});
|
||||
return buildAbsoluteAssetImageUrl({
|
||||
id: asset.id,
|
||||
@@ -1122,7 +1125,7 @@ export function useTKMindChat(
|
||||
const submit = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
options?: { mindspaceContext?: MindSpaceChatContext },
|
||||
options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string },
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
) => {
|
||||
@@ -1146,6 +1149,7 @@ export function useTKMindChat(
|
||||
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
|
||||
const userMessage = buildUserMessage(trimmed, {
|
||||
id: options?.messageId,
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
displayText: trimmed,
|
||||
imageUrls: normalizedImageUrls,
|
||||
@@ -1184,6 +1188,9 @@ export function useTKMindChat(
|
||||
if (!activeSessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
}
|
||||
if (normalizedImageUrls.length > 0) {
|
||||
void claimMindSpaceConversationUploads(activeSessionId, userMessage.id).catch(() => {});
|
||||
}
|
||||
if (!session?.id || session.id !== activeSessionId) {
|
||||
const nextSession: Session = {
|
||||
id: activeSessionId,
|
||||
|
||||
@@ -2141,6 +2141,9 @@ body,
|
||||
}
|
||||
|
||||
.conversation-package-state {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
padding: 28px 12px;
|
||||
border: 1px dashed rgba(158, 178, 202, 0.25);
|
||||
border-radius: 16px;
|
||||
@@ -2148,10 +2151,75 @@ body,
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conversation-package-state strong {
|
||||
color: #edf6ff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.conversation-package-state span {
|
||||
max-width: 460px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.conversation-package-state-muted {
|
||||
color: #95a9be;
|
||||
}
|
||||
|
||||
.conversation-package-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 2px 4px;
|
||||
}
|
||||
|
||||
.conversation-package-message-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
margin-top: -8px;
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
|
||||
.conversation-package-message-filters > span:first-child {
|
||||
flex: 0 0 auto;
|
||||
color: #7f94ad;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.conversation-package-filters button,
|
||||
.conversation-package-message-filters button {
|
||||
border: 1px solid rgba(151, 176, 204, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
color: #afc1d5;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-package-filters button:hover,
|
||||
.conversation-package-filters button.is-active,
|
||||
.conversation-package-message-filters button:hover,
|
||||
.conversation-package-message-filters button.is-active {
|
||||
border-color: rgba(255, 213, 157, 0.44);
|
||||
color: #ffe3bd;
|
||||
background: rgba(255, 189, 113, 0.14);
|
||||
}
|
||||
|
||||
.conversation-package-filters button span,
|
||||
.conversation-package-message-filters button span {
|
||||
margin-left: 4px;
|
||||
color: #7f94ad;
|
||||
}
|
||||
|
||||
.conversation-package-filters button.is-active span,
|
||||
.conversation-package-message-filters button.is-active span {
|
||||
color: #ffd59d;
|
||||
}
|
||||
|
||||
.conversation-package-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -496,6 +496,20 @@ export type MindSpaceConversationPackage = {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
messages?: Array<{
|
||||
messageId: string;
|
||||
artifactIds: string[];
|
||||
kinds: MindSpaceConversationArtifactKind[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
agentRuns?: Array<{
|
||||
agentRunId: string;
|
||||
artifactIds: string[];
|
||||
kinds: MindSpaceConversationArtifactKind[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type MindSpacePageVersion = {
|
||||
|
||||
@@ -119,7 +119,13 @@ export function normalizeUserMessageForApi(message: Message): Message {
|
||||
|
||||
export function buildUserMessage(
|
||||
text: string,
|
||||
options?: { agentText?: string; displayText?: string; imageUrls?: string[]; previewImageUrls?: string[] },
|
||||
options?: {
|
||||
id?: string;
|
||||
agentText?: string;
|
||||
displayText?: string;
|
||||
imageUrls?: string[];
|
||||
previewImageUrls?: string[];
|
||||
},
|
||||
): Message {
|
||||
const displayText = stripImageUrlLines(options?.displayText ?? text);
|
||||
const imageUrls = (options?.imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
||||
@@ -131,7 +137,7 @@ export function buildUserMessage(
|
||||
const agentText = [baseAgentText.trim(), imageText].filter(Boolean).join('\n\n');
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
id: options?.id ?? crypto.randomUUID(),
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: agentText ? [{ type: 'text', text: agentText }] : [],
|
||||
|
||||
Reference in New Issue
Block a user