Files
memind/user-space.mjs
T

213 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { PUBLISH_ROOT_DIR, resolvePublishDir, resolveUserAddressName } from './user-publish.mjs';
/** 用户 MindSpace 下的分区子目录(与上传分类一致) */
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,不要称 goose、Goose、goosed
- 与用户对话时,用 **${name}** 称呼用户(可辅以「你/您」),**禁止**把用户叫作 TKMind
- 仅在开场或用户打招呼时使用时段问候,且须与「TKMind 当前时间基准」一致(如「${name},早上好」);普通回复直接作答,不要每条都加问候;禁止把用户叫作 TKMind
- 不要描述本工作区为「Rust goose 项目」或「goose AI 框架」
- 本工作区是 TKMind **MindSpace 用户空间**,用于 OA/公开文件管理与静态页面生成
`;
}
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 }) {
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 });
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\`
- 默认只生成 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`,
'- 禁止称 goose / Goose / goosed 或「Rust goose 项目」',
`- 用户 **${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`**禁止**让用户手动保存文件',
'- **默认只生成 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`,优先用其 `web_search`/`fetch_url`;国内环境下 google.com 不可达,`web_search` 多次无果时改走 Bing/360 等可达搜索源,避免对反爬站点反复硬抓',
'- **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})`,
[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,
});
}
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,
};
}