Files
memind/user-space.mjs
T

228 lines
12 KiB
JavaScript
Raw Normal View History

import fs from 'node:fs';
import path from 'node:path';
import { SYSTEM_CATEGORIES } from './mindspace.mjs';
import { resolveMindSpaceStorageRoot as resolveRuntimeMindSpaceStorageRoot } from './mindspace-runtime-config.mjs';
import { USER_FACING_ARCHITECTURE_LANGUAGE_RULE } from './conversation-display.mjs';
import { PUBLISH_ROOT_DIR, resolvePublishDir, resolveUserAddressName } from './user-publish.mjs';
/** 用户 MindSpace 下的分区子目录(与上传分类一致) */
2026-06-29 22:20:04 +08:00
export const UPLOAD_ZONE_CODES = ['oa', 'public'];
export function resolveMindspaceStorageRoot(h5Root, env = process.env) {
return resolveRuntimeMindSpaceStorageRoot(h5Root, env);
}
/** Agent 工作区 = MindSpace/<userId>/ */
export function resolveUserWorkspaceRoot(h5Root, user) {
return resolvePublishDir(h5Root, user);
}
export const WORKSPACE_HINTS_FILENAME = '.tkmindhints';
export const LEGACY_WORKSPACE_HINTS_FILENAME = '.goosehints';
function renderUserSpaceBrandingBlock(userAddressName) {
const name = userAddressName || '用户';
return `## 品牌与称呼(硬性)
- 你是 **TKMind** 助手;介绍产品时用 TKMind,不要向用户暴露内部运行时、搜索引擎品牌、中间件或编排组件名称
- 与用户对话时,用 **${name}** 称呼用户(可辅以「你/您」),**禁止**把用户叫作 TKMind
- 仅在开场或用户打招呼时使用时段问候,且须与「TKMind 当前时间基准」一致(如「${name},早上好」);普通回复直接作答,不要每条都加问候;禁止把用户叫作 TKMind
- 不要描述本工作区为底层 AI 框架或 Rust 运行时项目
2026-06-29 22:20:04 +08:00
- 本工作区是 TKMind **MindSpace 用户空间**,用于 OA/公开文件管理与静态页面生成
- ${USER_FACING_ARCHITECTURE_LANGUAGE_RULE}
`;
}
export function resolveZoneDir(workspaceRoot, categoryCode) {
return path.join(workspaceRoot, categoryCode);
}
export function resolveZoneFilePath(workspaceRoot, categoryCode, filename) {
return path.join(resolveZoneDir(workspaceRoot, categoryCode), filename);
}
export function zoneLabel(categoryCode) {
return SYSTEM_CATEGORIES.find((item) => item.code === categoryCode)?.name ?? categoryCode;
}
export function ensureUserZoneDirs(workspaceRoot) {
for (const code of UPLOAD_ZONE_CODES) {
fs.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true });
}
}
/** 上传完成后镜像到 MindSpace/<user>/<category>/文件名 */
export function mirrorAssetToZone({
workspaceRoot,
categoryCode,
filename,
sourcePath,
overwrite = true,
}) {
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
if (!sourcePath || !fs.existsSync(sourcePath)) return null;
ensureUserZoneDirs(workspaceRoot);
const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
fs.mkdirSync(path.dirname(dest), { recursive: true });
// Layout bootstrap is a recovery/backfill path. Re-copying every canonical
// asset on every chat request changes workspace mtimes, can overwrite a
// newer Agent edit, and makes historical HTML look newly generated.
if (!overwrite && fs.existsSync(dest)) return dest;
fs.copyFileSync(sourcePath, dest);
return dest;
}
export function removeZoneMirror({ workspaceRoot, categoryCode, filename }) {
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return;
const target = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
if (fs.existsSync(target)) fs.unlinkSync(target);
}
export function isPathInsideUserWorkspace(workspaceRoot, requestedPath) {
const base = path.resolve(workspaceRoot);
const resolved = path.resolve(requestedPath);
return resolved === base || resolved.startsWith(`${base}${path.sep}`);
}
export function renderUserSpaceHints({ username, workspaceRoot, displayName, slug }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
const zoneLines = UPLOAD_ZONE_CODES.map(
(code) => `- \`${code}/\` — ${zoneLabel(code)}(用户上传落在此分区)`,
);
return `# TKMind 用户空间分区
你是用户 **${addressName}** 的专属 TKMind 助手。当前会话工作区(唯一文件根目录):
\`${workspaceRoot}\`
${renderUserSpaceBrandingBlock(addressName)}
## 分区规则(硬性)
用户上传的文件**只会**出现在下列子目录之一:
${zoneLines.join('\n')}
## 查找 / 读取文件
1. 在工作区内搜索,例如 \`tree oa/\`、\`find oa -name '*.csv'\`、\`cat oa/文件名.csv\`
2. 用户说「OA 区」→ \`oa/\`;「私人区」→ \`private/\`;「公开区」→ \`public/\`
3. **禁止**访问其它用户目录、\`${PUBLISH_ROOT_DIR}/\` 根目录(非本用户)、\`data/mindspace/\`、主机绝对路径
4. **禁止**用公网 URL 列目录或读 CSV;公网链接仅用于分享已发布 HTML
5. 找不到时说明该分区内没有该文件,请用户确认上传到了哪个分区
## 生成页面
- 用户要网页 / HTML / 报告 / 分享链接时:**你必须亲自完成**,不要推给用户手动操作
- 先 \`load_skill\` → \`static-page-publish\`,再使用 \`write_file\` 创建 \`public/页面名.html\`(需要调整已有页面时用 \`edit_file\`)
2026-07-02 19:18:56 +08:00
- 默认只生成 HTML;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件
- 只有用户明确要求 Word / docx 下载时:必须先 \`load_skill\` → \`docx-generate\`,生成 \`public/文件名.docx\` 并确认文件存在,再写 HTML 用相对路径链接该文档
- 用户明确要求 Word / docx 下载时,必须用 sandbox-fs 的 \`generate_docx\` 写入 \`public/*.docx\`,不要用 shell/computercontroller 生成生产下载文件
- 若用 \`apps__create_app\` 设计/预览页面:这一步只是在 Apps 窗口内创建交互式 App,**还没有公网链接**;必须紧接着按 \`static-page-publish\` skill 把最终内容 \`write_file\` 落到 \`public/*.html\`,才能给出真实可访问的链接
- 写入 \`<head>\` 的 **mindspace-cover** 元数据(详见 \`.agents/skills/static-page-publish/SKILL.md\`)
- 保存后立即给出 Markdown 可点击公网链接 \`[标题](URL)\`;写入 \`public/页面.html\` 时 URL 为 \`.../MindSpace/<用户ID>/public/页面.html\`
- 给链接时按 \`static-page-publish\` skill 里的公网前缀模板拼接真实地址,不确定域名时用相对路径 \`public/xxx.html\` 说明,不要猜一个域名充数
- **禁止**回复「请手动保存到 public 目录」「我无法生成页面」——除非 \`write_file\` / \`edit_file\` 调用已失败并说明具体错误
## 工作区文件与 OA 界面
- 写入 \`oa/\`、\`private/\`、\`public/\` 下任意层级的支持类型文件(docx、md、txt、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库(含子目录,如 \`oa/诗歌散文/夏日的诗篇.md\`)
- 打开对应分区或保存文件后会出现在界面中,可直接预览或下载
- 生成 docx/pdf 等 OA 资料时,可直接 \`write_file\` 到 \`oa/<子目录>/<文件名>\`,无需手动复制到根目录
- 用户上传的文件仍以界面入库为准,并镜像到上述分区
`;
}
export function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slug, displayName }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
const zoneList = UPLOAD_ZONE_CODES.map((code) => `\`${code}/\`(${zoneLabel(code)})`).join('、');
return [
'## TKMind 用户空间分区(硬性约束)',
'',
`- 你是 **TKMind** 助手;与用户对话时用 **${addressName}** 称呼用户,禁止把用户叫作 TKMind`,
'- 禁止向用户暴露内部运行时、搜索引擎品牌、中间件或编排组件名称',
`- ${USER_FACING_ARCHITECTURE_LANGUAGE_RULE}`,
`- 用户 **${addressName}** 的 Agent 工作区:\`${workspaceRoot}\``,
`- 用户上传落在分区子目录:${zoneList}(例如 OA 文件在 \`oa/\`)`,
'- **查找文件**:只在上述工作区内搜索(如 `oa/2025-12-06T13-34_export.csv`)',
'- **禁止**:访问其它用户目录、MindSpace 根目录、data/mindspace 内部路径、主机绝对路径',
'- **禁止**用公网 URL 列目录或读 CSV;生成 HTML 写入 `public/` 并给出公网链接',
'- **生成页面**:先 `load_skill` → `static-page-publish`,再用 `write_file` / `edit_file` 写入 `public/*.html`;**禁止**让用户手动保存文件',
2026-07-02 19:18:56 +08:00
'- **默认只生成 HTML**:不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件',
'- **Word 下载页**:若用户明确要求 Word / docx 下载,必须先 `load_skill` → `docx-generate` 生成 `public/*.docx` 并确认文件存在,再在 HTML 里用相对路径链接该文档',
'- 用 `apps__create_app` 设计页面时,最后一步仍要按 `static-page-publish` skill 把内容 `write_file` 落到 `public/*.html`,再给出下方前缀拼出的真实链接,不要停在 App 阶段就回复链接',
'- **需要查实时/真实世界信息(如机构名单、新闻、行情)时**:先 `load_skill` → `web`,同一轮同时调用 `tkmind_search`(专用联网搜索)和 `web_search`(内置联网搜索)并合并去重;向用户只称「联网搜索」,不要提具体搜索引擎或中间件名称;一侧失败时继续使用另一侧,必要时再走 Bing/360 等可达搜索源,避免对反爬站点反复硬抓',
2026-07-02 19:18:56 +08:00
'- **Word 下载页**:若用户明确要求 Word / docx 下载,必须先 `load_skill` → `docx-generate` 生成 `public/*.docx` 并确认文件存在,再在 HTML 里用相对路径链接该文档;不要用 shell/computercontroller 生成生产下载文件',
publicBaseUrl && slug
? `- 公网 HTML 前缀(公开区):\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`(写入 \`public/页面.html\` 时分享链接必须含 \`public/\`)`
: null,
]
.filter(Boolean)
.join('\n');
}
export function ensureUserSpaceHints(workspaceRoot, context) {
const hintsPath = path.join(workspaceRoot, WORKSPACE_HINTS_FILENAME);
const legacyPath = path.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME);
const content = renderUserSpaceHints({ ...context, workspaceRoot });
fs.writeFileSync(hintsPath, content, 'utf8');
if (fs.existsSync(legacyPath)) {
fs.unlinkSync(legacyPath);
}
return hintsPath;
}
export async function syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot) {
ensureUserZoneDirs(workspaceRoot);
const placeholders = UPLOAD_ZONE_CODES.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT a.original_filename, c.category_code, v.storage_key
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})
ORDER BY a.updated_at DESC`,
[userId, ...UPLOAD_ZONE_CODES],
);
for (const row of rows) {
const sourcePath = path.join(storageRoot, row.storage_key);
mirrorAssetToZone({
workspaceRoot,
categoryCode: row.category_code,
filename: row.original_filename,
sourcePath,
overwrite: false,
});
}
return { workspaceRoot, mirrored: rows.length };
}
export async function ensureUserSpaceLayout({
pool,
storageRoot,
userId,
username,
displayName,
publicBaseUrl,
slug,
workspaceRoot,
}) {
ensureUserZoneDirs(workspaceRoot);
if (pool && userId) {
await syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot);
}
const context = {
username: username ?? slug,
displayName,
workspaceRoot,
slug,
publicBaseUrl,
};
ensureUserSpaceHints(workspaceRoot, context);
return {
workspaceRoot,
zonesRoot: workspaceRoot,
zonesPublicDir: resolveZoneDir(workspaceRoot, 'public'),
constraints: buildUserSpaceConstraints(context),
...context,
};
}