fix: require docx generation for downloadable pages
This commit is contained in:
+2
-1
@@ -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 '';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .
|
||||
## 何时使用
|
||||
|
||||
- 用户要 Word / docx / .doc 文档(输出 `.docx`)
|
||||
- 需要保存到 `oa/`、`private/` 等分区
|
||||
- 需要保存到 `oa/`、`private/`、`public/` 等分区
|
||||
|
||||
## 推荐命令
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 写入 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');
|
||||
|
||||
@@ -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\//);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user