Files
memind/user-space.mjs
T
john 9b4a25799f Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 15:19:03 +08:00

201 lines
8.5 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 { SYSTEM_CATEGORIES } from './mindspace.mjs';
import { PUBLISH_ROOT_DIR, resolvePublishDir, resolveUserAddressName } from './user-publish.mjs';
/** 用户 MindSpace 下的分区子目录(与上传分类一致) */
export const UPLOAD_ZONE_CODES = ['oa', 'private', 'public'];
export function resolveMindspaceStorageRoot(h5Root, env = process.env) {
return path.resolve(env.MINDSPACE_STORAGE_ROOT ?? path.join(h5Root, 'data', 'mindspace'));
}
/** Agent 工作区 = MindSpace/<userId>/ */
export function resolveUserWorkspaceRoot(h5Root, user) {
return resolvePublishDir(h5Root, user);
}
export const WORKSPACE_HINTS_FILENAME = '.tkmindhints';
export const LEGACY_WORKSPACE_HINTS_FILENAME = '.goosehints';
function renderUserSpaceBrandingBlock(userAddressName) {
const name = userAddressName || '用户';
return `## 品牌与称呼(硬性)
- 你是 **TKMind** 助手;介绍产品时用 TKMind,不要称 goose、Goose、goosed
- 与用户对话时,用 **${name}** 称呼用户(可辅以「你/您」),**禁止**把用户叫作 TKMind
- 问候示例:「${name},下午好」——不要用「TKMind,下午好」
- 不要描述本工作区为「Rust goose 项目」或「goose AI 框架」
- 本工作区是 TKMind **MindSpace 用户空间**,用于 OA/私人/公开文件管理与静态页面生成
`;
}
export function resolveZoneDir(workspaceRoot, categoryCode) {
return path.join(workspaceRoot, categoryCode);
}
export function resolveZoneFilePath(workspaceRoot, categoryCode, filename) {
return path.join(resolveZoneDir(workspaceRoot, categoryCode), filename);
}
export function zoneLabel(categoryCode) {
return SYSTEM_CATEGORIES.find((item) => item.code === categoryCode)?.name ?? categoryCode;
}
export function ensureUserZoneDirs(workspaceRoot) {
for (const code of UPLOAD_ZONE_CODES) {
fs.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true });
}
}
/** 上传完成后镜像到 MindSpace/<user>/<category>/文件名 */
export function mirrorAssetToZone({ workspaceRoot, categoryCode, filename, sourcePath }) {
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
if (!sourcePath || !fs.existsSync(sourcePath)) return null;
ensureUserZoneDirs(workspaceRoot);
const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(sourcePath, dest);
return dest;
}
export function removeZoneMirror({ workspaceRoot, categoryCode, filename }) {
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return;
const target = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
if (fs.existsSync(target)) fs.unlinkSync(target);
}
export function isPathInsideUserWorkspace(workspaceRoot, requestedPath) {
const base = path.resolve(workspaceRoot);
const resolved = path.resolve(requestedPath);
return resolved === base || resolved.startsWith(`${base}${path.sep}`);
}
export function renderUserSpaceHints({ username, workspaceRoot, displayName, slug }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
const zoneLines = UPLOAD_ZONE_CODES.map(
(code) => `- \`${code}/\`${zoneLabel(code)}(用户上传落在此分区)`,
);
return `# TKMind 用户空间分区
你是用户 **${addressName}** 的专属 TKMind 助手。当前会话工作区(唯一文件根目录):
\`${workspaceRoot}\`
${renderUserSpaceBrandingBlock(addressName)}
## 分区规则(硬性)
用户上传的文件**只会**出现在下列子目录之一:
${zoneLines.join('\n')}
## 查找 / 读取文件
1. 在工作区内搜索,例如 \`tree oa/\`\`find oa -name '*.csv'\`\`cat oa/文件名.csv\`
2. 用户说「OA 区」→ \`oa/\`;「私人区」→ \`private/\`;「公开区」→ \`public/\`
3. **禁止**访问其它用户目录、\`${PUBLISH_ROOT_DIR}/\` 根目录(非本用户)、\`data/mindspace/\`、主机绝对路径
4. **禁止**用公网 URL 列目录或读 CSV;公网链接仅用于分享已发布 HTML
5. 找不到时说明该分区内没有该文件,请用户确认上传到了哪个分区
## 生成页面
- 用户要网页 / HTML / 报告 / 分享链接时:**你必须亲自完成**,不要推给用户手动操作
- 先 \`load_skill\`\`static-page-publish\`,再使用 \`write_file\` 创建 \`public/页面名.html\`(需要调整已有页面时用 \`edit_file\`
- 写入 \`<head>\` 的 **mindspace-cover** 元数据(详见 \`.agents/skills/static-page-publish/SKILL.md\`
- 保存后立即给出 Markdown 可点击公网链接 \`[标题](URL)\`;写入 \`public/页面.html\` 时 URL 为 \`.../MindSpace/<用户ID>/public/页面.html\`
- **禁止**回复「请手动保存到 public 目录」「我无法生成页面」——除非 \`write_file\` / \`edit_file\` 调用已失败并说明具体错误
## 工作区文件与 OA 界面
- 写入 \`oa/\`\`private/\`\`public/\` 根目录的支持类型文件(docx、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库
- 打开对应分区或保存文件后会出现在界面中,可直接预览或下载
- 用户上传的文件仍以界面入库为准,并镜像到上述分区
`;
}
export function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slug, displayName }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
const zoneList = UPLOAD_ZONE_CODES.map((code) => `\`${code}/\`${zoneLabel(code)}`).join('、');
return [
'## TKMind 用户空间分区(硬性约束)',
'',
`- 你是 **TKMind** 助手;与用户对话时用 **${addressName}** 称呼用户,禁止把用户叫作 TKMind`,
'- 禁止称 goose / Goose / goosed 或「Rust goose 项目」',
`- 用户 **${addressName}** 的 Agent 工作区:\`${workspaceRoot}\``,
`- 用户上传落在分区子目录:${zoneList}(例如 OA 文件在 \`oa/\``,
'- **查找文件**:只在上述工作区内搜索(如 `oa/2025-12-06T13-34_export.csv`',
'- **禁止**:访问其它用户目录、MindSpace 根目录、data/mindspace 内部路径、主机绝对路径',
'- **禁止**用公网 URL 列目录或读 CSV;生成 HTML 写入 `public/` 并给出公网链接',
'- **生成页面**:先 `load_skill` → `static-page-publish`,再用 `write_file` / `edit_file` 写入 `public/*.html`**禁止**让用户手动保存文件',
publicBaseUrl && slug
? `- 公网 HTML 前缀(公开区):\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`(写入 \`public/页面.html\` 时分享链接必须含 \`public/\``
: null,
]
.filter(Boolean)
.join('\n');
}
export function ensureUserSpaceHints(workspaceRoot, context) {
const hintsPath = path.join(workspaceRoot, WORKSPACE_HINTS_FILENAME);
const legacyPath = path.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME);
const content = renderUserSpaceHints({ ...context, workspaceRoot });
fs.writeFileSync(hintsPath, content, 'utf8');
if (fs.existsSync(legacyPath)) {
fs.unlinkSync(legacyPath);
}
return hintsPath;
}
export async function syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot) {
ensureUserZoneDirs(workspaceRoot);
const placeholders = UPLOAD_ZONE_CODES.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT a.original_filename, c.category_code, v.storage_key
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})`,
[userId, ...UPLOAD_ZONE_CODES],
);
for (const row of rows) {
const sourcePath = path.join(storageRoot, row.storage_key);
mirrorAssetToZone({
workspaceRoot,
categoryCode: row.category_code,
filename: row.original_filename,
sourcePath,
});
}
return { workspaceRoot, mirrored: rows.length };
}
export async function ensureUserSpaceLayout({
pool,
storageRoot,
userId,
username,
displayName,
publicBaseUrl,
slug,
workspaceRoot,
}) {
ensureUserZoneDirs(workspaceRoot);
if (pool && userId) {
await syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot);
}
const context = {
username: username ?? slug,
displayName,
workspaceRoot,
slug,
publicBaseUrl,
};
ensureUserSpaceHints(workspaceRoot, context);
return {
workspaceRoot,
zonesRoot: workspaceRoot,
zonesPublicDir: resolveZoneDir(workspaceRoot, 'public'),
constraints: buildUserSpaceConstraints(context),
...context,
};
}