fix: require docx generation for downloadable pages

This commit is contained in:
john
2026-07-01 12:07:46 +08:00
parent 6cb1a2475d
commit ee3dd3b23c
9 changed files with 88 additions and 4 deletions
+2 -1
View File
@@ -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 '';
+2
View File
@@ -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', () => {
+1 -1
View File
@@ -17,7 +17,7 @@ description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .
## 何时使用
- 用户要 Word / docx / .doc 文档(输出 `.docx`
- 需要保存到 `oa/``private/` 等分区
- 需要保存到 `oa/``private/``public/` 等分区
## 推荐命令
-1
View File
@@ -116,7 +116,6 @@ def table_block(headers: list[str], rows: list[list[str]]) -> str:
"</w:tblBorders>"
"</w:tblPr>"
)
# Table must be a direct child of w:body — never wrap w:tbl inside w:p.
parts = ["<w:tbl>", tbl_pr, f"<w:tblGrid>{grid_cols}</w:tblGrid>"]
if headers:
parts.append(table_row(headers))
+29
View File
@@ -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 中只能使用同目录相对路径链接文档,例如 \`<a href="report.docx" download>下载 Word 文档</a>\`
- **禁止**只写下载按钮或 \`.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 写入 HTMLshell 在容器内执行,文件不会出现在公网 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');
+11
View File
@@ -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\//);
});
+2
View File
@@ -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 用相对路径链接该文档
- 写入 \`<head>\` 的 **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,
+21 -1
View File
@@ -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(
+20
View File
@@ -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';