feat: complete mindspace conversation packages

This commit is contained in:
john
2026-07-02 23:46:05 +08:00
parent b084f6d23c
commit b258004dad
31 changed files with 1990 additions and 118 deletions
@@ -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` artifactPNG 同步写入 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 页面能看到所有产物。
+98
View File
@@ -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
View File
@@ -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-'));
+54
View File
@@ -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;
}
+83
View File
@@ -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,
);
});
+157
View File
@@ -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,
+14 -1
View File
@@ -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();
+83
View File
@@ -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);
});
+41 -1
View File
@@ -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,
};
+49
View File
@@ -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,
},
]);
});
+114 -3
View File
@@ -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 };
}
+113 -1
View File
@@ -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 });
}
+9 -2
View File
@@ -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
+165 -1
View File
@@ -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
View File
@@ -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 [];
},
};
}
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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}`);
});
+14
View File
@@ -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
View File
@@ -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()}>
+5 -3
View File
@@ -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}
+6 -4
View File
@@ -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}
+5 -1
View File
@@ -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,
+8 -1
View File
@@ -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,
+68
View File
@@ -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;
+14
View File
@@ -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 = {
+8 -2
View File
@@ -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 }] : [],