Files
memind/user-publish.mjs

541 lines
32 KiB
JavaScript
Raw Permalink 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 { fileURLToPath } from 'node:url';
import {
createMindSpacePublicPageUrl,
createMindSpacePublicUrl,
createMindSpaceUserRootUrl,
} from './mindspace-canonical-url.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const PUBLISH_SKILL_NAME = 'static-page-publish';
export const DOCX_SKILL_NAME = 'docx-generate';
export const LONG_IMAGE_SKILL_NAME = 'long-image-download';
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 DOCX_SKILL_DIR = path.join(__dirname, 'skills', DOCX_SKILL_NAME);
export const LONG_IMAGE_SKILL_DIR = path.join(__dirname, 'skills', LONG_IMAGE_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
- 仅在开场或用户打招呼时使用时段问候,且须与「TKMind 当前时间基准」一致(如「${name},早上好」);普通回复直接作答,不要每条都加问候;禁止把用户叫作 TKMind
- 不要描述本工作区为「Rust goose 项目」或「goose AI 框架」
- 本工作区是 TKMind **MindSpace 用户空间**,用于文件管理与静态页面生成
`;
}
/** Preferred name when addressing the user in chat (display name > username > slug). */
const INTERNAL_ID_PATTERN =
/^wx_[a-z0-9_]{4,64}$|^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function resolveUserAddressName({ displayName, username, slug } = {}) {
const preferred = String(displayName ?? '').trim();
if (preferred && !INTERNAL_ID_PATTERN.test(preferred)) return preferred;
const uname = String(username ?? '').trim();
if (uname && !INTERNAL_ID_PATTERN.test(uname)) return uname;
const fallback = String(slug ?? '').trim();
if (fallback && !INTERNAL_ID_PATTERN.test(fallback)) return fallback;
return '用户';
}
export function resolvePublicBaseUrl(env = process.env) {
return (env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/$/, '');
}
const LOCAL_VITE_PUBLIC_BASE_PATTERN = /^https?:\/\/(?:127\.0\.0\.1|localhost):5173$/i;
/** Agent 交付的 MindSpace 公开页 + Page Data API 应走 Portal,本地开发时勿用 Vite 5173。 */
export function resolvePageDataDeliveryBaseUrl(env = process.env) {
const explicit = String(
env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL ?? env.H5_PORTAL_BASE_URL ?? '',
)
.trim()
.replace(/\/$/, '');
if (explicit) return explicit;
const publicBase = String(env.H5_PUBLIC_BASE_URL ?? '').trim().replace(/\/$/, '');
const portalPort = Number(env.H5_PORT ?? 8081);
const portalBase = `http://127.0.0.1:${portalPort}`;
if (publicBase && LOCAL_VITE_PUBLIC_BASE_PATTERN.test(publicBase)) {
return portalBase;
}
if (publicBase) return publicBase;
return portalBase;
}
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));
}
/** Path passed to goosed sandbox-fs; must match the bind mount inside goosed containers. */
export function resolveGoosedSandboxRoot(h5Root, user, env = process.env) {
const canonicalRoot = String(
env.GOOSED_SANDBOX_PUBLISH_ROOT ?? env.MEMIND_SHARED_PUBLISH_ROOT ?? '',
).trim();
if (canonicalRoot) {
return path.join(path.resolve(canonicalRoot), resolvePublishKey(user));
}
return resolvePublishDir(h5Root, 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/<username>/ into MindSpace/<userId>/ 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 = '') {
if (!filename) {
return createMindSpaceUserRootUrl({ publicBaseUrl, ownerKey: publishKey });
}
return createMindSpacePublicUrl({ publicBaseUrl, ownerKey: publishKey, relativePath: filename });
}
/** Public-zone HTML pages live under \`public/\`; share URLs must include that segment. */
export function buildPublicZonePageUrl(publicBaseUrl, publishKey, filename) {
return createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey: publishKey, filename });
}
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_file\`\`edit_file\`\`read_file\`\`list_dir\`\`generate_image\`\`generate_docx\`\`generate_long_image\`**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 OS 层拦截越界访问)
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 根目录时才省略)
7. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 \`localStorage\` / \`sessionStorage\` / \`IndexedDB\` 保存任何数据;必须立即 \`load_skill\`\`page-data-collect\`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。
8. 当编排提示为“图片策略:强制生成”或用户明确要求新图片、背景图、主图、封面、插画、照片时,必须先 \`load_skill\`\`image-generation\`,由该技能自动补齐主体、场景、构图、光线和负面约束,再调用 \`sandbox-fs__generate_image\`;不得要求用户自己提供专业生图参数,不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。
## 推荐工作流
1. 确认需求(标题、章节、是否要图表/样式)
2. 使用 \`write_file\` 创建 \`public/页面.html\`(可含内联 CSS;需要时在 \`public/assets/\` 或工作区 \`assets/\` 放资源)
3. 在 \`<head>\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致)
4. 页面内资源使用**相对路径**\`assets/foo.png\`),不要用磁盘绝对路径
5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」)
6. 默认只生成 HTML;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件
7. 若用户明确要求 Word / docx 下载,下载按钮的相对链接目标(如 \`public/report.docx\`)必须通过 \`generate_docx\` 落盘,且 basename 与 HTML 一致
8. 若用户明确要求长图下载,必须先 \`load_skill\`\`long-image-download\`,调用 \`generate_long_image\` 生成并确认 \`public/<页面名>.long.png\` 已存在
9. 完成后按「回复格式」返回可点击链接
## AI 图片生成(按意图与用户选择)
- 调用前必须先 \`load_skill\`\`image-generation\`;用户只需描述主题与感觉,其余专业提示词由该技能补齐
- **强制生成**:页面主图优先只调用一次 \`generate_image(purpose=hero)\`;卡片封面和信息流缩略图必须复用这张主图裁剪派生,hero 成功后禁止再调用 \`card_cover\` / \`feed_cover\`
- 只有后台关闭 \`hero\`、且任务确实只需要某一种封面时,才允许改用一次 \`card_cover\`\`feed_cover\`;正文配图 \`inline_image\` 仅在后台开启且正文确有需要时单独生成
- 成功结果必须满足 \`ok=true\`、包含 \`jobId\`,且 \`source.mimeType\`\`image/png\`\`image/jpeg\`\`image/webp\`
- HTML 的 \`<img>\`、背景图及 \`mindspace-cover.cover\` 必须引用本次返回的 \`asset.htmlSrc\`\`workspaceRelativePath\` 只用于工作区文件操作,禁止直接写入 HTML。页面主图默认同时作为缩略图源图
- **自动**:只在用户需求确实需要新主图、背景图、正文配图或封面时调用
- **关闭**:禁止调用 \`generate_image\`
- 强制生成失败时必须如实报告,禁止静默改用 SVG/旧图后宣称图片生成完成
## 按需伴生下载文件
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成
- 相对路径下载链接(\`.docx\` / \`.pdf\` 等)必须在 HTML 同目录或子目录真实存在
- Word 文档必须用 sandbox-fs 的 \`generate_docx\` 生成;禁止用 \`computercontroller\` / shell 生成生产下载文件作为交付依据
- 改 HTML 文件名时同步 rename/copy 伴生文件;从 \`oa/\` 复制到 \`public/\` 再链接
- 可跑 \`node scripts/check-mindspace-public-links.mjs\` 自检
## Word 下载页(按需)
- 如果用户明确要求页面里**可下载 Word / docx**,必须先 \`load_skill\`\`docx-generate\`
- 用 \`.agents/skills/docx-generate/generate_docx.py\` 生成 \`public/文件名.docx\`
- 生成后必须用 \`list_dir public\`(或等价方式)确认目标 \`.docx\` 已存在,再写 \`public/页面.html\`
- HTML 中只能使用同目录相对路径链接文档,例如 \`<a href="report.docx" download>下载 Word 文档</a>\`
- **禁止**只写下载按钮或 \`.docx\` 链接,却没有先把目标文档落盘
## 回复格式(必须)
向用户交付页面时,**必须使用 Markdown 可点击链接**,让用户在聊天里直接点开预览:
\`\`\`markdown
[普拉提 618 活动页](${exampleUrl})
\`\`\`
要求:
- **必须**使用 \`[标题](URL)\` 格式,不要只给裸 URL
- 标题用页面真实主题(不要用「点击这里」「链接」)
- 可同时给出本地相对路径(如 \`public/pilates-618.html\`)与公网链接
- 说明:静态文件保存即生效,**无需重启**
- 若生成了长图,同时给 \`[长图预览](.../public/pilates-618.long.png)\`\`[下载长图](.../public/pilates-618.html?download=long-image)\`
## 信息流预览图(必须)
每个 HTML 页面都必须在 \`<head>\` 包含与**页面主题一致**的预览元数据。系统据此生成 **精美的 3:4 信息流封面**(「我的空间」卡片 + 工作区 \`.thumbnail.svg\` + 保存弹窗预览):
\`\`\`html
<meta name="description" content="一句话摘要,显示在预览图副标题">
<meta name="mindspace-cover" content='{"tag":"运动","emoji":"🧘","accent":"#eeb04e","accent2":"#1a0a2e","subtitle":"618 限时 5 节课","cover":"assets/hero.jpg"}'>
\`\`\`
| 字段 | 要求 |
|------|------|
| \`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\`
### 交付前自检(必须)
写完 \`public/*.html\` 后运行 cover 合规检查;有 error 则补全后再交付链接:
\`\`\`bash
npm run check:mindspace-cover
\`\`\`
视觉类页面若出现 \`missing_cover_image\` 警告,必须补 hero 主图后再回复用户。对比效果:\`npm run demo:thumbnails\`\`/thumbnail-demo/\`
## HTML 模板建议
- 完整 \`<!DOCTYPE html>\`\`lang="zh-CN"\`
- 移动端友好:\`<meta name="viewport" content="width=device-width, initial-scale=1">\`
- 深色/浅色与内容一致;主色在 CSS 与 mindspace-cover 中保持一致
- 中文页面优先使用系统无衬线字体:\`-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif\`**避免** Georgia、Times 等西文衬线体
- 页脚平台品牌行**必须**使用 \`data-mindspace-page-tag="platform-brand"\` 标记,显示为 **TKMind · 智趣**,**禁止**使用邮箱或 \`tkmind.ai\`
\`\`\`html
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
\`\`\`
- 带 \`data-mindspace-page-tag\` 的区域为平台固定信息:用户在编辑模式中不可见、不可改;预览与发布后正常显示
## 查找文件(CSV、文档等)
- 只在**当前工作区**内从 \`.\` 搜索(如 \`list_dir\`\`list_dir oa\`,或 shell 可用时 \`find . -name '*.csv'\`
- 用户说的「OA 区」等视为工作区**子目录**(如 \`oa/\`),用相对路径查找
- **禁止**搜索上级目录、其它用户目录、\`${PUBLISH_ROOT_DIR}\` 根目录、\`/Users\`、项目根目录
- 找不到时告知用户上传或提供相对路径,**不要**扩大到工作区外
## 禁止事项
- 不要写入当前工作区以外的任何目录
- shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖
- **禁止**在 HTML 中用 \`data:...;base64,...\` 内嵌 Word/PDF;二进制文件单独落盘后用相对路径链接(见 \`docx-generate\` 技能)
- 用户要求 Word / docx 下载时,禁止跳过 \`docx-generate\` 直接在 HTML 中伪造下载链接
- 用户要求长图下载时,禁止跳过 \`long-image-download\` 或把 \`.thumbnail.svg\` 当成长图
## 示例
用户:「写一份越南旅游指南网页」
正确做法:
- \`write_file\`\`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. **只能**在本工作区内搜索,例如 \`list_dir\`\`list_dir oa\`,或 shell 可用时 \`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 在工作区内操作
## 生成 / 发布 HTML 页面
- 用户要网页、交互应用或分享链接时,必须使用 对应 skill 并将最终 HTML 写入 \`public/*.html\`
- \`generate_image\` 成功后,HTML 的图片与背景只能使用返回的 \`asset.htmlSrc\`\`workspaceRelativePath\` 只用于文件操作,禁止直接写入 HTML
- 若页面接收用户输入并需要保存、查询、统计、修改或删除,必须使用 \`page-data-collect\` + Page Data API,禁止 localStorage / sessionStorage / IndexedDB
- \`apps__create_app\` 只能用于设计预览,不是 H5 交付物;使用后仍必须写入 \`public/*.html\` 并返回真实可访问链接
- 未验证文件已落盘且链接可访问前,禁止声称「已创建」「已打开」或「可在 Apps 标签找到」
## Word 下载页
- 若用户要求页面里可下载 Word / docx:必须先 \`load_skill\`\`docx-generate\`
- 先生成并确认 \`public/*.docx\` 已存在,再写 \`public/*.html\`
- HTML 中只能用同目录相对路径链接该文档,禁止只写下载按钮却没有先把 \`.docx\` 落盘
## 长图下载
- 若用户明确要求长图下载:必须先 \`load_skill\`\`long-image-download\`
- 调用 \`generate_long_image\` 生成 \`public/<页面名>.long.png\`
- 先确认 \`public/*.long.png\` 已存在,再回复 \`[长图预览](...long.png)\`\`[下载长图](...html?download=long-image)\`
- \`.thumbnail.svg\` 只是信息流封面,不能当成长图
`;
}
export function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] }) {
const tools = developerTools.length > 0 ? developerTools : ['write_file', 'edit_file'];
const hasSandboxMcp = tools.includes('write_file') || tools.includes('read_file');
const hasShell = tools.includes('shell');
const hasListDir = tools.includes('list_dir') || tools.includes('tree');
const lines = [
baseConstraints,
'',
'## 当前用户专属 PostgreSQL 数据空间(硬性)',
'- 当前 session 已绑定当前用户身份;`private_data_*` 工具只连接该用户在新 PG 中的独立 schema,禁止跨用户访问',
'- 任何需要保存、再次读取、统计、汇总、筛选、修改或删除的数据都必须使用 `private_data_*` 建表/注册 dataset,并由 HTML 通过 Page Data API 访问',
'- Agent 生成的 HTML/JavaScript 禁止使用 `localStorage`、`sessionStorage`、`IndexedDB` 保存数据;write_file/edit_file 会直接拒绝此类代码',
'- 不允许 SQLite,也不允许在浏览器存储、静态 JSON/JS 文件中伪装持久化业务数据;失败时必须修复 PG/Page Data 链路,不能降级',
'',
'## 当前会话可用文件工具',
`- ${tools.join(', ')}`,
hasSandboxMcp
? '- 文件工具由**沙箱 MCP** 提供:所有路径在 OS 层强制限制在工作区内,越界访问会立即报错'
: '',
].filter((l) => l !== '');
if (hasShell) {
lines.push(
'- **shell 已开启**:只能在本工作区内执行(如 `ls oa/`、`find . -name "*.csv"`、`cat oa/文件.csv`',
'- **禁止** curl/wget 或访问公网 URL 来读取工作区文件',
);
} else {
lines.push(
'- **shell 未开启**:不能执行 shell;也不要用公网 URL 代替本地读文件',
);
}
if (hasListDir) {
lines.push('- **list_dir 可用**`list_dir` 或 `list_dir oa` 查看目录结构');
} else {
lines.push('- 需要列目录或读 CSV 时,请用户上传文件内容或开启 list_dir 能力');
}
lines.push(
'',
'## 生成 / 发布 HTML 页面',
'- 你有 write_file/edit_file 工具:**必须由你**写入 `public/xxx.html`(或工作区根目录 `.html`',
'- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据',
'- `generate_image` 成功后,HTML 的图片与背景只能使用返回的 `asset.htmlSrc``workspaceRelativePath` 只用于文件操作,禁止直接写入 HTML',
'- 默认只生成 HTML;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件',
'- 只有用户明确要求 Word / docx 下载时:先 `load_skill` → `docx-generate`,生成并确认 `public/*.docx` 已存在,再写 HTML 用相对路径链接',
'- 只有用户明确要求长图下载时:先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成并确认 `public/*.long.png` 已存在,再回复长图预览与下载链接',
'- **禁止**用 shell / cat / heredoc / echo / cp 写入 HTMLshell 在容器内执行,文件不会出现在公网 MindSpace 路径',
'- 用 `apps__create_app` 设计页面时,最后仍要按 `static-page-publish` skill 把内容 write_file 落到 `public/`,才有公网链接',
'- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write_file 调用失败)',
'- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段;按本会话给出的公网前缀模板拼接真实地址',
'- 按需下载附件:Word 用 `generate_docx` 写入 `public/*.docx`,相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致',
'- 按需长图附件:用 `generate_long_image` 写入 `public/*.long.png`,不要把 `.thumbnail.svg` 当成长图',
'',
'## sandbox-fs / MCP 稳定性',
'- `private_data_bind_workspace_page` 等 sandbox-fs 工具若返回 **Transport closed**,最多再试 **1 次**',
'- 连续失败后:若 `public/*.html` 已 write_file 落盘,改走 Portal/H5 API 或 shell 发布兜底;**禁止**反复 kill MCP 或 curl 401 的浏览器下载接口',
'',
'## 网页搜索上限',
'- 实时搜索必须同一轮同时调用 `tkmind_search`103 专用 SearXNG)和 `web_search`DuckDuckGo),每个 provider 最多 **2 次**;再按需用 `fetch_url` 读取 Bing/360 等来源',
'- 3 轮仍无可用结果:停止搜索,用内置知识直接生成页面,并告知用户未获取实时搜索结果',
);
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 资料**:优先读工作区 `oa/文件名`;**禁止** curl 浏览器登录接口 `/api/mindspace/v1/assets/:id/download`Agent 无 cookie 会 401',
'- 工作区无副本时:POST `/api/agent/mindspace_asset_download`body: session_id + asset_id)落盘后再 `read_file`',
'- **查找文件**:在工作区内对应分区搜索(如 `oa/2025-12-06T13-34_export.csv`),不要搜其它用户目录或公网 URL',
'- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV',
'- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)',
'- **当前用户专属 PG**:本 session 的 `private_data_*` 工具已绑定当前用户,只能访问该用户在新 PostgreSQL 中的独立 schema;任何需持久化、再次读取、统计、汇总、修改或删除的数据都必须走 PG + Page Data API',
'- **浏览器存储硬禁令**Agent 生成的 HTML/JavaScript 禁止使用 `localStorage`、`sessionStorage`、`IndexedDB` 保存数据;禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化',
'- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入 `public/页面.html`',
'- **默认只生成 HTML**:不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件',
'- **Word 下载页(按需亲自完成)**:若用户明确要求 Word / docx 下载,必须先 `load_skill` → `docx-generate`,生成并确认 `public/*.docx` 已存在,再在 HTML 中用相对路径链接',
'- **长图下载(按需亲自完成)**:若用户明确要求长图下载,必须先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成并确认 `public/*.long.png` 已存在',
'- **用户可见回复**:不要向用户复述 load_skill、技能更新、页脚标记、mindspace-cover 等内部实现;完成后直接给出页面链接或结果',
'- **禁止**用 shell 写入 HTML**禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误',
'- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`',
'- 若先用 `apps__create_app` 设计/预览,最后仍要把内容 write_file 落到 `public/页面.html` 才有公网链接',
'- **需要查实时/真实世界信息时**:先 `load_skill` → `web`,同一轮同时调用 `tkmind_search`103 专用 SearXNG)和 `web_search`DuckDuckGo),合并去重;国内 google.com 不可达,一侧失败时继续使用另一侧,再按需改走 Bing/360 等可达来源',
'- **禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误',
'- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`,按模板拼真实地址',
'- 按需下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致;Word 必须用 sandbox-fs `generate_docx` 生成,不要用 `computercontroller` / shell 作为交付依据',
'- 按需长图链接:生成 `report.long.png` 后给 `[长图预览](.../report.long.png)`,下载用 `[下载长图](.../report.html?download=long-image)`',
'- **sandbox-fs Transport closed**`private_data_bind_workspace_page` 等 MCP 工具若返回 Transport closed**最多重试 1 次**;仍失败则改走 Portal API / shell 发布兜底,**禁止**无限 kill/restart MCP',
'- **网页搜索**`tkmind_search` 与 `web_search` 各最多 2 次并合并结果;再试 Bing/360 `fetch_url`;两边仍无结果则用内置知识直接生成页面并说明未获取实时搜索结果',
`- 发布技能:\`${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 ensureDocxSkillInstalled(publishDir) {
if (!fs.existsSync(DOCX_SKILL_DIR)) return null;
const skillRoot = path.join(publishDir, '.agents', 'skills', DOCX_SKILL_NAME);
// Multiple local H5 processes can bootstrap the same user workspace at once.
// Copy in place so one process never removes a directory while another is
// traversing it. Bundled skill files are overwritten deterministically.
fs.mkdirSync(skillRoot, { recursive: true });
fs.cpSync(DOCX_SKILL_DIR, skillRoot, { recursive: true, force: true });
return path.join(skillRoot, 'SKILL.md');
}
export function ensureLongImageSkillInstalled(publishDir) {
if (!fs.existsSync(LONG_IMAGE_SKILL_DIR)) return null;
const skillRoot = path.join(publishDir, '.agents', 'skills', LONG_IMAGE_SKILL_NAME);
fs.mkdirSync(skillRoot, { recursive: true });
fs.cpSync(LONG_IMAGE_SKILL_DIR, skillRoot, { recursive: true, force: true });
return path.join(skillRoot, 'SKILL.md');
}
export function ensurePublishSkillInstalled(publishDir, context) {
ensureDocxSkillInstalled(publishDir);
ensureLongImageSkillInstalled(publishDir);
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}`);
}