diff --git a/chat-skills.mjs b/chat-skills.mjs index 8d4ef9a..4554b8c 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -112,7 +112,8 @@ export function buildChatSkillPrompt(promptKey, skillName) { return ( `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。` + '必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;完成后直接返回 Markdown 可点击公网链接 `[页面标题](URL)`。' + - '禁止只给本地路径(如 hello/index.html),禁止询问是否还要发布,除非写文件工具实际失败。' + '禁止只给本地路径(如 hello/index.html),禁止询问是否还要发布,除非写文件工具实际失败。' + + '如果页面里还需要提供 Word / docx 下载,必须先 `load_skill` → `docx-generate`,生成 `public/*.docx` 并确认文件已存在,再写 HTML 并用同目录相对路径链接该文档。' ); default: return ''; diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index c33087d..4bde421 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -42,6 +42,8 @@ test('buildChatSkillPrompt includes skill name for platform skills', () => { assert.match(buildChatSkillPrompt('web', 'web'), /请使用 web 技能/); assert.match(buildChatSkillPrompt('product-campaign-page'), /商品宣传 \/ 活动页/); assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/); + assert.match(buildChatSkillPrompt('generate-page'), /docx-generate/); + assert.match(buildChatSkillPrompt('generate-page'), /public\/\*\.docx/); }); test('prefillOnly is set for open-ended chat skills', () => { diff --git a/skills/docx-generate/SKILL.md b/skills/docx-generate/SKILL.md index 4dbf011..ea2a3e6 100644 --- a/skills/docx-generate/SKILL.md +++ b/skills/docx-generate/SKILL.md @@ -17,7 +17,7 @@ description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word . ## 何时使用 - 用户要 Word / docx / .doc 文档(输出 `.docx`) -- 需要保存到 `oa/`、`private/` 等分区 +- 需要保存到 `oa/`、`private/`、`public/` 等分区 ## 推荐命令 diff --git a/skills/docx-generate/generate_docx.py b/skills/docx-generate/generate_docx.py index ff28077..e0a3748 100644 --- a/skills/docx-generate/generate_docx.py +++ b/skills/docx-generate/generate_docx.py @@ -116,7 +116,6 @@ def table_block(headers: list[str], rows: list[list[str]]) -> str: "" "" ) - # Table must be a direct child of w:body — never wrap w:tbl inside w:p. parts = ["", tbl_pr, f"{grid_cols}"] if headers: parts.append(table_row(headers)) diff --git a/user-publish.mjs b/user-publish.mjs index d2ec1c6..4a24323 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -5,9 +5,11 @@ import { fileURLToPath } from 'node:url'; 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 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 WORKSPACE_HINTS_FILENAME = '.tkmindhints'; export const LEGACY_WORKSPACE_HINTS_FILENAME = '.goosehints'; @@ -155,6 +157,14 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」) 6. 完成后按「回复格式」返回可点击链接 +## Word 下载页(必须) + +- 如果用户明确要求页面里**可下载 Word / docx**,必须先 \`load_skill\` → \`docx-generate\` +- 用 \`.agents/skills/docx-generate/generate_docx.py\` 生成 \`public/文件名.docx\` +- 生成后必须用 \`list_dir public\`(或等价方式)确认目标 \`.docx\` 已存在,再写 \`public/页面.html\` +- HTML 中只能使用同目录相对路径链接文档,例如 \`下载 Word 文档\` +- **禁止**只写下载按钮或 \`.docx\` 链接,却没有先把目标文档落盘 + ## 回复格式(必须) 向用户交付页面时,**必须使用 Markdown 可点击链接**,让用户在聊天里直接点开预览: @@ -222,6 +232,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 - 不要写入当前工作区以外的任何目录 - shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖 - **禁止**在 HTML 中用 \`data:...;base64,...\` 内嵌 Word/PDF;二进制文件单独落盘后用相对路径链接(见 \`docx-generate\` 技能) +- 用户要求 Word / docx 下载时,禁止跳过 \`docx-generate\` 直接在 HTML 中伪造下载链接 ## 示例 @@ -257,6 +268,12 @@ ${renderBrandingBlock(addressName)} - **禁止**用公网 URL、curl、wget 或 read_image 去「列目录」「读 CSV」——静态站点不提供目录索引 - 有 \`shell\` 时用 \`ls oa/\`、\`find .\`、\`cat oa/file.csv\`;有 \`tree\` 时用 \`tree oa/\` - 不要说「我没有 shell」——若系统提示已列出 shell,就必须用 shell 在工作区内操作 + +## Word 下载页 + +- 若用户要求页面里可下载 Word / docx:必须先 \`load_skill\` → \`docx-generate\` +- 先生成并确认 \`public/*.docx\` 已存在,再写 \`public/*.html\` +- HTML 中只能用同目录相对路径链接该文档,禁止只写下载按钮却没有先把 `.docx` 落盘 `; } @@ -296,6 +313,7 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools '## 生成 / 发布 HTML 页面', '- 你有 write_file/edit_file 工具:**必须由你**写入 `public/xxx.html`(或工作区根目录 `.html`)', '- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据', + '- 如果用户要求 Word / docx 下载:先 `load_skill` → `docx-generate`,生成并确认 `public/*.docx` 已存在,再写 HTML 用相对路径链接', '- **禁止**用 shell / cat / heredoc / echo / cp 写入 HTML;shell 在容器内执行,文件不会出现在公网 MindSpace 路径', '- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write_file 调用失败)', '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段', @@ -317,6 +335,7 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish '- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV', '- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)', '- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入 `public/页面.html`', + '- **Word 下载页(必须亲自完成)**:若用户要求 Word / docx 下载,必须先 `load_skill` → `docx-generate`,生成并确认 `public/*.docx` 已存在,再在 HTML 中用相对路径链接', '- **用户可见回复**:不要向用户复述 load_skill、技能更新、页脚标记、mindspace-cover 等内部实现;完成后直接给出页面链接或结果', '- **禁止**用 shell 写入 HTML;**禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误', '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`', @@ -335,7 +354,17 @@ export function ensureWorkspaceHintsInstalled(publishDir, context) { return hintsPath; } +export function ensureDocxSkillInstalled(publishDir) { + if (!fs.existsSync(DOCX_SKILL_DIR)) return null; + const skillRoot = path.join(publishDir, '.agents', 'skills', DOCX_SKILL_NAME); + fs.rmSync(skillRoot, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(skillRoot), { recursive: true }); + fs.cpSync(DOCX_SKILL_DIR, skillRoot, { recursive: true }); + return path.join(skillRoot, 'SKILL.md'); +} + export function ensurePublishSkillInstalled(publishDir, context) { + ensureDocxSkillInstalled(publishDir); const skillRoot = path.join(publishDir, '.agents', 'skills', PUBLISH_SKILL_NAME); fs.mkdirSync(skillRoot, { recursive: true }); const skillPath = path.join(skillRoot, 'SKILL.md'); diff --git a/user-publish.test.mjs b/user-publish.test.mjs index ad52698..dc3b0e0 100644 --- a/user-publish.test.mjs +++ b/user-publish.test.mjs @@ -66,13 +66,20 @@ test('publish dir and public url use stable user id', () => { publicBaseUrl: 'https://m.tkmind.cn', }); const skillPath = path.join(layout.publishDir, '.agents', 'skills', 'static-page-publish', 'SKILL.md'); + const docxSkillPath = path.join(layout.publishDir, '.agents', 'skills', 'docx-generate', 'SKILL.md'); + const docxScriptPath = path.join(layout.publishDir, '.agents', 'skills', 'docx-generate', 'generate_docx.py'); assert.ok(fs.existsSync(skillPath)); + assert.ok(fs.existsSync(docxSkillPath)); + assert.ok(fs.existsSync(docxScriptPath)); const skillText = fs.readFileSync(skillPath, 'utf8'); + const docxSkillText = fs.readFileSync(docxSkillPath, 'utf8'); assert.match(skillText, new RegExp(`m\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`)); assert.match(skillText, /public\/report\.html/); assert.match(skillText, /\[.*\]\(.*\)/); assert.match(skillText, /mindspace-cover/); assert.match(skillText, /\.thumbnail\.svg/); + assert.match(skillText, /docx-generate/); + assert.match(docxSkillText, /public\/文件名\.docx/); }); test('migrateUserPublishDir merges legacy username directory', () => { @@ -103,6 +110,8 @@ test('buildPublishConstraints scopes default search to user workspace', () => { assert.doesNotMatch(constraints, /对用户统一称 \*\*TKMind\*\*/); assert.match(constraints, /禁止用公网 URL 列目录/); assert.match(constraints, /public\/页面\.html/); + assert.match(constraints, /docx-generate/); + assert.match(constraints, /public\/\*\.docx/); assert.match(constraints, /\/public\//); }); @@ -115,6 +124,8 @@ test('buildSandboxSessionConstraints documents shell and forbids public url brow assert.match(text, /禁止.*curl/); assert.match(text, /list_dir 可用/); assert.match(text, /public\/xxx\.html/); + assert.match(text, /docx-generate/); + assert.match(text, /public\/\*\.docx/); assert.match(text, /\/public\//); }); diff --git a/user-space.mjs b/user-space.mjs index 5311293..f2d1c5c 100644 --- a/user-space.mjs +++ b/user-space.mjs @@ -100,6 +100,7 @@ ${zoneLines.join('\n')} - 用户要网页 / HTML / 报告 / 分享链接时:**你必须亲自完成**,不要推给用户手动操作 - 先 \`load_skill\` → \`static-page-publish\`,再使用 \`write_file\` 创建 \`public/页面名.html\`(需要调整已有页面时用 \`edit_file\`) +- 如果页面里还要提供 Word / docx 下载:必须先 \`load_skill\` → \`docx-generate\`,生成 \`public/文件名.docx\` 并确认文件存在,再写 HTML 用相对路径链接该文档 - 写入 \`\` 的 **mindspace-cover** 元数据(详见 \`.agents/skills/static-page-publish/SKILL.md\`) - 保存后立即给出 Markdown 可点击公网链接 \`[标题](URL)\`;写入 \`public/页面.html\` 时 URL 为 \`.../MindSpace/<用户ID>/public/页面.html\` - **禁止**回复「请手动保存到 public 目录」「我无法生成页面」——除非 \`write_file\` / \`edit_file\` 调用已失败并说明具体错误 @@ -127,6 +128,7 @@ export function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseU '- **禁止**:访问其它用户目录、MindSpace 根目录、data/mindspace 内部路径、主机绝对路径', '- **禁止**用公网 URL 列目录或读 CSV;生成 HTML 写入 `public/` 并给出公网链接', '- **生成页面**:先 `load_skill` → `static-page-publish`,再用 `write_file` / `edit_file` 写入 `public/*.html`;**禁止**让用户手动保存文件', + '- **Word 下载页**:若用户要求 Word / docx 下载,必须先 `load_skill` → `docx-generate` 生成 `public/*.docx` 并确认文件存在,再在 HTML 里用相对路径链接该文档', publicBaseUrl && slug ? `- 公网 HTML 前缀(公开区):\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`(写入 \`public/页面.html\` 时分享链接必须含 \`public/\`)` : null, diff --git a/wechat-mp.mjs b/wechat-mp.mjs index bee38af..f034a64 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -346,6 +346,12 @@ function looksLikeHtmlGenerationIntent(text) { return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized); } +function looksLikeDocxDownloadIntent(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return /(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu.test(normalized); +} + function usedStaticPagePublishSkill(messages = []) { return messages.some((message) => message?.content?.some((item) => { @@ -1067,8 +1073,19 @@ function normalizeWechatInboundIntent(inbound) { return base; } -function buildWechatAgentPrompt(intent) { +export function buildWechatAgentPrompt(intent) { const msgType = String(intent?.msgType ?? 'text'); + const docxDownloadHint = + looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) && + looksLikeDocxDownloadIntent(intent?.agentText ?? intent?.content) + ? [ + '【Word 下载要求】用户明确要求页面里可下载 Word / docx。', + '开始前必须先调用 `load_skill` → `docx-generate`,并按技能说明生成目标 `public/*.docx`。', + '生成后必须确认目标 `.docx` 已存在,再调用 `load_skill` → `static-page-publish` 写 `public/*.html`。', + 'HTML 中只能用同目录相对路径链接该文档;禁止只写下载按钮却没有先把 `.docx` 落盘。', + '', + ].join('\n') + : ''; const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) ? [ '【页面发布技能要求】这条消息是在生成可访问 HTML 页面。', @@ -1094,6 +1111,7 @@ function buildWechatAgentPrompt(intent) { : ''; if (msgType === 'voice') { return [ + docxDownloadHint, scheduleAssistantHint, '【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。', '', @@ -1104,6 +1122,7 @@ function buildWechatAgentPrompt(intent) { } if (msgType === 'image') { return [ + docxDownloadHint, scheduleAssistantHint, '【微信服务号图片消息】用户发送了图片。', '如用户没有明确要求,请先根据图片内容给出简短理解,并询问下一步。', @@ -1144,6 +1163,7 @@ function buildWechatAgentPrompt(intent) { } const content = String(intent?.agentText ?? intent?.content ?? '').trim(); const lines = []; + if (docxDownloadHint) lines.push(docxDownloadHint); if (pagePublishHint) lines.push(pagePublishHint); if (scheduleAssistantHint) lines.push(scheduleAssistantHint); lines.push( diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index ca46edf..66db3eb 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { + buildWechatAgentPrompt, buildWechatTextReply, createWechatMpService, guardMissingPublicHtmlLinks, @@ -128,6 +129,25 @@ test('splitWechatText respects WeChat 2048-byte customer service limit', () => { assert.equal(chunks.join(''), longChinese); }); +test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => { + const prompt = buildWechatAgentPrompt({ + msgType: 'text', + content: '帮我生成一个行业分布报告页面,提供链接word给我来下载', + }); + assert.match(prompt, /docx-generate/); + assert.match(prompt, /public\/\*\.docx/); + assert.match(prompt, /static-page-publish/); +}); + +test('buildWechatAgentPrompt keeps normal html guidance without docx requirement for plain page requests', () => { + const prompt = buildWechatAgentPrompt({ + msgType: 'text', + content: '帮我生成一个简单页面', + }); + assert.match(prompt, /static-page-publish/); + assert.doesNotMatch(prompt, /docx-generate/); +}); + test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', async () => { const text = '页面已创建:\nhttps://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/missing.html';