Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.

Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 15:04:43 -07:00
commit 2e14873f2d
272 changed files with 64133 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
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(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/<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('/')}`;
}
export function renderPublishSkill({ slug, username, publicBaseUrl, publishDir, displayName }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
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. 在 \`<head>\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致)
4. 页面内资源使用**相对路径**\`assets/foo.png\`),不要用磁盘绝对路径
5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」)
6. 完成后按「回复格式」返回可点击链接
## 回复格式(必须)
向用户交付页面时,**必须使用 Markdown 可点击链接**,让用户在聊天里直接点开预览:
\`\`\`markdown
[普拉提 618 活动页](${exampleUrl})
\`\`\`
要求:
- **必须**使用 \`[标题](URL)\` 格式,不要只给裸 URL
- 标题用页面真实主题(不要用「点击这里」「链接」)
- 可同时给出本地相对路径(如 \`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、文档等)
- 只在**当前工作区**内从 \`.\` 搜索(如 \`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, 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)` 可点击链接',
);
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)}\``,
'- **查找文件**:在工作区内对应分区搜索(如 `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/` 后会自动出现在 MindSpace 界面',
`- 发布技能:\`${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}`);
}