Files
tkmind_go/ui/h5/user-publish.mjs
john 4e21ca937a
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
Fork goose with custom MCP widgets, platform extensions (aider, git, web, search),
MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:30:20 +08:00

284 lines
12 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';
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. 在 \`<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);**强烈建议**使用页面 hero 图 |
| \`emoji\` | 可选;也可写在 title 中 |
**禁止**使用与页面无关的通用风景图逻辑;促销/运动/品牌页必须写明 \`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, 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}`);
}