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 PUBLIC_ZONE_DIR = 'public'; 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(userAddressName) { const name = userAddressName || '用户'; return `## 品牌与称呼(硬性) - 你是 **TKMind** 助手;介绍产品时用 TKMind,不要称 goose、Goose、goosed - 与用户对话时,用 **${name}** 称呼用户(可辅以「你/您」),**禁止**把用户叫作 TKMind - 问候示例:「${name},下午好」——不要用「TKMind,下午好」 - 不要描述本工作区为「Rust goose 项目」或「goose AI 框架」 - 本工作区是 TKMind **MindSpace 用户空间**,用于文件管理与静态页面生成 `; } /** Preferred name when addressing the user in chat (display name > username > slug). */ export function resolveUserAddressName({ displayName, username, slug } = {}) { const preferred = String(displayName ?? '').trim(); if (preferred) return preferred; const uname = String(username ?? '').trim(); if (uname) return uname; const fallback = String(slug ?? '').trim(); return fallback || '用户'; } export function resolvePublicBaseUrl(env = process.env) { return (env.H5_PUBLIC_BASE_URL ?? 'https://goo.tkmind.cn').replace(/\/$/, ''); } export const PUBLISH_KEY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** Stable per-user directory key (UUID). */ export function resolvePublishKey(user) { if (!user?.id) throw new Error('缺少用户 ID'); return String(user.id).trim().toLowerCase(); } /** @deprecated Use resolvePublishKey — kept as alias for URL/path segments. */ export function resolvePublishSlug(user) { return resolvePublishKey(user); } export function resolveUsernameSlug(user) { if (!user?.username) throw new Error('缺少用户名'); return String(user.username).trim().toLowerCase(); } export function resolvePublishDir(h5Root, user) { return path.join(h5Root, PUBLISH_ROOT_DIR, resolvePublishKey(user)); } export function resolveLegacyPublishDir(h5Root, user) { if (!user?.username) return null; return path.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user)); } function mergePublishTrees(sourceDir, targetDir) { if (!fs.existsSync(sourceDir)) return; for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { const from = path.join(sourceDir, entry.name); const to = path.join(targetDir, entry.name); if (entry.isDirectory()) { fs.mkdirSync(to, { recursive: true }); mergePublishTrees(from, to); } else if (!fs.existsSync(to)) { fs.copyFileSync(from, to); } } } /** Move username-based MindSpace// into MindSpace// once. */ export function migrateUserPublishDir(h5Root, user, legacyUsersRoot = null) { const target = resolvePublishDir(h5Root, user); fs.mkdirSync(target, { recursive: true }); const legacyDirs = [ resolveLegacyPublishDir(h5Root, user), legacyUsersRoot ? path.join(legacyUsersRoot, resolveUsernameSlug(user)) : null, ].filter(Boolean); for (const legacyDir of legacyDirs) { if (path.resolve(legacyDir) === path.resolve(target)) continue; mergePublishTrees(legacyDir, target); } return target; } export function buildPublicUrl(publicBaseUrl, publishKey, filename = '') { const base = `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}`; if (!filename) return `${base}/`; const clean = filename.replace(/^\/+/, ''); return `${base}/${clean.split('/').map(encodeURIComponent).join('/')}`; } /** Public-zone HTML pages live under \`public/\`; share URLs must include that segment. */ export function buildPublicZonePageUrl(publicBaseUrl, publishKey, filename) { const clean = String(filename ?? '').replace(/^\/+/, ''); const relative = clean.startsWith(`${PUBLIC_ZONE_DIR}/`) ? clean : `${PUBLIC_ZONE_DIR}/${clean}`; return buildPublicUrl(publicBaseUrl, publishKey, relative); } export function renderPublishSkill({ slug, username, publicBaseUrl, publishDir, displayName }) { const addressName = resolveUserAddressName({ displayName, username, slug }); const exampleUrl = buildPublicZonePageUrl(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. 页面默认写入 \`public/\` 分区,使用相对路径如 \`public/report.html\`、\`public/assets/chart.png\` 6. 生成 HTML 后,向用户提供可访问链接,格式: \`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/<文件名>\` (写入 \`public/页面.html\` 时 URL **必须**含 \`public/\`;仅当 HTML 直接写在 workspace 根目录时才省略) ## 推荐工作流 1. 确认需求(标题、章节、是否要图表/样式) 2. 使用 \`write\` 创建 \`public/页面.html\`(可含内联 CSS;需要时在 \`public/assets/\` 或工作区 \`assets/\` 放资源) 3. 在 \`\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致) 4. 页面内资源使用**相对路径**(\`assets/foo.png\`),不要用磁盘绝对路径 5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」) 6. 完成后按「回复格式」返回可点击链接 ## 回复格式(必须) 向用户交付页面时,**必须使用 Markdown 可点击链接**,让用户在聊天里直接点开预览: \`\`\`markdown [普拉提 618 活动页](${exampleUrl}) \`\`\` 要求: - **必须**使用 \`[标题](URL)\` 格式,不要只给裸 URL - 标题用页面真实主题(不要用「点击这里」「链接」) - 可同时给出本地相对路径(如 \`public/pilates-618.html\`)与公网链接 - 说明:静态文件保存即生效,**无需重启** ## 信息流预览图(必须) 每个 HTML 页面都必须在 \`\` 包含与**页面主题一致**的预览元数据。系统据此生成 **精美的 3:4 信息流封面**(「我的空间」卡片 + 工作区 \`.thumbnail.svg\` + 保存弹窗预览): \`\`\`html \`\`\` | 字段 | 要求 | |------|------| | \`tag\` | 与页面主题一致:旅行 / 美食 / 报告 / **运动** / **活动** 等;决定默认配色 | | \`accent\` / \`accent2\` | 从页面主色提取,与 hero/背景一致 | | \`subtitle\` | 一句话卖点;未写时用 description | | \`cover\` / \`image\` | **必须**指向高质量主图(相对 HTML 或 https);见下文 | | \`emoji\` | 可选;也可写在 title 中 | ### 精美预览图(必须达标) 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」)。要产出**可在信息流中直接展示的精美封面**,必须: 1. **视觉类页面**(旅行、美食、活动、运动、品牌、产品、促销等)**必须**在 \`assets/\` 放置高质量 hero 主图(建议宽度 ≥1200px),并在 \`cover\` 字段引用 2. **纯文字报告**可仅用配色 + tag,但仍须保证 \`accent\` / \`subtitle\` 与页面风格一致 3. \`tag\`、\`accent\`、\`accent2\`、\`subtitle\` 必须与页面实际视觉一致;**禁止**省略 mindspace-cover 或填无关默认值 4. 若缺少 hero 主图,封面会退化为简陋默认图,**视为未达标** **禁止**使用与页面无关的通用风景图逻辑;促销/运动/品牌页必须写明 \`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\` → \`public/vietnam-guide.html\`(内容完整) - 回复链接:\`${buildPublicZonePageUrl(publicBaseUrl, slug, 'vietnam-guide.html')}\` `; } export function renderWorkspaceHints({ slug, username, publishDir, displayName }) { const addressName = resolveUserAddressName({ displayName, username, slug }); return `# TKMind 用户工作区边界 你是用户 **${addressName}** 的专属 TKMind 助手。当前会话**唯一**文件根目录: \`${publishDir}\` ${renderBrandingBlock(addressName)} ## 查找 / 读取文件(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/` 查看目录结构'); } lines.push( '', '## 生成 / 发布 HTML 页面', '- 你有 write/edit 工具:**必须由你**写入 `public/xxx.html`(或工作区根目录 `.html`)', '- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据', '- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write 调用失败)', '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段', ); return lines.join('\n'); } export function buildPublishConstraints({ slug, username, publicBaseUrl, publishDir, displayName }) { const addressName = resolveUserAddressName({ displayName, username, slug }); return [ '## TKMind 用户空间沙箱(硬性约束)', '', '- 你是 **TKMind** 助手;与用户对话时用 **' + addressName + '** 称呼用户,禁止把用户叫作 TKMind', '- 禁止称 goose / Goose / goosed 或「Rust goose 项目」', `- 当前用户 **${addressName}** 的 Agent 工作区(唯一文件根目录):\`${publishDir}\``, '- 用户上传落在分区子目录:`oa/`、`private/`、`public/`(均在上述工作区内)', `- 公网 HTML 前缀(公开区页面):\`${buildPublicUrl(publicBaseUrl, slug, `${PUBLIC_ZONE_DIR}/`)}\``, '- **查找文件**:在工作区内对应分区搜索(如 `oa/2025-12-06T13-34_export.csv`),不要搜其它用户目录或公网 URL', '- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV', '- **路径规则**:只用相对路径;禁止 `../`;越界 shell/tree/write/edit 会被拒绝', '- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write`/`edit` 写入 `public/页面.html`', '- **禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write 已失败并报告错误', '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`', `- 发布技能:\`${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); 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, }) { migrateUserPublishDir(h5Root, user, legacyUsersRoot); const slug = resolvePublishKey(user); const username = user.username ? resolveUsernameSlug(user) : slug; const displayName = user.displayName ?? user.display_name ?? null; const publishDir = resolvePublishDir(h5Root, user); fs.mkdirSync(path.join(publishDir, 'assets'), { recursive: true }); const context = { slug, username, displayName, publicBaseUrl, publishDir }; if (installWorkspaceHints) { ensureWorkspaceHintsInstalled(publishDir, context); ensurePublishSkillInstalled(publishDir, context); } return { slug, username, displayName, publishDir, publicBaseUrl, publicUrl: buildPublicUrl(publicBaseUrl, slug), skillName: PUBLISH_SKILL_NAME, constraints: buildPublishConstraints(context), }; } export function isPathInsidePublishDir(publishDir, requestedPath) { const resolvedPublish = path.resolve(publishDir); const resolved = path.resolve(requestedPath); return resolved === resolvedPublish || resolved.startsWith(`${resolvedPublish}${path.sep}`); }