Files
memind/user-publish.mjs
T
john 945b0a609b fix: guide agent to a real public link instead of dead-ending on apps__create_app
Diagnosed via production session logs (RDS + shared goose PG) that some
users' page-generation attempts drifted: the agent used apps__create_app
(no public URL) then fabricated a placeholder domain when asked for a
link, and separately burned many turns scraping unreachable/anti-bot
search engines. Steer both flows toward the already-working path instead
of banning tools outright:

- apps__create_app is fine for designing/previewing a page, but the
  agent must still write_file the result into public/*.html per the
  static-page-publish skill and return a link built from the real
  public URL template (no invented domains)
- for real-world lookups, load_skill('web') first and prefer
  Bing/360 over retrying google.com or hammering anti-bot sites

Also folds in generate_docx guidance for Word-download attachments
(sandbox-fs tool) that landed in the same files during this pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 14:11:09 +08:00

380 lines
19 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 { 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). */
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(/\/$/, '');
}
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/<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 = '') {
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_file\`\`edit_file\`\`read_file\`\`list_dir\`\`generate_docx\`**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 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 根目录时才省略)
## 推荐工作流
1. 确认需求(标题、章节、是否要图表/样式)
2. 使用 \`write_file\` 创建 \`public/页面.html\`(可含内联 CSS;需要时在 \`public/assets/\` 或工作区 \`assets/\` 放资源)
3. 在 \`<head>\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致)
4. 页面内资源使用**相对路径**\`assets/foo.png\`),不要用磁盘绝对路径
5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」)
6. 下载按钮的相对链接目标(如 \`public/report.docx\`)必须通过 \`generate_docx\` 落盘,且 basename 与 HTML 一致
7. 完成后按「回复格式」返回可点击链接
## 伴生下载文件(必须)
- 相对路径下载链接(\`.docx\` / \`.pdf\` 等)必须在 HTML 同目录或子目录真实存在
- Word 文档必须用 sandbox-fs 的 \`generate_docx\` 生成;禁止用 \`computercontroller\` / shell 生成生产下载文件作为交付依据
- 改 HTML 文件名时同步 rename/copy 伴生文件;从 \`oa/\` 复制到 \`public/\` 再链接
- 可跑 \`node scripts/check-mindspace-public-links.mjs\` 自检
## 回复格式(必须)
向用户交付页面时,**必须使用 Markdown 可点击链接**,让用户在聊天里直接点开预览:
\`\`\`markdown
[普拉提 618 活动页](${exampleUrl})
\`\`\`
要求:
- **必须**使用 \`[标题](URL)\` 格式,不要只给裸 URL
- 标题用页面真实主题(不要用「点击这里」「链接」)
- 可同时给出本地相对路径(如 \`public/pilates-618.html\`)与公网链接
- 说明:静态文件保存即生效,**无需重启**
## 信息流预览图(必须)
每个 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\`
## HTML 模板建议
- 完整 \`<!DOCTYPE html>\`\`lang="zh-CN"\`
- 移动端友好:\`<meta name="viewport" content="width=device-width, initial-scale=1">\`
- 深色/浅色与内容一致;主色在 CSS 与 mindspace-cover 中保持一致
## 查找文件(CSV、文档等)
- 只在**当前工作区**内从 \`.\` 搜索(如 \`list_dir\`\`list_dir oa\`,或 shell 可用时 \`find . -name '*.csv'\`
- 用户说的「OA 区」等视为工作区**子目录**(如 \`oa/\`),用相对路径查找
- **禁止**搜索上级目录、其它用户目录、\`${PUBLISH_ROOT_DIR}\` 根目录、\`/Users\`、项目根目录
- 找不到时告知用户上传或提供相对路径,**不要**扩大到工作区外
## 禁止事项
- 不要写入当前工作区以外的任何目录
- shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖
## 示例
用户:「写一份越南旅游指南网页」
正确做法:
- \`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 在工作区内操作
`;
}
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,
'',
'## 当前会话可用文件工具',
`- ${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 元数据',
'- 用 `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 一致',
);
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',
'- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)',
'- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入 `public/页面.html`',
'- 若先用 `apps__create_app` 设计/预览,最后仍要把内容 write_file 落到 `public/页面.html` 才有公网链接',
'- **禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误',
'- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`,按模板拼真实地址',
'- **需要查实时/真实世界信息时**:先 `load_skill` → `web`,优先 `web_search`/`fetch_url`;国内 google.com 不可达,多次无果时改走 Bing/360 等可达搜索源',
'- 下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致;Word 必须用 sandbox-fs `generate_docx` 生成,不要用 `computercontroller` / shell 作为交付依据',
`- 发布技能:\`${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}`);
}