import fs from 'node:fs'; import path from 'node:path'; import { SYSTEM_CATEGORIES } from './mindspace.mjs'; import { PUBLISH_ROOT_DIR, resolvePublishDir } from './user-publish.mjs'; /** 用户 MindSpace 下的分区子目录(与上传分类一致) */ export const UPLOAD_ZONE_CODES = ['oa', 'private', 'public']; export function resolveMindspaceStorageRoot(h5Root, env = process.env) { return path.resolve(env.MINDSPACE_STORAGE_ROOT ?? path.join(h5Root, 'data', 'mindspace')); } /** Agent 工作区 = MindSpace// */ export function resolveUserWorkspaceRoot(h5Root, user) { return resolvePublishDir(h5Root, user); } export const WORKSPACE_HINTS_FILENAME = '.tkmindhints'; export const LEGACY_WORKSPACE_HINTS_FILENAME = '.goosehints'; function renderBrandingBlock() { return `## 品牌与称呼(硬性) - 对用户统一称 **TKMind**,不要称 goose、Goose、goosed - 不要描述本工作区为「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///文件名 */ 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 }) { const zoneLines = UPLOAD_ZONE_CODES.map( (code) => `- \`${code}/\` — ${zoneLabel(code)}(用户上传落在此分区)`, ); return `# TKMind 用户空间分区 你是用户 \`${username}\` 的专属助手。当前会话工作区(唯一文件根目录): \`${workspaceRoot}\` ${renderBrandingBlock()} ## 分区规则(硬性) 用户上传的文件**只会**出现在下列子目录之一: ${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 可写入工作区根目录或 \`public/\`,完成后给出公网链接 ## 工作区文件与 OA 界面 - 写入 \`oa/\`、\`private/\`、\`public/\` 根目录的支持类型文件(docx、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库 - 打开对应分区或保存文件后会出现在界面中,可直接预览或下载 - 用户上传的文件仍以界面入库为准,并镜像到上述分区 `; } export function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slug }) { const zoneList = UPLOAD_ZONE_CODES.map((code) => `\`${code}/\`(${zoneLabel(code)})`).join('、'); return [ '## TKMind 用户空间分区(硬性约束)', '', '- 对用户统一称 **TKMind**;禁止称 goose / Goose / goosed 或「Rust goose 项目」', `- 用户 \`${username}\` 的 Agent 工作区:\`${workspaceRoot}\``, `- 用户上传落在分区子目录:${zoneList}(例如 OA 文件在 \`oa/\`)`, '- **查找文件**:只在上述工作区内搜索(如 `oa/2025-12-06T13-34_export.csv`)', '- **禁止**:访问其它用户目录、MindSpace 根目录、data/mindspace 内部路径、主机绝对路径', '- **禁止**用公网 URL 列目录或读 CSV;生成 HTML 写入工作区并给出公网链接', publicBaseUrl && slug ? `- 公网 HTML 前缀(仅分享用):\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/\`` : 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 }); const existing = fs.existsSync(hintsPath) ? fs.readFileSync(hintsPath, 'utf8') : ''; if (existing !== content) { 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, publicBaseUrl, slug, workspaceRoot, }) { ensureUserZoneDirs(workspaceRoot); if (pool && userId) { await syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot); } const context = { username: username ?? slug, workspaceRoot, slug, publicBaseUrl }; ensureUserSpaceHints(workspaceRoot, context); return { workspaceRoot, zonesRoot: workspaceRoot, zonesPublicDir: resolveZoneDir(workspaceRoot, 'public'), constraints: buildUserSpaceConstraints(context), ...context, }; }