import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const PUBLISH_SKILL_NAME = 'static-page-publish'; export const PUBLISH_ROOT_DIR = 'MindSpace'; export const PUBLISH_SKILL_DIR = path.join(__dirname, 'skills', PUBLISH_SKILL_NAME); 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 用户空间**,用于文件管理与静态页面生成 `; } export function resolvePublicBaseUrl(env = process.env) { return (env.H5_PUBLIC_BASE_URL ?? 'https://goo.tkmind.cn').replace(/\/$/, ''); } export function resolvePublishSlug(user) { if (!user?.username) throw new Error('缺少用户名'); return String(user.username).trim().toLowerCase(); } export function resolvePublishDir(h5Root, user) { const slug = resolvePublishSlug(user); return path.join(h5Root, PUBLISH_ROOT_DIR, slug); } export function buildPublicUrl(publicBaseUrl, slug, filename = '') { const base = `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(slug)}`; if (!filename) return `${base}/`; const clean = filename.replace(/^\/+/, ''); return `${base}/${clean.split('/').map(encodeURIComponent).join('/')}`; } export function renderPublishSkill({ slug, publicBaseUrl, publishDir }) { const exampleUrl = buildPublicUrl(publicBaseUrl, slug, 'report.html'); return `--- name: ${PUBLISH_SKILL_NAME} description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报告与页面(TKMind H5) --- # 静态页面 / 报告发布(TKMind) 当用户需要**网页、HTML 报告、可视化页面、可分享链接**时,**必须先加载本技能**并按以下规则执行。 ## 硬性约束(不可违反) 1. **唯一可写目录**:\`${publishDir}\` 2. **禁止**使用绝对路径(如 \`/Users/...\`、\`../\` 跳出目录) 3. **允许**在本目录内使用 \`write\`、\`edit\`、\`shell\`、\`tree\`;**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录) 4. **禁止**子 Agent、扩展管理、修改工作区外文件 5. 优先使用**相对路径**,例如 \`report.html\`、\`assets/chart.png\` 6. 生成 HTML 后,向用户提供可访问链接,格式: \`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/<文件名>\` ## 推荐工作流 1. 确认需求(标题、章节、是否要图表/样式) 2. 使用 \`write\` 创建 \`.html\`(可含内联 CSS;需要时在同目录建 \`assets/\`) 3. 在 \`
\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致) 4. 页面内资源使用**相对路径**(\`assets/foo.png\`),不要用磁盘绝对路径 5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」) 6. 完成后按「回复格式」返回可点击链接 ## 回复格式(必须) 向用户交付页面时,**必须使用 Markdown 可点击链接**,让用户在聊天里直接点开预览: \`\`\`markdown [普拉提 618 活动页](${exampleUrl}) \`\`\` 要求: - **必须**使用 \`[标题](URL)\` 格式,不要只给裸 URL - 标题用页面真实主题(不要用「点击这里」「链接」) - 可同时给出本地相对路径(如 \`pilates-618.html\`)与公网链接 - 说明:静态文件保存即生效,**无需重启** ## 信息流预览图(必须) 每个 HTML 页面都必须在 \`\` 包含与**页面主题一致**的预览元数据。系统据此生成 3:4 信息流封面(「我的空间」卡片 + 工作区 \`.thumbnail.svg\`): \`\`\`html \`\`\` | 字段 | 要求 | |------|------| | \`tag\` | 与页面主题一致:旅行 / 美食 / 报告 / **运动** / **活动** 等;决定默认配色 | | \`accent\` / \`accent2\` | 从页面主色提取,与 hero/背景一致 | | \`subtitle\` | 一句话卖点;未写时用 description | | \`cover\` / \`image\` | 主图(相对 HTML 路径或 https);**强烈建议**使用页面 hero 图 | | \`emoji\` | 可选;也可写在 title 中 | **禁止**使用与页面无关的通用风景图逻辑;促销/运动/品牌页必须写明 \`tag\`、\`accent\` 和 \`cover\`。 ## HTML 模板建议 - 完整 \`\`,\`lang="zh-CN"\` - 移动端友好:\`\` - 深色/浅色与内容一致;主色在 CSS 与 mindspace-cover 中保持一致 ## 查找文件(CSV、文档等) - 只在**当前工作区**内从 \`.\` 搜索(如 \`find . -name '*.csv'\`、\`tree .\`、\`rg\`) - 用户说的「OA 区」等视为工作区**子目录**(如 \`oa/\`),用相对路径查找 - **禁止**搜索上级目录、其它用户目录、\`${PUBLISH_ROOT_DIR}\` 根目录、\`/Users\`、项目根目录 - 找不到时告知用户上传或提供相对路径,**不要**扩大到工作区外 ## 禁止事项 - 不要写入当前工作区以外的任何目录 - shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖 ## 示例 用户:「写一份越南旅游指南网页」 正确做法: - \`write\` → \`vietnam-guide.html\`(内容完整) - 回复链接:\`${buildPublicUrl(publicBaseUrl, slug, 'vietnam-guide.html')}\` `; } export function renderWorkspaceHints({ slug, publishDir }) { return `# TKMind 用户工作区边界 你是用户 \`${slug}\` 的专属助手。当前会话**唯一**文件根目录: \`${publishDir}\` ${renderBrandingBlock()} ## 查找 / 读取文件(CSV、文档、OA 区等) 1. **只能**在本工作区内、从 \`.\` 开始搜索,例如 \`tree .\`、\`find . -name '*.csv'\`、\`rg keyword .\` 2. 用户说的「OA 区」「文档区」等,**先当作工作区内的子目录**(如 \`oa/\`、\`docs/\`),只用相对路径查找 3. **绝对禁止**: - 去上级目录、\`${PUBLISH_ROOT_DIR}\` 根目录、其它用户名目录、项目根目录搜索 - 使用 \`/Users\`、\`/home\`、Desktop、Documents、Downloads 等主机路径 - 在回复中建议「在 MindSpace 目录或附近目录搜索」——你没有这个权限 4. 找不到文件时:说明在其工作区内未找到,请用户上传或提供相对路径;**不得**扩大到工作区外 ## 公网链接 vs 本地文件 - 公网 URL(如 \`https://…/MindSpace/<用户>/\`)**仅供用户浏览器打开已发布的 HTML 页面** - **禁止**用公网 URL、curl、wget 或 read_image 去「列目录」「读 CSV」——静态站点不提供目录索引 - 有 \`shell\` 时用 \`ls oa/\`、\`find .\`、\`cat oa/file.csv\`;有 \`tree\` 时用 \`tree oa/\` - 不要说「我没有 shell」——若系统提示已列出 shell,就必须用 shell 在工作区内操作 `; } export function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] }) { const tools = developerTools.length > 0 ? developerTools : ['write', 'edit']; const lines = [ baseConstraints, '', '## 当前会话可用 developer 工具', `- ${tools.join(', ')}`, ]; if (tools.includes('shell')) { lines.push( '- **shell 已开启**:只能在本工作区内执行(如 `ls oa/`、`find . -name "*.csv"`、`cat oa/文件.csv`)', '- **禁止** curl/wget 或访问公网 URL 来读取工作区文件', ); } else { lines.push( '- **shell 未开启**:不能执行 shell;也不要用公网 URL 代替本地读文件', '- 需要列目录或读 CSV 时,请用户开启 shell 能力或上传文件内容', ); } if (tools.includes('tree')) { lines.push('- **tree 已开启**:`tree .` 或 `tree oa/` 查看目录结构'); } return lines.join('\n'); } export function buildPublishConstraints({ slug, publicBaseUrl, publishDir }) { return [ '## TKMind 用户空间沙箱(硬性约束)', '', '- 对用户统一称 **TKMind**;禁止称 goose / Goose / goosed 或「Rust goose 项目」', `- 当前用户 \`${slug}\` 的 Agent 工作区(唯一文件根目录):\`${publishDir}\``, '- 用户上传落在分区子目录:`oa/`、`private/`、`public/`(均在上述工作区内)', `- 公网 HTML 前缀(仅分享用):\`${buildPublicUrl(publicBaseUrl, slug)}\``, '- **查找文件**:在工作区内对应分区搜索(如 `oa/2025-12-06T13-34_export.csv`),不要搜其它用户目录或公网 URL', '- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV', '- **路径规则**:只用相对路径;禁止 `../`;越界 shell/tree/write/edit 会被拒绝', '- 生成 HTML 写入 `public/` 并给出公网链接', `- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill)`, ].join('\n'); } export function ensureWorkspaceHintsInstalled(publishDir, context) { const hintsPath = path.join(publishDir, WORKSPACE_HINTS_FILENAME); const legacyPath = path.join(publishDir, LEGACY_WORKSPACE_HINTS_FILENAME); const content = renderWorkspaceHints(context); 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 function ensurePublishSkillInstalled(publishDir, context) { const skillRoot = path.join(publishDir, '.agents', 'skills', PUBLISH_SKILL_NAME); fs.mkdirSync(skillRoot, { recursive: true }); const skillPath = path.join(skillRoot, 'SKILL.md'); const content = renderPublishSkill(context); const existing = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : ''; if (existing !== content) { fs.writeFileSync(skillPath, content, 'utf8'); } return skillPath; } export function ensureUserPublishLayout({ h5Root, publicBaseUrl, user, legacyUsersRoot = null, installWorkspaceHints = false, }) { const slug = resolvePublishSlug(user); const publishDir = resolvePublishDir(h5Root, user); fs.mkdirSync(publishDir, { recursive: true }); fs.mkdirSync(path.join(publishDir, 'assets'), { recursive: true }); const context = { slug, publicBaseUrl, publishDir }; if (legacyUsersRoot) { migrateLegacyUserTree(path.join(legacyUsersRoot, slug), publishDir); } if (installWorkspaceHints) { ensureWorkspaceHintsInstalled(publishDir, context); ensurePublishSkillInstalled(publishDir, context); } return { slug, publishDir, publicBaseUrl, publicUrl: buildPublicUrl(publicBaseUrl, slug), skillName: PUBLISH_SKILL_NAME, constraints: buildPublishConstraints(context), }; } function migrateLegacyUserTree(legacyDir, publishDir) { if (!fs.existsSync(legacyDir)) return; copyTree(legacyDir, publishDir); } function copyTree(src, dest) { for (const entry of fs.readdirSync(src, { withFileTypes: true })) { if (entry.name === '.agents') continue; const from = path.join(src, entry.name); const to = path.join(dest, entry.name); if (entry.isDirectory()) { fs.mkdirSync(to, { recursive: true }); copyTree(from, to); } else if (!fs.existsSync(to)) { fs.copyFileSync(from, to); } } } export function isPathInsidePublishDir(publishDir, requestedPath) { const resolvedPublish = path.resolve(publishDir); const resolved = path.resolve(requestedPath); return resolved === resolvedPublish || resolved.startsWith(`${resolvedPublish}${path.sep}`); }