diff --git a/scripts/print-tang-page-refine-message.mjs b/scripts/print-tang-page-refine-message.mjs new file mode 100644 index 0000000..bced45b --- /dev/null +++ b/scripts/print-tang-page-refine-message.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** Print Tang copy-paste WeChat page-refine message. */ +import { buildWechatPageRefineUserMessageTemplate } from '../wechat/page-refine-target.mjs'; + +const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db'; +const PAGE = 'public/shengsi-3day-guide.html'; +const BASE = 'https://m.tkmind.cn'; + +console.log(buildWechatPageRefineUserMessageTemplate({ + publicHtmlPath: PAGE, + requirements: [ + '按 Day1 / Day2 / Day3 列出具体时间节点(出发、交通、景点、用餐、住宿)', + '在原有内容基础上补充,不要删掉已有有效信息', + '直接 edit 原文件,不要新建页面', + ].join(';'), + publicUrl: `${BASE}/MindSpace/${TANG_USER_ID}/${PAGE}`, +})); diff --git a/wechat-intent-router.test.mjs b/wechat-intent-router.test.mjs index 7f62d26..0325edc 100644 --- a/wechat-intent-router.test.mjs +++ b/wechat-intent-router.test.mjs @@ -13,6 +13,18 @@ import { defaultsFromEnv, } from './wechat-intent-router-config.mjs'; import { isPageGenerateText } from './wechat/intent/patterns.mjs'; +import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; + +test('classifyWechatIntent honors channel prefix for chat vs page', () => { + assert.equal( + classifyWechatIntent({ msgType: 'text', agentText: '【聊天】帮我做一个游玩攻略页面' }).kind, + 'chat.general', + ); + assert.equal( + classifyWechatIntent({ msgType: 'text', agentText: '【改页】public/demo.html 更详细' }).kind, + 'page.generate', + ); +}); test('isPageGenerateText matches travel guide requests without explicit 页面', () => { assert.equal( diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 526a6c7..5272196 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -46,11 +46,16 @@ import { resolveSyncReply, shouldHandleSyncReply, } from './wechat/handlers/sync-replies.mjs'; +import { stripWechatChannelPrefix, WECHAT_CHANNEL_PREFIX } from './wechat/intent/channel-prefix.mjs'; import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; import { isWechatSessionResetAction, resolveWechatSessionAction, } from './wechat/intent/session-action.mjs'; +import { + extractMindSpacePublicHtmlPath, + isWechatPageRefineRequestText, +} from './wechat/page-refine-target.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; import { isPageDataDevIntent, @@ -1078,12 +1083,21 @@ function isTopicResetIntent(text) { } export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) { - return ( + if ( wechatIntent?.kind === 'session.reset' || isWechatSessionResetAction(wechatIntent?.sessionActionResult) || isTopicResetIntent(resetCandidate) || isPageDataIntent(resetCandidate) - ); + ) { + return true; + } + if (wechatIntent?.kind !== 'page.generate') return false; + const text = String(resetCandidate ?? wechatIntent?.topic ?? '').trim(); + if (isWechatSessionPageContinuation(wechatIntent, text)) return false; + if (wechatIntent?.channelPrefix === WECHAT_CHANNEL_PREFIX.PAGE) return true; + if (extractMindSpacePublicHtmlPath(text)) return true; + if (isWechatPageRefineRequestText(text)) return true; + return false; } export function isWechatImmediateContextFollowup(wechatIntent, text) { @@ -1384,7 +1398,7 @@ function normalizeWechatInboundIntent(inbound) { return { ...base, displayText: content, - agentText: content, + agentText: stripWechatChannelPrefix(content), }; } @@ -1393,7 +1407,7 @@ function normalizeWechatInboundIntent(inbound) { return { ...base, displayText: recognition ? `语音:${recognition}` : '语音消息', - agentText: recognition, + agentText: stripWechatChannelPrefix(recognition), media: { mediaId: inbound?.mediaId || '', format: inbound?.format || '', diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index a7f04fa..18d4417 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -343,6 +343,27 @@ test('Page Data requests always rotate away from an existing WeChat route', () = true, ); assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '继续聊德川家康'), false); + assert.equal( + shouldForceNewWechatAgentSession( + { kind: 'page.generate', channelPrefix: 'page' }, + 'public/shengsi-3day-guide.html 更详细', + ), + true, + ); + assert.equal( + shouldForceNewWechatAgentSession( + { kind: 'page.generate' }, + 'https://m.tkmind.cn/MindSpace/u/public/demo.html 页面更详细', + ), + true, + ); + assert.equal( + shouldForceNewWechatAgentSession( + { kind: 'page.generate' }, + '把这个做成页面', + ), + false, + ); assert.equal(shouldForceNewWechatAgentSession({ kind: 'session.reset' }, '继续聊德川家康'), true); assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true); }); diff --git a/wechat/intent/channel-prefix.mjs b/wechat/intent/channel-prefix.mjs new file mode 100644 index 0000000..ca7f2d2 --- /dev/null +++ b/wechat/intent/channel-prefix.mjs @@ -0,0 +1,49 @@ +/** Explicit WeChat channel prefixes so users can separate chat, page, and scheduled tasks. */ + +export const WECHAT_CHANNEL_PREFIX = Object.freeze({ + CHAT: 'chat', + PAGE: 'page', + SCHEDULED_TASK: 'scheduled_task', +}); + +const PREFIX_ENTRIES = Object.freeze([ + { + channel: WECHAT_CHANNEL_PREFIX.CHAT, + pattern: /^【?\s*聊天\s*】?[::\s]*/iu, + }, + { + channel: WECHAT_CHANNEL_PREFIX.PAGE, + pattern: /^【?\s*(?:改页|做页|页面)\s*】?[::\s]*/iu, + }, + { + channel: WECHAT_CHANNEL_PREFIX.SCHEDULED_TASK, + pattern: /^【?\s*定时任务\s*】?[::\s]*/iu, + }, +]); + +/** + * @returns {{ channel: string|null, body: string, hadPrefix: boolean }} + */ +export function parseWechatChannelPrefix(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) { + return { channel: null, body: '', hadPrefix: false }; + } + for (const entry of PREFIX_ENTRIES) { + if (!entry.pattern.test(normalized)) continue; + return { + channel: entry.channel, + body: normalized.replace(entry.pattern, '').trim(), + hadPrefix: true, + }; + } + return { channel: null, body: normalized, hadPrefix: false }; +} + +export function stripWechatChannelPrefix(text) { + return parseWechatChannelPrefix(text).body; +} + +export function hasWechatPageChannelPrefix(text) { + return parseWechatChannelPrefix(text).channel === WECHAT_CHANNEL_PREFIX.PAGE; +} diff --git a/wechat/intent/channel-prefix.test.mjs b/wechat/intent/channel-prefix.test.mjs new file mode 100644 index 0000000..2acb9d6 --- /dev/null +++ b/wechat/intent/channel-prefix.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseWechatChannelPrefix, + stripWechatChannelPrefix, + WECHAT_CHANNEL_PREFIX, +} from './channel-prefix.mjs'; + +test('parseWechatChannelPrefix strips chat prefix', () => { + const parsed = parseWechatChannelPrefix('【聊天】上海明天天气怎么样'); + assert.equal(parsed.channel, WECHAT_CHANNEL_PREFIX.CHAT); + assert.equal(parsed.body, '上海明天天气怎么样'); +}); + +test('parseWechatChannelPrefix strips page refine prefix', () => { + const parsed = parseWechatChannelPrefix('【改页】public/demo.html 补充时间节点'); + assert.equal(parsed.channel, WECHAT_CHANNEL_PREFIX.PAGE); + assert.match(parsed.body, /public\/demo\.html/); +}); + +test('parseWechatChannelPrefix strips scheduled task prefix', () => { + const parsed = parseWechatChannelPrefix('【定时任务】列出我进行中的任务'); + assert.equal(parsed.channel, WECHAT_CHANNEL_PREFIX.SCHEDULED_TASK); + assert.equal(parsed.body, '列出我进行中的任务'); +}); + +test('stripWechatChannelPrefix leaves unprefixed text unchanged', () => { + assert.equal(stripWechatChannelPrefix('帮我做嵊泗攻略'), '帮我做嵊泗攻略'); +}); diff --git a/wechat/intent/classifier.mjs b/wechat/intent/classifier.mjs index 2437fe9..c6649db 100644 --- a/wechat/intent/classifier.mjs +++ b/wechat/intent/classifier.mjs @@ -1,3 +1,4 @@ +import { parseWechatChannelPrefix, WECHAT_CHANNEL_PREFIX } from './channel-prefix.mjs'; import { CONNECTIVITY_TEST_PATTERN, GREETING_PATTERN, @@ -43,27 +44,50 @@ import { WECHAT_SESSION_ACTION } from './session-action.mjs'; * @param {{ msgType?: string, agentText?: string, displayText?: string }} intent * @returns {WechatIntent} */ +function withChannelMeta(result, channelPrefix) { + if (!channelPrefix) return result; + return { ...result, channelPrefix }; +} + export function classifyWechatIntent(intent) { const msgType = String(intent?.msgType ?? 'text').toLowerCase(); - const text = String(intent?.agentText ?? intent?.displayText ?? '').trim(); + const rawText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); + const { channel: channelPrefix, body: text } = parseWechatChannelPrefix(rawText); if (msgType === 'text' || msgType === 'voice') { if (isTopicResetText(text)) { - return { kind: 'session.reset', sessionAction: WECHAT_SESSION_ACTION.RESET }; + return withChannelMeta( + { kind: 'session.reset', sessionAction: WECHAT_SESSION_ACTION.RESET }, + channelPrefix, + ); + } + if (STATUS_PROBE_PATTERN.test(text)) { + return withChannelMeta({ kind: 'status.probe' }, channelPrefix); + } + if (GREETING_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) { + return withChannelMeta({ kind: 'greeting' }, channelPrefix); } - if (STATUS_PROBE_PATTERN.test(text)) return { kind: 'status.probe' }; - if (GREETING_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) return { kind: 'greeting' }; if (CONNECTIVITY_TEST_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) { - return { kind: 'connectivity.test' }; + return withChannelMeta({ kind: 'connectivity.test' }, channelPrefix); } - if (isPageGenerateText(text)) { - return { + if (channelPrefix === WECHAT_CHANNEL_PREFIX.CHAT) { + return withChannelMeta({ kind: 'chat.general', text }, channelPrefix); + } + if (channelPrefix === WECHAT_CHANNEL_PREFIX.PAGE) { + return withChannelMeta({ kind: 'page.generate', topic: text, wantsDocx: wantsDocxDownload(text), - }; + }, channelPrefix); } - return { kind: 'chat.general', text }; + if (isPageGenerateText(text)) { + return withChannelMeta({ + kind: 'page.generate', + topic: text, + wantsDocx: wantsDocxDownload(text), + }, channelPrefix); + } + return withChannelMeta({ kind: 'chat.general', text }, channelPrefix); } if (msgType === 'event') { diff --git a/wechat/page-refine-target.mjs b/wechat/page-refine-target.mjs new file mode 100644 index 0000000..63bf8ae --- /dev/null +++ b/wechat/page-refine-target.mjs @@ -0,0 +1,69 @@ +import { + PAGE_MINDSPACE_PUBLIC_URL_PATTERN, + PAGE_REFINE_DETAIL_PATTERN, +} from './intent/patterns.mjs'; + +/** MindSpace public HTML path inside a full URL (userId or username slug). */ +export const MINDSPACE_PUBLIC_HTML_PATH_PATTERN = + /(?:https?:\/\/[^\s/]+)?\/?mindspace\/[^/\s"'<>]+?\/(public\/[\w.-]+\.html)/iu; + +export function extractMindSpacePublicHtmlPath(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return null; + const match = normalized.match(MINDSPACE_PUBLIC_HTML_PATH_PATTERN); + const relativePath = match?.[1]?.replace(/\\/g, '/').trim(); + return relativePath || null; +} + +export function isWechatPageRefineRequestText(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + if (PAGE_REFINE_DETAIL_PATTERN.test(normalized)) return true; + return ( + PAGE_MINDSPACE_PUBLIC_URL_PATTERN.test(normalized) + && /(?:页面|网页|更新|详细|改|完善|补充)/iu.test(normalized) + ); +} + +/** + * Extra Cursor/Goose page prompt block: pin edit target file when URL or refine intent is present. + */ +export function buildPageRefineTargetPromptBlock(text) { + const normalized = String(text ?? '').trim(); + if (!normalized || !isWechatPageRefineRequestText(normalized)) return ''; + + const relativePath = extractMindSpacePublicHtmlPath(normalized); + if (relativePath) { + return [ + '【改页目标文件(从用户消息解析,必须优先改此文件)】', + `- 相对路径:${relativePath}`, + '- 用 edit_file 更新该 HTML;禁止新建无关页面或只回复文字说明。', + '- 按用户要求补充/修改内容,保留其余仍有效的结构与样式。', + '', + ].join('\n'); + } + + return [ + '【改页任务】用户要求细化或更新已有页面。', + '- 在工作区查找最相关的 public/*.html,用 edit_file 更新原文件,不要新建无关页面。', + '- 禁止只回复文字说明而不落盘。', + '', + ].join('\n'); +} + +/** Copy-paste WeChat message template for page refine (e.g. Tang). */ +export function buildWechatPageRefineUserMessageTemplate({ + publicHtmlPath = 'public/your-page.html', + requirements = '请把页面改更详细(示例:行程细化到每个时间节点)', + publicUrl = '', +} = {}) { + const pathLine = String(publicHtmlPath ?? '').trim(); + const reqLine = String(requirements ?? '').trim(); + const urlLine = String(publicUrl ?? '').trim(); + return [ + '改页任务(请直接改原文件,不要新建页面)', + pathLine ? `文件:${pathLine}` : '', + reqLine ? `要求:${reqLine}` : '', + urlLine ? `链接:${urlLine}` : '', + ].filter(Boolean).join('\n'); +} diff --git a/wechat/page-refine-target.test.mjs b/wechat/page-refine-target.test.mjs new file mode 100644 index 0000000..09eb8da --- /dev/null +++ b/wechat/page-refine-target.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPageGenerateAgentPrompt } from './prompts/page-generate.mjs'; +import { + buildPageRefineTargetPromptBlock, + buildWechatPageRefineUserMessageTemplate, + extractMindSpacePublicHtmlPath, + isWechatPageRefineRequestText, +} from './page-refine-target.mjs'; + +test('extractMindSpacePublicHtmlPath parses MindSpace public URLs', () => { + assert.equal( + extractMindSpacePublicHtmlPath( + 'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/shengsi-3day-guide.html请改详细', + ), + 'public/shengsi-3day-guide.html', + ); + assert.equal(extractMindSpacePublicHtmlPath('public/demo.html'), null); +}); + +test('buildPageRefineTargetPromptBlock pins relative path for URL refine', () => { + const block = buildPageRefineTargetPromptBlock( + 'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/shengsi-3day-guide.html 页面更详细到时间节点', + ); + assert.match(block, /public\/shengsi-3day-guide\.html/); + assert.match(block, /edit_file/); +}); + +test('buildPageRefineTargetPromptBlock covers refine without URL', () => { + const block = buildPageRefineTargetPromptBlock('页面还不够详细,必须详细到时间节点'); + assert.match(block, /改页任务/); + assert.doesNotMatch(block, /相对路径:public\//); +}); + +test('buildPageGenerateAgentPrompt includes page refine target block', () => { + const prompt = buildPageGenerateAgentPrompt({ + agentText: 'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/shengsi-3day-guide.html 把页面更新更详细', + }); + assert.match(prompt, /改页目标文件/); + assert.match(prompt, /public\/shengsi-3day-guide\.html/); +}); + +test('buildWechatPageRefineUserMessageTemplate formats copy-paste message', () => { + const text = buildWechatPageRefineUserMessageTemplate({ + publicHtmlPath: 'public/shengsi-3day-guide.html', + requirements: '按 Day1/Day2/Day3 细化到具体时间点', + publicUrl: 'https://m.tkmind.cn/MindSpace/u/public/shengsi-3day-guide.html', + }); + assert.match(text, /public\/shengsi-3day-guide\.html/); + assert.match(text, /Day1/); + assert.match(text, /不要新建页面/); +}); + +test('isWechatPageRefineRequestText detects refine intents', () => { + assert.equal(isWechatPageRefineRequestText('页面还不够详细'), true); + assert.equal(isWechatPageRefineRequestText('帮我推荐跑步路线'), false); +}); diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index 12021ad..82d8061 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -4,6 +4,7 @@ import { isPageDataIntent, } from '../../chat-skills.mjs'; import { buildWechatPageImageInstruction } from '../image-generation-policy.mjs'; +import { buildPageRefineTargetPromptBlock } from '../page-refine-target.mjs'; /** * Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix. @@ -52,6 +53,7 @@ export function buildPageGenerateAgentPrompt( const coverExample = imageBlock ? '<本轮 generate_image 返回的 asset.htmlSrc>' : 'assets/hero.jpg'; + const pageRefineBlock = buildPageRefineTargetPromptBlock(topic); const pageDataBlock = pageDataTask ? [ '【Page Data 强制要求】这不是静态展示页。', @@ -72,6 +74,7 @@ export function buildPageGenerateAgentPrompt( imageBlock, immediateContextBlock, carriedSessionBlock, + pageRefineBlock, pageDataBlock, '步骤(必须全部完成):', pageDataTask