Compare commits

..

10 Commits

Author SHA1 Message Date
john 371900bae5 feat(mindspace): add page quota grace write and local delivery guardrails.
Memind CI / Test, build, and release guards (pull_request) Waiting to run
Centralize page HTML quota checks with grace-write semantics across page
services and workspace tools, keep localhost MindSpace links clickable in
chat display, and expand Page Data/static-page skill plus local dev docs
for quota, delivery URLs, and native Aider/OpenHands tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:44:26 +08:00
john b553107817 fix: make goosed remount health ignore canary
Memind CI / Test, build, and release guards (push) Failing after 1m25s
Stable remount checks required exactly 9 compose containers, but the
shared goosed-prod project also runs canary. Count only goosed-1..9 and
verify the /opt/portal bind plus a root marker under the live runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:34:28 +08:00
john 065b1588b7 fix: deliver WeChat pages when image_make fails
Fall back to workspace share thumbnails so already-published HTML can be
sent instead of blocking on REQUIRED_FRESH. Also allow an explicit
ALLOW_DIRECT_STABLE_RELEASE path matching the pre-2026-07-23 direct
stable packaging flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:19:29 +08:00
john 7eedda092d fix: broaden WeChat session page continuation phrasing
Memind CI / Test, build, and release guards (push) Successful in 2m37s
Recognize retry, HTML page, page edits, and poem content edits as session continuations so delivery skips fresh-thumbnail forcing and retains agent context for multi-turn page work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 16:54:42 +08:00
john 6307f974cf fix: treat decorated HTML page requests as WeChat immediate context
Memind CI / Test, build, and release guards (push) Successful in 4m32s
Match phrases like「生成一个精美的 HTML 页面」so poem follow-ups skip fresh-thumbnail forcing and reuse the same session poem context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 16:27:28 +08:00
john 491a62aed6 fix: require write_file for WeChat immediate-context page requests
Memind CI / Test, build, and release guards (push) Failing after 12m9s
Skip fresh thumbnail forcing and retry in-session when load_skill completes without HTML落盘, so「把刚才的诗做成页面」can deliver the prior poem as a static page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:27:23 +08:00
john e1ad094f93 fix: retain unlinked WeChat route for immediate-context page requests
Memind CI / Test, build, and release guards (push) Successful in 20m49s
Short follow-ups like "把刚才的诗做成页面" must keep the existing agent
session so Goose still sees the adjacent poem in snapshot history. Rotating
orphan routes before these requests dropped the poem and revived stale memory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:38:53 +08:00
john b10f0a7600 merge: guard WeChat page follow-up context
Memind CI / Test, build, and release guards (push) Successful in 4m46s
2026-07-28 11:54:02 +08:00
john 31cfdf7a94 fix: guard WeChat page follow-up context 2026-07-28 11:53:00 +08:00
john 9b68dc6b77 merge: fix WeChat runtime delivery path
Memind CI / Test, build, and release guards (push) Successful in 1m59s
2026-07-28 09:50:38 +08:00
30 changed files with 1686 additions and 295 deletions
+20
View File
@@ -65,6 +65,22 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_AGENT_RUN_WORKER_ID=<optional-instance-id>
# MEMIND_AGENT_RUN_WORKER_EXPECT_RUNNING=0
# 本机 Aider / OpenHandsColima 迁出后走 native CLI,见 memind-local-aider / memind-local-openhands
# AIDER_BIN=/Users/john/.local/bin/aider
# GOOSE_AIDER_BIN=/Users/john/.openhands/bin/studio-aider
# OPENHANDS_BIN=/Users/john/.local/bin/openhands
# GOOSE_AIDER_BIN=/Users/john/Project/Memind/scripts/aider-goose-wrapper.sh
# GOOSE_OPENHANDS_BIN=/Users/john/Project/Memind/scripts/openhands-goose-wrapper.sh
# GOOSE_OPENHANDS_RUNNER=host
# 本地开发:普通用户 role=user 默认开启 aider/openhands;生产保持 0
# MEMIND_ROLE_CODE_TOOLS_DEFAULT=0
# MEMIND_TOOL_GATEWAY_ENABLED=0
# MEMIND_TOOL_GATEWAY_DRY_RUN=0
# MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR=aider
# Orchestrator workermemind-local-orchestrator/.env 同步)OpenHands 进程执行器:
# MEMIND_EXECUTOR_OPENHANDS_COMMAND=/Users/john/Project/Memind/scripts/openhands-goose-wrapper.sh
# MEMIND_EXECUTOR_WORKSPACE_ROOT=/Users/john/Project/Memind
# LangGraph Orchestrator Shadow(默认完全不接入 Agent Run 请求路径)。
# 仅在独立 Orchestrator 已健康、service token 已配置后,先显式打开观察 wiring,
# 再通过 memindadm 选择 shadow。关闭 wiring 时不查询编排配置、不排队、不传用户数据。
@@ -331,6 +347,10 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
# MindSearch / standalone Deep Search
# ---------------------------------------------------------------------------
# Production SearXNG on 103 is bound to the host at 127.0.0.1:20080.
# Outbound proxy for DuckDuckGo/Yandex lives in searxng-runtime shared config:
# /Users/john/Project/searxng-runtime/shared/.env
# SEARXNG_OUTGOING_PROXY=http://127.0.0.1:1082 (Shadowrocket MacPacket)
# Template source: /Users/john/Project/searxng-prod/settings.yml.template
# Goosed MCP endpoints are automatically rewritten to host.docker.internal.
# TKMIND_SEARCH_SEARXNG_URL=http://127.0.0.1:20080/search
# TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal
+11 -2
View File
@@ -32,8 +32,17 @@ export function stripMindSpaceContextPrefix(text) {
return next;
}
const ASSISTANT_DELIVERABLE_RE =
/\[[^\]]+\]\(https?:\/\/[^)]+\)|https?:\/\/(?:m\.)?[^/\s]*tkmind\.(?:cn|ai)\/MindSpace\//i;
/** MindSpace 交付链接:生产域名与本地 dev127.0.0.1 / localhost)都必须可点击,不可被架构脱敏替换。 */
export const MINDSPACE_DELIVERABLE_ORIGIN_RE =
'(?:127\\.0\\.0\\.1|localhost):\\d+|(?:m\\.)?[^/\\s]*tkmind\\.(?:cn|ai)';
const ASSISTANT_DELIVERABLE_RE = new RegExp(
[
'\\[[^\\]]+\\]\\(https?:\\/\\/[^)]+\\)',
`https?:\\/\\/${MINDSPACE_DELIVERABLE_ORIGIN_RE}\\/MindSpace\\/`,
].join('|'),
'i',
);
const INTERNAL_ASSISTANT_MARKERS = [
/data-mindspace-page-tag/i,
+10
View File
@@ -80,3 +80,13 @@ test('deriveAssistantFacingText keeps user-facing replies with public links', ()
'页面已生成:[AI 机器人研究报告 2026](https://m.tkmind.cn/MindSpace/john/public/ai-robot-report.html)';
assert.equal(deriveAssistantFacingText(external), external);
});
test('deriveAssistantFacingText keeps local MindSpace deliverable links clickable', () => {
const localMarkdown =
'日记本就绪:[打开日记本](http://127.0.0.1:8081/MindSpace/32035858-9a20-425b-89da-c118ef0779aa/public/diary.html)';
assert.equal(deriveAssistantFacingText(localMarkdown), localMarkdown);
const localBare =
'本地预览:http://127.0.0.1:5173/MindSpace/john/public/survey.html';
assert.equal(deriveAssistantFacingText(localBare), localBare);
});
+5
View File
@@ -1,5 +1,10 @@
# OpenHands 安装说明
> **本机 Colima 已迁出(2026-07-29**。日常开发请优先使用
> [`/Users/john/Project/memind-local-openhands`](../memind-local-openhands/README.md)
> 与 [`memind-local-infra/MIGRATION.md`](/Users/john/Project/memind-local-infra/MIGRATION.md)。
> 下文 Colima compose 章节仅作历史参考。
> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 Goose / 路由策略。
>
> 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。
@@ -7,6 +7,16 @@ Page Data 页面只有在下面所有条件满足后才可向用户交付链接
3. page record、online publication 与 policy 使用同一个真实 page UUID。
4. 公开页完成一次受权限约束的 insert smoke;后台页完成 password auth 后的 read smoke。
## 页面空间配额(grace write
MindSpace **页面 HTML**`public/*.html``draft/*.html`)配额策略:
- **尚未超配额**但剩余空间不足:允许当次页面写完并完成 bind(grace write)。
- **已经超配额**`used_bytes + reserved_bytes >= quota_bytes`):下一次 `write_file` / `edit_file` / bind / 发布必须直接失败,提示用户先清理空间。
- grace **不覆盖**图片/附件等非页面 HTML 资产。
Agent 不得在空间已满时交付 Page Data 链接;不得只写 slug policy 文件而不完成 `private_data_bind_workspace_page`
`private_data_bind_workspace_page` 是硬门:它在创建 page / publication / policy 前,必须从 SQLite registry 派生权限。Agent 传入的策略不能凭空创建 dataset 或字段权限。
运行时路径必须按语义区分:
+1
View File
@@ -128,6 +128,7 @@ export function createMindSpaceLocalRuntimeServices({
syncWorkspaceAssets,
conversationArtifactService,
workspacePageDeliveryService,
getQuotaPool: () => pool,
});
const cleanupService = createCleanupService(pool, {
storageRoot: runtime.storageRoot,
+5 -7
View File
@@ -22,6 +22,7 @@ import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
const MAX_TITLE_LENGTH = 255;
const MAX_SUMMARY_LENGTH = 1000;
@@ -393,13 +394,10 @@ export function createPageService(pool, options = {}) {
if (!space || space.status !== 'active') {
throw pageError('用户空间不可用', 'space_unavailable');
}
const available =
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
if (available < normalized.contentBytes) {
throw pageError('剩余空间不足', 'quota_exceeded', {
requiredBytes: normalized.contentBytes,
availableBytes: Math.max(0, available),
});
try {
assertPageWriteQuota(space, normalized.contentBytes);
} catch (error) {
throw pageError(error.message, error.code ?? 'quota_exceeded', error.details);
}
const [categories] = await conn.query(
`SELECT id, category_code FROM h5_space_categories
+5 -9
View File
@@ -21,6 +21,7 @@ import {
inferWorkspaceHtmlRelativePath,
} from './mindspace-html-download-links.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
const SCANNER_VERSION = 'mindspace-content-v1';
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
@@ -723,15 +724,10 @@ export function createPublicationService(pool, options = {}) {
WHERE id = ? AND user_id = ? FOR UPDATE`,
[page.space_id, userId],
);
const available =
Number(spaces[0]?.quota_bytes ?? 0) -
Number(spaces[0]?.used_bytes ?? 0) -
Number(spaces[0]?.reserved_bytes ?? 0);
if (available < htmlBytes) {
throw publicationError('剩余空间不足', 'quota_exceeded', {
requiredBytes: htmlBytes,
availableBytes: Math.max(0, available),
});
try {
assertPageWriteQuota(spaces[0], htmlBytes);
} catch (error) {
throw publicationError(error.message, error.code ?? 'quota_exceeded', error.details);
}
const [publicationUsage] = await conn.query(
`SELECT COUNT(DISTINCT page_id) AS public_page_used,
+110
View File
@@ -0,0 +1,110 @@
function asQuotaNumber(value) {
return Number(value ?? 0);
}
export function computeUserSpaceQuotaStats(space = {}) {
const quotaBytes = asQuotaNumber(space.quota_bytes);
const usedBytes = asQuotaNumber(space.used_bytes);
const reservedBytes = asQuotaNumber(space.reserved_bytes);
const occupiedBytes = usedBytes + reservedBytes;
const availableBytes = quotaBytes - occupiedBytes;
return {
quotaBytes,
usedBytes,
reservedBytes,
occupiedBytes,
availableBytes,
isOverQuota: occupiedBytes >= quotaBytes,
};
}
/**
* Page write grace policy:
* - Already at/above quota: block the next page write entirely.
* - Below quota but insufficient headroom: allow this write (may exceed quota).
* - Sufficient headroom: allow normally.
*/
export function evaluatePageWriteQuota(space, requiredBytes) {
const stats = computeUserSpaceQuotaStats(space);
const required = Math.max(0, asQuotaNumber(requiredBytes));
if (stats.isOverQuota) {
return {
allowed: false,
grace: false,
stats,
requiredBytes: required,
reason: 'over_quota',
};
}
if (stats.availableBytes >= required) {
return {
allowed: true,
grace: false,
stats,
requiredBytes: required,
reason: 'sufficient',
};
}
return {
allowed: true,
grace: true,
stats,
requiredBytes: required,
reason: 'grace_write',
};
}
export function buildPageWriteQuotaError(
evaluation,
{ messagePrefix = '剩余空间不足' } = {},
) {
const { stats, requiredBytes, reason } = evaluation;
const message =
reason === 'over_quota'
? '空间已满,请先清理后再创建或修改页面'
: messagePrefix;
return Object.assign(new Error(message), {
code: 'quota_exceeded',
details: {
requiredBytes,
availableBytes: Math.max(0, stats.availableBytes),
usedBytes: stats.usedBytes,
quotaBytes: stats.quotaBytes,
overQuota: reason === 'over_quota',
graceWrite: reason === 'grace_write',
},
});
}
export function assertPageWriteQuota(space, requiredBytes) {
const evaluation = evaluatePageWriteQuota(space, requiredBytes);
if (!evaluation.allowed) {
throw buildPageWriteQuotaError(evaluation);
}
return evaluation;
}
export function isMindSpacePageWriteRelativePath(relativePath) {
const normalized = String(relativePath ?? '').replace(/\\/g, '/').trim();
if (!normalized.toLowerCase().endsWith('.html')) return false;
const zone = normalized.split('/')[0]?.toLowerCase();
return zone === 'public' || zone === 'draft';
}
export async function loadUserSpaceForQuota(pool, userId) {
if (!pool || !userId) return null;
const [rows] = await pool.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
[userId],
);
return rows[0] ?? null;
}
export async function assertPageWriteQuotaForUser(pool, userId, requiredBytes) {
const space = await loadUserSpaceForQuota(pool, userId);
if (!space || space.status !== 'active') {
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
}
return assertPageWriteQuota(space, requiredBytes);
}
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertPageWriteQuota,
evaluatePageWriteQuota,
isMindSpacePageWriteRelativePath,
} from './mindspace-space-quota.mjs';
const baseSpace = {
quota_bytes: 100,
used_bytes: 80,
reserved_bytes: 0,
};
test('evaluatePageWriteQuota allows normal writes within headroom', () => {
assert.deepEqual(evaluatePageWriteQuota(baseSpace, 15).reason, 'sufficient');
});
test('evaluatePageWriteQuota allows grace write when headroom is insufficient but not over quota', () => {
const result = evaluatePageWriteQuota(baseSpace, 30);
assert.equal(result.reason, 'grace_write');
assert.equal(result.allowed, true);
assert.equal(result.grace, true);
});
test('evaluatePageWriteQuota blocks next write after quota is exhausted', () => {
const over = { quota_bytes: 100, used_bytes: 100, reserved_bytes: 0 };
const result = evaluatePageWriteQuota(over, 1);
assert.equal(result.reason, 'over_quota');
assert.equal(result.allowed, false);
});
test('assertPageWriteQuota throws over-quota message for subsequent writes', () => {
const over = { quota_bytes: 100, used_bytes: 101, reserved_bytes: 0 };
assert.throws(
() => assertPageWriteQuota(over, 1),
(error) =>
error.code === 'quota_exceeded' &&
error.message.includes('空间已满') &&
error.details?.overQuota === true,
);
});
test('isMindSpacePageWriteRelativePath matches public and draft html only', () => {
assert.equal(isMindSpacePageWriteRelativePath('public/bug-tracker.html'), true);
assert.equal(isMindSpacePageWriteRelativePath('draft/report.html'), true);
assert.equal(isMindSpacePageWriteRelativePath('private/notes.html'), false);
assert.equal(isMindSpacePageWriteRelativePath('public/data.json'), false);
});
+106 -13
View File
@@ -514,6 +514,84 @@ function sanitizeFreshVerification(
};
}
function collectDeliverablePageArtifacts({
artifacts = [],
messages = [],
publishDir,
buildCanonicalUrl,
} = {}) {
const restored = restoreWorkspaceArtifacts({
artifacts,
publishDir,
buildCanonicalUrl,
});
if (restored.length > 0) return restored;
const writeArtifacts = extractPublicHtmlWriteArtifacts(
messages,
{ publishDir },
);
return restoreWorkspaceArtifacts({
artifacts: writeArtifacts.map((artifact) => ({
relativePath: artifact.relativePath,
})),
publishDir,
buildCanonicalUrl,
});
}
async function acceptWorkspaceThumbnailFallback({
artifacts = [],
publishDir,
} = {}) {
const eligible = artifacts.filter((artifact) => {
const localPath = String(artifact?.localPath ?? '').trim();
return localPath && fs.existsSync(localPath) && artifact?.auxiliary !== true;
});
if (eligible.length === 0) {
return { ok: false, reason: 'missing_page_artifact', matches: [] };
}
await Promise.all(
eligible.map((artifact) =>
ensureWorkspaceHtmlThumbnail(
publishDir,
artifact.relativePath,
),
),
);
const withThumbnails = eligible.filter((artifact) =>
fs.existsSync(
path.join(
publishDir,
workspaceThumbnailRelativePath(artifact.relativePath),
),
),
);
if (withThumbnails.length !== eligible.length) {
return {
ok: false,
reason: 'workspace_thumbnail_fallback_failed',
matches: [],
artifact: eligible.find((artifact) =>
!fs.existsSync(
path.join(
publishDir,
workspaceThumbnailRelativePath(artifact.relativePath),
),
),
),
};
}
return {
ok: true,
reason: 'workspace_thumbnail_fallback',
matches: withThumbnails.map((artifact) => ({
artifact,
image: null,
cover: null,
})),
};
}
export async function ensureWechatFreshPageThumbnailsAtWorkspace({
artifacts = [],
images = [],
@@ -522,12 +600,12 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
buildCanonicalUrl,
repairEnabled = false,
} = {}) {
const workspaceArtifacts =
restoreWorkspaceArtifacts({
artifacts,
publishDir,
buildCanonicalUrl,
});
let workspaceArtifacts = collectDeliverablePageArtifacts({
artifacts,
messages,
publishDir,
buildCanonicalUrl,
});
let verification =
verifyFreshWechatPageThumbnails(
workspaceArtifacts,
@@ -551,20 +629,35 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
verification.reason,
});
if (repair.ok) {
workspaceArtifacts = restoreWorkspaceArtifacts({
artifacts: workspaceArtifacts,
publishDir,
buildCanonicalUrl,
});
verification =
verifyFreshWechatPageThumbnails(
restoreWorkspaceArtifacts({
artifacts,
publishDir,
buildCanonicalUrl,
}),
workspaceArtifacts,
images,
);
}
}
// When image_make failed or returned nothing, still deliver pages that already
// landed on disk by generating the workspace share thumbnail sidecars.
if (
!verification.ok
&& ['fresh_image_not_generated', 'missing_generated_cover', 'cover_not_from_current_run'].includes(
String(verification.reason ?? ''),
)
&& workspaceArtifacts.length > 0
) {
verification = await acceptWorkspaceThumbnailFallback({
artifacts: workspaceArtifacts,
publishDir,
});
}
if (verification.ok) {
await Promise.all(
verification.matches.map((match) =>
(verification.matches ?? []).map((match) =>
ensureWorkspaceHtmlThumbnail(
publishDir,
match.artifact.relativePath,
@@ -579,7 +672,7 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
),
thumbnailRelativePaths:
verification.ok
? verification.matches.map((match) =>
? (verification.matches ?? []).map((match) =>
workspaceThumbnailRelativePath(
match.artifact.relativePath,
),
+38
View File
@@ -271,6 +271,44 @@ test('MindSpace verifies and renders fresh WeChat page thumbnails without return
);
});
test('MindSpace falls back to workspace thumbnails when fresh image_make is missing', async (t) => {
const publishDir = fs.mkdtempSync(
path.join(
os.tmpdir(),
'mindspace-wechat-thumbnail-fallback-',
),
);
t.after(() => {
fs.rmSync(publishDir, {
recursive: true,
force: true,
});
});
const relativePath = 'public/fallback.html';
const htmlPath = path.join(publishDir, relativePath);
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(
htmlPath,
pageHtml({ title: 'Fallback' }),
'utf8',
);
const result = await ensureWechatFreshPageThumbnailsAtWorkspace({
publishDir,
buildCanonicalUrl: canonical,
artifacts: [{ relativePath, exists: true }],
images: [],
});
assert.equal(result.ok, true);
assert.equal(result.reason, 'workspace_thumbnail_fallback');
assert.deepEqual(result.matchRelativePaths, [relativePath]);
assert.equal(
fs.existsSync(path.join(publishDir, 'public', 'fallback.thumbnail.svg')),
true,
);
});
test('MindSpace owns unambiguous cover repair before thumbnail delivery', async (t) => {
const publishDir = fs.mkdtempSync(
path.join(
+17 -8
View File
@@ -11,6 +11,7 @@ import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mj
import { assetInternals } from './mindspace-assets.mjs';
import { runBasicFileScan } from './mindspace-scan.mjs';
import { buildWorkspaceStorageKey } from './workspace-storage.mjs';
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']);
const SKIP_ZONE_DIR_NAMES = new Set([
@@ -186,10 +187,13 @@ export function createWorkspaceAssetSync({
if (!space || space.status !== 'active') {
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
}
const available =
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
if (available < buffer.length) {
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
try {
assertPageWriteQuota(space, buffer.length);
} catch (error) {
throw Object.assign(new Error(error.message), {
code: error.code ?? 'quota_exceeded',
details: error.details,
});
}
const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
@@ -346,10 +350,15 @@ export function createWorkspaceAssetSync({
};
}
const sizeDelta = buffer.length - asNumber(currentAsset.size_bytes);
const available =
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
if (sizeDelta > 0 && available < sizeDelta) {
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
if (sizeDelta > 0) {
try {
assertPageWriteQuota(space, sizeDelta);
} catch (error) {
throw Object.assign(new Error(error.message), {
code: error.code ?? 'quota_exceeded',
details: error.details,
});
}
}
const versionId = currentAsset.current_version_id;
if (!versionId) {
+35
View File
@@ -13,6 +13,10 @@ import {
import {
UPLOAD_ZONE_CODES,
} from './user-space.mjs';
import {
assertPageWriteQuotaForUser,
isMindSpacePageWriteRelativePath,
} from './mindspace-space-quota.mjs';
function workspaceToolError(message, code) {
return Object.assign(new Error(message), { code });
@@ -330,6 +334,9 @@ export function createMindSpaceWorkspaceToolService({
maxBinaryWriteBytes = 8 * 1024 * 1024,
maxGeneratedBinaryBytes =
40 * 1024 * 1024,
assertPageWriteQuotaForUserFn =
assertPageWriteQuotaForUser,
getQuotaPool = null,
} = {}) {
if (!h5Root) {
throw new Error(
@@ -395,6 +402,23 @@ export function createMindSpaceWorkspaceToolService({
};
};
const ensurePageWriteAllowed = async ({
userId,
relativePath,
requiredBytes,
}) => {
if (
!getQuotaPool ||
typeof assertPageWriteQuotaForUserFn !== 'function' ||
!isMindSpacePageWriteRelativePath(relativePath)
) {
return;
}
const pool = getQuotaPool();
if (!pool) return;
await assertPageWriteQuotaForUserFn(pool, userId, requiredBytes);
};
const registerWrite = async ({
scope,
relativePath,
@@ -523,6 +547,11 @@ export function createMindSpaceWorkspaceToolService({
content,
{ relativePath },
);
await ensurePageWriteAllowed({
userId: scope.userId,
relativePath,
requiredBytes: sizeBytes,
});
const target =
await resolveWritableWorkspacePath(
scope.workspaceRoot,
@@ -620,6 +649,12 @@ export function createMindSpaceWorkspaceToolService({
updated,
{ relativePath },
);
const originalSizeBytes = mimeSafeByteLength(original);
await ensurePageWriteAllowed({
userId: scope.userId,
relativePath,
requiredBytes: Math.max(0, sizeBytes - originalSizeBytes),
});
await fs.writeFile(target, updated, 'utf8');
const registration = await registerWrite({
scope,
+13
View File
@@ -18,3 +18,16 @@ test('page-data-collect skill pins deletion to the public client API', () => {
assert.match(skillText, /禁止发明 `softDeleteRows`/);
assert.match(skillText, /表必须包含 `deleted_at TIMESTAMPTZ`/);
});
test('page-data-collect skill documents page quota grace and delivery URLs', () => {
assert.match(skillText, /本次写完、下次再拦/);
assert.match(skillText, /127\.0\.0\.1:8081\/MindSpace/);
assert.match(skillText, /禁止.*本地服务/);
assert.match(skillText, /pageId.*UUID/);
});
test('page-data-collect skill requires independent table per delivery task', () => {
assert.match(skillText, /每个独立交付任务必须独立建表/);
assert.match(skillText, /禁止复用已有表/);
assert.match(skillText, /禁止.*为新的独立页面复用已有 dataset/);
});
+56
View File
@@ -0,0 +1,56 @@
{
"id": "wechat-poem-page-modify",
"name": "写诗→做页面→修改页面(H5 多轮)",
"description": "模拟服务号常见三回合:写诗、做成页面、修改页面内容",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录"
},
{
"action": "chat",
"label": "请求写诗",
"message": "帮我写一首关于七月午后的短诗,四段即可",
"expect": {
"assistantMinChars": 40,
"timeoutMs": 180000,
"replyKeywords": ["七月", "午"]
}
},
{
"action": "chat",
"label": "做成精美 HTML 页面",
"message": "生成一个精美的 HTML 页面",
"expect": {
"assistantMinChars": 20,
"timeoutMs": 300000,
"replyKeywords": ["七月", "午"],
"page": {
"keywords": ["七月", "午"],
"requirePublicLink": true,
"requireHttp200": true,
"requireMindspaceCover": true
}
}
},
{
"action": "chat",
"label": "修改页面标题",
"message": "把页面标题改成《七月的午后》",
"expect": {
"assistantMinChars": 10,
"timeoutMs": 300000,
"replyKeywords": ["七月"],
"page": {
"keywords": ["七月的午后", "七月"],
"requirePublicLink": true,
"requireHttp200": true
}
}
}
]
}
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/Users/john/Project/Memind"
ENV_SCRIPT="${ROOT_DIR}/scripts/executor-env.mjs"
AIDER_BIN_DEFAULT="/Users/john/.local/bin/aider"
AIDER_BIN="${AIDER_CLI_BIN:-${AIDER_BIN:-${AIDER_BIN_DEFAULT}}}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
if [[ -z "${NODE_BIN}" ]]; then
echo "node not found in PATH" >&2
exit 1
fi
if [[ ! -x "${AIDER_BIN}" ]]; then
echo "Aider CLI not executable: ${AIDER_BIN}" >&2
exit 1
fi
eval "$("${NODE_BIN}" "${ENV_SCRIPT}" aider --shell)"
exec "${AIDER_BIN}" "$@"
+8 -3
View File
@@ -78,17 +78,22 @@ try {
codeHasCodeTools: grantedTools.includes('aider') || grantedTools.includes('openhands'),
}));
const roleCodeToolsDefault = ['1', 'true', 'yes', 'on'].includes(
String(process.env.MEMIND_ROLE_CODE_TOOLS_DEFAULT ?? '').trim().toLowerCase(),
);
const expectedRoleCodeTools = roleCodeToolsDefault;
const ok = Boolean(
roleDefaults.aider === false &&
roleDefaults.openhands === false &&
roleDefaults.aider === expectedRoleCodeTools &&
roleDefaults.openhands === expectedRoleCodeTools &&
sampledUsers.every((user) => user.chatHasCodeTools === false) &&
sampledUsers.every((user) => user.codeHasCodeTools === true),
(roleCodeToolsDefault || sampledUsers.every((user) => user.codeHasCodeTools === true)),
);
console.log(JSON.stringify({
ok,
checkedAt: new Date().toISOString(),
roleDefaults,
roleCodeToolsDefault,
whitelistUserCount: grantsByUser.size,
sampledUsers,
runtimePolicy: {
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/**
* 本机代码工具能力修复role 默认 aider/openhands + 可选 canary 用户白名单
* 用法: MEMIND_ENV_FILE=.env node scripts/fix-openhands-local-link.mjs
*
* MEMIND_ROLE_CODE_TOOLS_DEFAULT=1 role=user 默认开启 aider/openhands本地开发
* 未设置或为 0 role=user 默认关闭生产基线
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { applyRoleSecurityBaseline } from '../security-baseline.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const CANARY_USER_ID = process.env.LOCAL_OPENHANDS_CANARY_USER_ID
?? '32035858-9a20-425b-89da-c118ef0779aa';
const CODE_TOOLS = ['aider', 'openhands'];
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx < 0) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
function roleCodeToolsDefaultEnabled() {
return ['1', 'true', 'yes', 'on'].includes(
String(process.env.MEMIND_ROLE_CODE_TOOLS_DEFAULT ?? '').trim().toLowerCase(),
);
}
loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(root, '.env'));
loadEnvFile(path.join(root, '.env.local'));
async function readRoleDefaults(pool) {
const [rows] = await pool.query(
`SELECT capability_key, allowed
FROM h5_capability_grants
WHERE subject_type = 'role'
AND subject_id = 'user'
AND capability_key IN ('aider', 'openhands')
ORDER BY capability_key`,
);
return Object.fromEntries(rows.map((row) => [row.capability_key, Boolean(row.allowed)]));
}
async function readUserGrants(pool, userId) {
const [rows] = await pool.query(
`SELECT capability_key, allowed
FROM h5_capability_grants
WHERE subject_type = 'user'
AND subject_id = ?
AND capability_key IN ('aider', 'openhands')
ORDER BY capability_key`,
[userId],
);
return Object.fromEntries(rows.map((row) => [row.capability_key, Boolean(row.allowed)]));
}
async function grantRoleTools(pool, role, tools, allowed) {
const now = Date.now();
for (const key of tools) {
await pool.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
[role, key, allowed ? 1 : 0, now],
);
}
}
async function grantUserTools(pool, userId, tools) {
const now = Date.now();
for (const key of tools) {
await pool.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('user', ?, ?, 1, ?)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
[userId, key, now],
);
}
}
const roleCodeToolsDefault = roleCodeToolsDefaultEnabled();
const pool = createDbPool();
try {
const beforeRole = await readRoleDefaults(pool);
const beforeUser = await readUserGrants(pool, CANARY_USER_ID);
if (roleCodeToolsDefault) {
await grantRoleTools(pool, 'user', CODE_TOOLS, true);
} else {
await applyRoleSecurityBaseline(pool, 'user');
await grantUserTools(pool, CANARY_USER_ID, CODE_TOOLS);
}
const afterRole = await readRoleDefaults(pool);
const afterUser = await readUserGrants(pool, CANARY_USER_ID);
const expectedRole = roleCodeToolsDefault;
console.log(JSON.stringify({
ok: afterRole.aider === expectedRole
&& afterRole.openhands === expectedRole
&& (!roleCodeToolsDefault || CODE_TOOLS.every((key) => afterRole[key] === true)),
roleCodeToolsDefault,
canaryUserId: CANARY_USER_ID,
before: { role: beforeRole, user: beforeUser },
after: { role: afterRole, user: afterUser },
}, null, 2));
} finally {
await pool.end();
}
+40 -14
View File
@@ -46,6 +46,11 @@ usage() {
6. 启动 Portal,保持旧端口 8081
7. 健康检查通过后重启 m.tkmind.cn 反向隧道 (105:19081 -> 103:8081)
8. 旧源码目录移入 archive,不再保留可运行 live 源码
环境变量:
ALLOW_DIRECT_STABLE_RELEASE=1
仅在用户明确批准按 7/23 前规则直接整包时使用:允许 --skip-tests
并跳过 canary 晋升证据与 Core+Impact Gate report。
EOF
}
@@ -68,9 +73,17 @@ while [[ $# -gt 0 ]]; do
shift
done
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
if [[ "${SKIP_TESTS}" -eq 1 ]]; then
echo "生产发布守门员禁止 --skip-tests。" >&2
exit 1
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
say "允许 --skip-testsALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则;测试须已在发布前手动跑过)"
else
echo "生产发布守门员禁止 --skip-tests。" >&2
exit 1
fi
fi
if [[ "${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" == "1" ]]; then
@@ -84,14 +97,14 @@ if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
fi
if [[ "${DRY_RUN}" -eq 0 ]]; then
say "验证 103 灰度晋升证据"
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}"
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
say "跳过灰度晋升证据(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)"
else
say "验证 103 灰度晋升证据"
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}"
fi
fi
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "缺少命令: $1" >&2
@@ -336,7 +349,9 @@ REMOTE
}
say "验证与当前 main 和 runtime artifact 绑定的 Gate report"
if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
say "跳过 Core+Impact Gate reportALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)"
elif ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
say "执行核心场景 + 变更影响域 Gate"
DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}"
if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then
@@ -351,8 +366,10 @@ if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME
IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
fi
node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}"
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
else
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
fi
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
if [[ "${DRY_RUN}" -ne 1 ]]; then
say "执行 103 只读预检"
@@ -415,7 +432,7 @@ remount_goosed_after_live_swap() {
local docker_bin="/opt/homebrew/bin/docker"
local compose_dir="/Users/john/Project/goosed-prod"
local compose_file="${compose_dir}/docker-compose.prod.yml"
local marker_rel="MindSpace/.goosed-remount-${RELEASE_ID}"
local marker_rel=".goosed-remount-${RELEASE_ID}"
local marker_host="${APP_DIR}/${marker_rel}"
local marker_value="${RELEASE_ID}-$(date +%s)"
local ready=0
@@ -425,7 +442,7 @@ remount_goosed_after_live_swap() {
return 1
fi
mkdir -p "${APP_DIR}/MindSpace"
mkdir -p "${APP_DIR}"
printf '%s\n' "${marker_value}" > "${marker_host}"
say "重建 goosed 容器以刷新 Portal/MindSpace bind mount"
@@ -441,7 +458,9 @@ remount_goosed_after_live_swap() {
local healthy=1
local containers=()
while IFS= read -r container; do
[[ -n "${container}" ]] && containers+=("${container}")
# Exclude canary/other services that share the compose project label.
[[ "${container}" =~ ^goosed-prod-[1-9]$ ]] || continue
containers+=("${container}")
done < <("${docker_bin}" ps \
--filter 'label=com.docker.compose.project=goosed-prod' \
--format '{{.Names}}')
@@ -458,8 +477,15 @@ remount_goosed_after_live_swap() {
healthy=0
continue
fi
local portal_source
portal_source="$("${docker_bin}" inspect "${container}" --format '{{range .Mounts}}{{if eq .Destination "/opt/portal"}}{{.Source}}{{end}}{{end}}' 2>/dev/null || true)"
if [[ "${portal_source}" != "${APP_DIR}" ]]; then
healthy=0
continue
fi
local observed
observed="$("${docker_bin}" exec "${container}" sh -lc "cat '${APP_DIR}/${marker_rel}'" 2>/dev/null || true)"
# Prefer /opt/portal marker (same bind as PORTAL_RUNTIME_DIR) over nested MindSpace.
observed="$("${docker_bin}" exec "${container}" sh -lc "cat '/opt/portal/${marker_rel}'" 2>/dev/null || true)"
if [[ "${observed}" != "${marker_value}" ]]; then
healthy=0
fi
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* Matrix test for WeChat page phrasing: intent, session continuation, delivery policy.
* Run: node scripts/verify-wechat-page-phrasing.mjs
*/
import assert from 'node:assert/strict';
import { classifyWechatIntent } from '../wechat/intent/classifier.mjs';
import {
isWechatSessionPageContinuation,
isWechatImmediateContextPageCreate,
} from '../wechat/intent/page-continuation.mjs';
import { resolveWechatImageGenerationPolicy, WECHAT_PAGE_THUMBNAIL_MODE } from '../wechat/image-generation-policy.mjs';
import {
isWechatImmediateContextFollowup,
shouldDeliverWechatHtmlArtifacts,
} from '../wechat-mp.mjs';
import { buildPageGenerateAgentPrompt } from '../wechat/prompts/page-generate.mjs';
import { buildWechatAgentPrompt } from '../wechat/prompts/chat-general.mjs';
const cases = [
{
label: '完整单条页面需求',
text: '帮我做一个上海旅游攻略页面',
expect: {
kind: 'page.generate',
continuation: false,
freshThumbnail: true,
deliverHtml: true,
},
},
{
label: '写诗(普通聊天)',
text: '帮我写一首诗吧',
expect: {
kind: 'chat.general',
continuation: false,
freshThumbnail: false,
deliverHtml: false,
},
},
{
label: '短指代做成页面',
text: '把刚才的诗做成页面',
expect: {
kind: 'page.generate',
continuation: true,
freshThumbnail: false,
deliverHtml: true,
},
},
{
label: '精美 HTML 页面',
text: '生成一个精美的 HTML 页面',
expect: {
kind: 'page.generate',
continuation: true,
freshThumbnail: false,
deliverHtml: true,
},
},
{
label: '重试上次页面',
text: '重试上次页面',
expect: {
kind: 'page.generate',
continuation: true,
freshThumbnail: false,
deliverHtml: true,
},
},
{
label: '修改刚才页面样式',
text: '修改刚才生成的页面,背景改成浅蓝色',
expect: {
kind: 'page.generate',
continuation: true,
freshThumbnail: false,
deliverHtml: true,
},
},
{
label: '改页面标题',
text: '把页面标题改成《七月清晨》',
expect: {
kind: 'page.generate',
continuation: true,
freshThumbnail: false,
deliverHtml: true,
},
},
{
label: '改诗内容(会话续作)',
text: '把诗里第三段改长一点',
expect: {
kind: 'chat.general',
continuation: true,
freshThumbnail: false,
deliverHtml: true,
},
},
];
let passed = 0;
let failed = 0;
for (const item of cases) {
const intent = { msgType: 'text', agentText: item.text, displayText: item.text };
const wechatIntent = classifyWechatIntent(intent);
const continuation = isWechatSessionPageContinuation(wechatIntent, item.text);
const policy = resolveWechatImageGenerationPolicy({
text: item.text,
isPageGenerate: wechatIntent.kind === 'page.generate',
requireFreshPageThumbnail: continuation ? false : true,
});
const deliverHtml = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent);
const errors = [];
if (wechatIntent.kind !== item.expect.kind) {
errors.push(`kind expected ${item.expect.kind}, got ${wechatIntent.kind}`);
}
if (continuation !== item.expect.continuation) {
errors.push(`continuation expected ${item.expect.continuation}, got ${continuation}`);
}
const freshRequired = policy.pageThumbnailMode === WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH;
if (freshRequired !== item.expect.freshThumbnail) {
errors.push(`freshThumbnail expected ${item.expect.freshThumbnail}, got ${freshRequired}`);
}
if (deliverHtml !== item.expect.deliverHtml) {
errors.push(`deliverHtml expected ${item.expect.deliverHtml}, got ${deliverHtml}`);
}
if (item.expect.continuation && wechatIntent.kind === 'page.generate') {
const prompt = buildPageGenerateAgentPrompt(intent, {
preferImmediateContext: continuation,
imagePolicy: policy,
});
if (!/即时上下文优先/.test(prompt)) errors.push('page prompt missing immediate context block');
if (!/write_file 硬门槛/.test(prompt)) errors.push('page prompt missing write_file gate');
}
if (item.expect.continuation && wechatIntent.kind === 'chat.general') {
const prompt = buildWechatAgentPrompt(intent, { preferSessionPageContinuation: continuation });
if (!/会话页面续作/.test(prompt)) errors.push('chat prompt missing session continuation block');
}
if (errors.length) {
failed += 1;
console.error(`${item.label}: ${errors.join('; ')}`);
} else {
passed += 1;
console.log(`${item.label}`);
}
}
assert.equal(isWechatImmediateContextFollowup({ kind: 'page.generate' }, '把刚才的诗做成页面'), true);
assert.equal(isWechatImmediateContextPageCreate({ kind: 'page.generate' }, '生成一个精美的 HTML 页面'), true);
console.log(`\n=== 汇总: ${passed}/${cases.length} 话术矩阵通过 ===`);
if (failed > 0) process.exit(1);
+58 -9
View File
@@ -130,6 +130,16 @@ load_skill → page-data-collect
### 2. 数据层:建表 + 注册 dataset
**每个独立交付任务必须独立建表、独立 dataset,禁止复用已有表。**
| 场景 | 正确做法 |
|------|----------|
| 用户要做一个**新页面 / 新功能**(即使主题相似,如第二个日记本、另一个问卷) | 新建专用表 + 新 dataset 名,例如 `safe_diary_entries``children_hobby_survey_20260729` |
| 用户明确说「在**这个已有页面**里加一块表单」 | 才可复用该页已 bind 的 dataset |
| Agent 在 registry 里看到同名/同主题旧表 | **不得**直接拿来给新 HTML 用;必须新建 |
命名建议:`{页面slug}_entries` / `{页面slug}_responses`,表名与 dataset 名一致。两个页面即使业务相似(日记、台账、问卷),也必须是**两套** `{table, dataset, policy}`,避免字段约束、口令策略、软删除配置互相污染。
`private_data_execute` 建表(示例):
```sql
@@ -164,6 +174,25 @@ CREATE TABLE IF NOT EXISTS survey_responses (
- PostgreSQL 连接错误应原样报告,不得把 `/tmp/.s.PGSQL.*``ECONNREFUSED``permission denied` 解释成“稍后会自动恢复”。
- dataset 配置了 `soft_delete` 时,表必须包含 `deleted_at TIMESTAMPTZ`;配置了 `own_rows` 时,表必须包含 policy 指定的所有者字段。
### 用户空间配额(页面写入 grace 策略)
MindSpace **页面 HTML**`public/*.html``draft/*.html`)写入采用 **「本次写完、下次再拦」**
| 状态 | 平台行为 |
|------|----------|
| 尚未超配额,但剩余空间 < 本次页面大小 | ✅ **允许写完**grace write;本次 bind 必须继续完成) |
| 已经超配额(used ≥ quota | ❌ **直接拦截** `write_file` / `edit_file` / bind / 发布 |
| 空间充足 | ✅ 正常写入 |
**Agent 必须遵守:**
- grace 只保证**当次**页面 HTML 能落盘并完成 bind;**不是**可以跳过 bind 或只写 policy 文件。
- bind 失败(含 `quota_exceeded` 且已超配额、`dataset_schema_mismatch`、PG 连接错误)时,**禁止**向用户交付「已就绪」链接;必须如实报告失败原因。
- 用户空间已满时,提示其在 MindSpace「我的空间」清理旧页面/资产,或购买/扩容后再继续写新页面。
- grace **不覆盖**图片/附件等非页面 HTML 资产上传;那些仍按原有配额硬拦截。
**禁止**只写 `.mindspace/page-data-policies/<slug>.json` 或使用非 UUID 的 `pageId` 冒充 bind 成功;policy 的 `pageId` 必须来自 `private_data_bind_workspace_page` 返回的真实 UUID,且数据库中存在对应 `h5_page_records`
### 3. 页面层:写 HTML
- 用 `write_file` / `edit_file` 写入或更新 `public/*.html`
@@ -234,9 +263,18 @@ await client.deleteRow('dataset_name', rowId);
}
```
返回 `pageId` **`workspaceUrl`**`/MindSpace/<用户ID>/public/xxx.html`)。
返回 `pageId`UUID**`workspaceUrl` / `deliveryUrl`**`/MindSpace/<用户ID>/public/xxx.html`)。
**交付时必须优先给用户 workspaceUrl**`/u/用户名/pages/...` 仅作补充。bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。
**交付时必须优先给用户真实可点击 URL**
| 环境 | 链接前缀示例 |
|------|-------------|
| **本地开发** | `http://127.0.0.1:8081/MindSpace/<用户ID>/public/xxx.html`Page Data API 走 Portal 80815173 仅 UI 预览) |
| **生产** | `https://m.tkmind.cn/MindSpace/<用户ID>/public/xxx.html` |
- **禁止**交付占位 host(如 `http://本地服务/...`);聊天展示层不得把 `127.0.0.1:8081` 替换成不可点击文字。
- `/u/用户名/pages/...` 仅作补充,用户可见主链接必须是 MindSpace `/public/` 路径。
- bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。
**dataset 名称必须与 HTML 一致**`private_data_register_dataset``name``private_data_bind_workspace_page``datasets` 键名、以及 HTML 里 `insertRow('...')` / `listRows('...')` 的字符串必须完全相同(例如都用 `tkmind_exp_survey`)。若不一致,提交会报「dataset 未授权 insert/read」。
@@ -293,17 +331,28 @@ await client.deleteRow('dataset_name', rowId);
11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
12. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
13. **禁止**在 Agent 生成的 HTML/JavaScript 中使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据或做 API fallback;所有需持久化的数据必须进入当前用户专属 PostgreSQL schema,禁止 SQLite、静态 JSON/JS 文件和内存 fallback
14. **禁止**在空间已满(已超配额)时继续 `write_file` / bind 新页面;必须先提示清理或扩容
15. **禁止**bind 未完成或 `pageId` 非 UUID 时声称 Page Data 页面已就绪;控制台出现 `pageId 未配置` 说明 bind/发布链路未闭环
16. **禁止**为新的独立页面复用已有 dataset / 表(例如第二个日记页继续用 `diary_entries`);除非用户明确要求改同一页面上的表单
## 交付前自检
0. 已选定分支 A~E,方案摘要已确认;`password` 页口令 ≥8 位且 bind 已传入
1. `__page_data_datasets` 中存在对应 dataset
2. `.mindspace/page-data-policies/<pageId>.json` 已写入
3. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
4. HTML **不含** `127.0.0.1:``/api/survey/``PLACEHOLDER_PAGE_ID`
5. 向用户说明:访客如何提交、管理员如何用口令查看记录
6. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)`
1. `private_data_register_dataset` 已注册;PostgreSQL 表字段与 policy 一致(含 `deleted_at` 等)
2. `private_data_bind_workspace_page` **已成功**,返回 UUID 形式 `pageId``deliveryUrl`
3. `.mindspace/page-data-policies/<pageId>.json` 已写入(文件名必须是 UUID,不是 slug)
4. 通过 Portal 打开 `deliveryUrl` 时,页面源码含 `__MINDSPACE_PAGE_DATA__`
5. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
6. HTML **不含**硬编码 `127.0.0.1:` 作为 API 基址;不含 `/api/survey/``PLACEHOLDER_PAGE_ID`
7. 本地交付链接使用 `http://127.0.0.1:8081/MindSpace/...`;生产使用 `https://m.tkmind.cn/MindSpace/...`;禁止 `http://本地服务/...`
8. 向用户说明:访客如何提交、管理员如何用口令查看记录
9. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)`
10. 若 bind 报 `quota_exceeded``overQuota: true`,停止交付并提示清理空间;grace 仅适用于「当次写完、尚未超配额」场景
## 回复格式
除数据能力外,优先返回 **workspaceUrl** 的 Markdown 链接 `[标题](workspaceUrl)`,并简要说明后台入口与口令(如有)。
除数据能力外,优先返回 **deliveryUrl / workspaceUrl** 的 Markdown 链接 `[标题](完整URL)`,并简要说明后台入口与口令(如有)。
- 本地:`http://127.0.0.1:8081/MindSpace/<用户ID>/public/xxx.html`
- 生产:`https://m.tkmind.cn/MindSpace/<用户ID>/public/xxx.html`
- 必须附带 bind 返回的 `pageId`(UUID),便于用户在「页面数据」面板排查
+5 -1
View File
@@ -29,6 +29,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
12. **微信分享必须使用 MindSpace 路径**:该路由会在服务端注入 `og:site_name=TKMind 智趣`、封面缩略图(`*.thumbnail.png`)与微信 JS-SDK 分享桥;禁止交付 `/u/用户名/pages/...` 作为用户可见链接
13. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据;必须立即 `load_skill``page-data-collect`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。
14. 当编排提示为“图片策略:强制生成”或用户明确要求新图片、背景图、主图、封面、插画、照片时,必须先 `load_skill``image-generation`,由该技能补齐提示词并调用 `sandbox-fs__generate_image`;不得要求用户自己提供专业生图参数,不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。
15. **页面 HTML 空间配额**:尚未超配额时,平台允许当次页面写完(grace write);**已超配额**后 `write_file` / `edit_file` 会被直接拦截。空间已满时提示用户清理 MindSpace 旧页面/资产后再继续。
详细约束以工作区内的 `.goosehints``.agents/skills/static-page-publish/SKILL.md` 为准。
@@ -74,7 +75,10 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
- **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」
- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致)
- 域名严格按本节模板拼接(`https://goo.tkmind.cn/MindSpace/<用户ID>/public/...`);拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名
- 域名严格按环境拼接,**禁止**使用 `http://本地服务/...` 等占位 host
- **本地开发**`http://127.0.0.1:8081/MindSpace/<用户ID>/public/...`MindSpace 公开页 + Page Data API
- **生产**`https://m.tkmind.cn/MindSpace/<用户ID>/public/...` 或环境配置的 `H5_PUBLIC_BASE_URL`
- 拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名
- 标题用页面真实主题名
- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`
- 说明:保存即生效,无需重启
+354 -165
View File
@@ -31,6 +31,11 @@ import {
isPageDataDevIntent,
isPageDataIntent,
} from './chat-skills.mjs';
import {
isWechatImmediateContextPageCreate,
isWechatSessionPageContinuation,
isWechatContentEditFollowup,
} from './wechat/intent/page-continuation.mjs';
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
import {
buildWechatImageRunMetadata,
@@ -40,6 +45,7 @@ import {
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import {
buildPageGenerateAgentPrompt,
buildImmediateContextPageRepairPrompt,
buildPagePublishFailureText,
} from './wechat/prompts/page-generate.mjs';
import {
@@ -974,6 +980,27 @@ export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) {
);
}
export function isWechatImmediateContextFollowup(wechatIntent, text) {
return isWechatImmediateContextPageCreate(wechatIntent, text);
}
export { isWechatSessionPageContinuation };
export function shouldRotateUnlinkedWechatRoute({
routeUpdatedAt,
wechatMessageCount,
snapshotMessageCount,
now = Date.now(),
} = {}) {
const updatedAt = Number(routeUpdatedAt ?? 0);
return (
updatedAt > 0 &&
Number(now) - updatedAt > 60_000 &&
Number(wechatMessageCount) === 0 &&
Number(snapshotMessageCount ?? 0) > 0
);
}
export function isWechatPageDataTask(text) {
return isPageDataIntent(text) || isPageDataDevIntent(text);
}
@@ -994,10 +1021,12 @@ export function isRecoverableWechatAgentSessionError(message) {
}
export function shouldDeliverWechatHtmlArtifacts(wechatIntent, intent) {
const text = String(intent?.agentText ?? intent?.displayText ?? '').trim();
return (
wechatIntent?.kind === 'page.generate'
|| looksLikeHtmlGenerationIntent(intent?.agentText)
|| isPageDataIntent(intent?.agentText)
|| looksLikeHtmlGenerationIntent(text)
|| isPageDataIntent(text)
|| (wechatIntent?.kind === 'chat.general' && isWechatContentEditFollowup(text))
);
}
@@ -1837,7 +1866,9 @@ export function createWechatMpService({
imagePolicy,
notifyFailure = true,
}) => {
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) {
return { ok: true, reason: null, matchRelativePaths: [] };
}
const images = collectWechatGeneratedImages(replyRequestMessages(reply));
const ensureFresh =
htmlDeliveryAuthority
@@ -1884,7 +1915,15 @@ export function createWechatMpService({
);
}
if (verification?.ok) {
return;
if (verification.reason === 'workspace_thumbnail_fallback') {
logger.info?.(
'WeChat MP page delivery accepted workspace thumbnail fallback:',
{
matchRelativePaths: verification.matchRelativePaths ?? [],
},
);
}
return verification;
}
logger.warn?.(
'WeChat MP fresh thumbnail verification failed:',
@@ -1960,6 +1999,7 @@ export function createWechatMpService({
userId,
openid,
forceNew = false,
retainUnlinkedRoute = false,
userContext = null,
}) => {
if (forceNew) {
@@ -1978,42 +2018,58 @@ export function createWechatMpService({
config.sessionIdleRotateMs > 0 &&
routeUpdatedAt > 0 &&
now - routeUpdatedAt > config.sessionIdleRotateMs;
let routeWechatMessageCount = null;
let routeSnapshotMessageCount = null;
let routeMessageCount = 0;
if (config.sessionMessageRotateCount > 0) {
const counts = [];
if (typeof userAuth.countWechatAgentSessionMessages === 'function') {
counts.push(
userAuth
.countWechatAgentSessionMessages({
appId: config.appId,
openid,
agentSessionId: existingRoute.agentSessionId,
})
.catch((err) => {
logger.warn?.('WeChat MP route message count lookup failed:', err);
return 0;
}),
);
}
if (typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function') {
counts.push(
userAuth
.getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId)
.catch((err) => {
logger.warn?.('WeChat MP snapshot message count lookup failed:', err);
return 0;
}),
);
}
if (counts.length > 0) {
const resolved = await Promise.all(counts);
routeMessageCount = Math.max(0, ...resolved.map((value) => Number(value) || 0));
}
[routeWechatMessageCount, routeSnapshotMessageCount] = await Promise.all([
typeof userAuth.countWechatAgentSessionMessages === 'function'
? userAuth
.countWechatAgentSessionMessages({
appId: config.appId,
openid,
agentSessionId: existingRoute.agentSessionId,
})
.then((value) => Number(value) || 0)
.catch((err) => {
logger.warn?.('WeChat MP route message count lookup failed:', err);
return null;
})
: null,
typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function'
? userAuth
.getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId)
.then((value) => Number(value) || 0)
.catch((err) => {
logger.warn?.('WeChat MP snapshot message count lookup failed:', err);
return null;
})
: null,
]);
routeMessageCount = Math.max(
0,
Number(routeWechatMessageCount) || 0,
Number(routeSnapshotMessageCount) || 0,
);
}
const routeIsTooLong =
config.sessionMessageRotateCount > 0 &&
Number(routeMessageCount ?? 0) >= config.sessionMessageRotateCount;
if (routeIsIdle || routeIsTooLong) {
const routeHasUnlinkedConversation = shouldRotateUnlinkedWechatRoute({
routeUpdatedAt,
wechatMessageCount: routeWechatMessageCount,
snapshotMessageCount: routeSnapshotMessageCount,
now,
});
const shouldRotateUnlinkedRoute =
routeHasUnlinkedConversation && !retainUnlinkedRoute;
if (routeIsIdle || routeIsTooLong || shouldRotateUnlinkedRoute) {
if (shouldRotateUnlinkedRoute) {
logger.warn?.('WeChat MP orphaned route rotated before reuse:', {
agentSessionId: existingRoute.agentSessionId,
snapshotMessageCount: routeSnapshotMessageCount,
});
}
await userAuth.clearWechatAgentRoute(config.appId, openid);
rememberedWechatContexts.delete(existingRoute.agentSessionId);
} else {
@@ -2271,7 +2327,12 @@ export function createWechatMpService({
}
};
const prepareWechatAgentUserMessage = async ({ userId, sessionId, userMessage }) => {
const prepareWechatAgentUserMessage = async ({
userId,
sessionId,
userMessage,
preserveAgentPrompt = false,
}) => {
if (
!chatIntentRouter?.resolveAgentMemoryContext
|| !chatIntentRouter?.applyAgentOrchestration
@@ -2296,8 +2357,17 @@ export function createWechatMpService({
) {
return userMessage;
}
return chatIntentRouter.applyAgentOrchestration(
userMessage,
const orchestrationMessage = preserveAgentPrompt
? {
...userMessage,
metadata: {
...(userMessage?.metadata ?? {}),
displayText: messageVisibleText(userMessage),
},
}
: userMessage;
const prepared = chatIntentRouter.applyAgentOrchestration(
orchestrationMessage,
{
route: 'agent_orchestration',
reason: '微信服务号消息由 Agent 处理',
@@ -2305,6 +2375,14 @@ export function createWechatMpService({
},
{ memoryContext },
);
if (!preserveAgentPrompt) return prepared;
return {
...prepared,
metadata: {
...(prepared?.metadata ?? {}),
displayText: displayText || undefined,
},
};
} catch (err) {
logger.warn?.(
'WeChat MP agent memory resolve skipped:',
@@ -2361,22 +2439,26 @@ export function createWechatMpService({
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
const isPageDataRequest = isWechatPageDataTask(resetCandidate);
const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent);
const sessionPageContinuation =
isWechatSessionPageContinuation(wechatIntent, resetCandidate);
const imagePolicy = resolveWechatImageGenerationPolicy({
text: resetCandidate,
isPageGenerate: wechatIntent.kind === 'page.generate',
requireFreshPageThumbnail: config.requireFreshPageThumbnail,
requireFreshPageThumbnail:
config.requireFreshPageThumbnail && !sessionPageContinuation,
});
// Page Data delivery owns persistent files, datasets and two publication
// policies. Reusing a conversational route here can make a new request
// inspect/retry unrelated historical pages from that session.
const isPageDataRequest = isWechatPageDataTask(resetCandidate);
const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent);
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
let route = await ensureWechatAgentSession({
userId: user.userId,
openid: inbound.fromUserName,
userContext: user,
forceNew,
retainUnlinkedRoute: sessionPageContinuation,
});
let sessionId = route.sessionId;
await ensureSessionProvider(sessionId);
@@ -2417,136 +2499,171 @@ export function createWechatMpService({
? buildPageGenerateAgentPrompt(intent, {
wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
preferImmediateContext: sessionPageContinuation,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
requestId,
agentPrompt,
buildIntentMetadata(intent, {
mediaAnalysisEnabled,
imagePolicy,
pgRequired: isPageDataRequest,
}),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
sessionId,
userMessage,
: buildWechatAgentPrompt(intent, {
imagePolicy,
preferSessionPageContinuation: sessionPageContinuation,
});
const maxImmediateContextPageAttempts =
wechatIntent.kind === 'page.generate' && sessionPageContinuation ? 2 : 1;
let reply;
let publishArtifacts = [];
let confirmedArtifacts = [];
let verifiedArtifacts = [];
let generatedImages = [];
let linkExistsForRequest = linkExists;
for (let pageAttempt = 1; pageAttempt <= maxImmediateContextPageAttempts; pageAttempt += 1) {
const activeAgentPrompt =
pageAttempt === 1 ? agentPrompt : buildImmediateContextPageRepairPrompt(intent);
const activeRequestId = pageAttempt === 1 ? requestId : crypto.randomUUID();
reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
activeRequestId,
activeAgentPrompt,
buildIntentMetadata(intent, {
mediaAnalysisEnabled,
imagePolicy,
pgRequired: isPageDataRequest,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
userId: user.userId,
sessionId,
requestId: replyRequestId,
userMessage,
options: { requireHistoricalImageIsolation: true },
})
: null,
timeoutMs: agentReplyTimeoutMs,
},
);
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
throw error;
}
const {
publishedArtifacts,
verifiedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts,
validReplyUrls,
hasValidReplyLink,
} = await resolveHtmlPublishArtifacts({
reply,
intent,
requestStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: htmlArtifactDeliveryExpected,
htmlDeliveryAuthority,
});
const linkExistsForRequest =
createPreparedPublicHtmlLinkExists({
userId: user.userId,
prepared: {
validReplyUrls,
confirmedArtifacts,
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
sessionId,
userMessage,
preserveAgentPrompt: sessionPageContinuation,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
userId: user.userId,
sessionId,
requestId: replyRequestId,
userMessage,
options: { requireHistoricalImageIsolation: true },
})
: null,
timeoutMs: agentReplyTimeoutMs,
},
fallback: linkExists,
});
const hasValidLinkInReply =
hasValidReplyLink ||
await hasAnyValidPublishedHtmlLink(
reply?.text,
linkExistsForRequest,
{ confirmedArtifacts },
);
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
const suspiciousPublishClaim =
expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }));
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts,
hasValidLinkInReply,
});
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
let publishArtifacts =
htmlArtifactDeliveryExpected || publishedArtifacts.length > 0
? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts })
: [];
if (wechatIntent.kind === 'page.generate') {
const pageOutcome = resolvePageGenerateOutcome({
generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
throw error;
}
const {
publishedArtifacts,
expectedArtifacts,
recentArtifacts,
confirmedArtifacts: resolvedConfirmedArtifacts,
verifiedArtifacts: resolvedVerifiedArtifacts,
validReplyUrls,
hasValidReplyLink,
} = await resolveHtmlPublishArtifacts({
reply,
confirmedArtifacts,
verifiedArtifacts,
suspiciousPublishClaim,
bareCompletionReply,
htmlGenerationNeedsRetry,
replyHasPublicLinks,
intent,
requestStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: htmlArtifactDeliveryExpected,
htmlDeliveryAuthority,
});
if (pageOutcome.action === 'session_retry') {
confirmedArtifacts = resolvedConfirmedArtifacts;
verifiedArtifacts = resolvedVerifiedArtifacts;
linkExistsForRequest =
createPreparedPublicHtmlLinkExists({
userId: user.userId,
prepared: {
validReplyUrls,
confirmedArtifacts,
},
fallback: linkExists,
});
const hasValidLinkInReply =
hasValidReplyLink ||
await hasAnyValidPublishedHtmlLink(
reply?.text,
linkExistsForRequest,
{ confirmedArtifacts },
);
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text);
const suspiciousPublishClaim =
expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }));
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts,
hasValidLinkInReply,
});
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
publishArtifacts =
htmlArtifactDeliveryExpected || publishedArtifacts.length > 0
? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts })
: [];
if (wechatIntent.kind === 'page.generate') {
const pageOutcome = resolvePageGenerateOutcome({
reply,
confirmedArtifacts,
verifiedArtifacts,
suspiciousPublishClaim,
bareCompletionReply,
htmlGenerationNeedsRetry,
replyHasPublicLinks,
});
if (pageOutcome.action === 'session_retry') {
throw new Error('stale_session_poisoned_completion');
}
if (pageOutcome.action === 'fail') {
if (
pageAttempt < maxImmediateContextPageAttempts &&
pageOutcome.reason === 'skill_or_stub'
) {
logger.warn?.('WeChat MP immediate-context page repair retry:', {
sessionId,
userId: user.userId,
pageAttempt,
});
continue;
}
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page generate failure notice failed:', sendErr);
}
throw markWechatUserNotified(new Error(text));
}
publishArtifacts = pageOutcome.artifacts ?? publishArtifacts;
break;
}
if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
if (
suspiciousPublishClaim ||
bareCompletionReply ||
(htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0)
) {
throw new Error('stale_session_poisoned_completion');
}
if (htmlGenerationNeedsRetry) {
const text = buildHtmlPublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP html publish failure notice failed:', sendErr);
}
throw markWechatUserNotified(new Error(text));
}
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error('stale_session_poisoned_completion');
}
if (pageOutcome.action === 'fail') {
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page generate failure notice failed:', sendErr);
}
throw markWechatUserNotified(new Error(text));
}
publishArtifacts = pageOutcome.artifacts ?? publishArtifacts;
} else if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
if (
suspiciousPublishClaim ||
bareCompletionReply ||
(htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0)
) {
throw new Error('stale_session_poisoned_completion');
}
if (htmlGenerationNeedsRetry) {
const text = buildHtmlPublishFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP html publish failure notice failed:', sendErr);
}
throw markWechatUserNotified(new Error(text));
}
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error('stale_session_poisoned_completion');
break;
}
await reviewWechatPageDataArtifacts({
user,
@@ -2559,8 +2676,13 @@ export function createWechatMpService({
forcePageData: isPageDataRequest,
});
if (wechatIntent.kind === 'page.generate') {
const deliveryArtifacts = uniqueArtifactsByUrl([
...publishArtifacts,
...confirmedArtifacts,
...verifiedArtifacts,
]);
await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts,
artifacts: deliveryArtifacts,
reply,
openid: inbound.fromUserName,
user,
@@ -2569,6 +2691,27 @@ export function createWechatMpService({
imagePolicy,
notifyFailure: false,
});
if (deliveryArtifacts.length > 0) {
publishArtifacts = deliveryArtifacts;
} else {
const recovered = await resolveHtmlPublishArtifacts({
reply,
intent,
requestStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: true,
htmlDeliveryAuthority,
});
publishArtifacts = uniqueArtifactsByUrl([
...selectSendableHtmlArtifacts({
verifiedArtifacts: recovered.verifiedArtifacts,
confirmedArtifacts: recovered.confirmedArtifacts,
}),
...recovered.confirmedArtifacts,
]);
}
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
@@ -2657,6 +2800,21 @@ export function createWechatMpService({
sessionId
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
if (mayBeStaleSession) {
if (sessionPageContinuation) {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
});
const text =
'我没能可靠确认“做成页面”指的是哪段内容,已经停止沿用旧主题。请把要做成页面的主题或原文再发一次。';
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP contextual follow-up notice failed:', sendErr);
}
const contextualError = markWechatUserNotified(new Error(text));
contextualError.wechatAgentSessionId = sessionId;
throw contextualError;
}
route = await ensureWechatAgentSession({
userId: user.userId,
openid: inbound.fromUserName,
@@ -2673,8 +2831,12 @@ export function createWechatMpService({
? buildPageGenerateAgentPrompt(intent, {
wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
preferImmediateContext: sessionPageContinuation,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
: buildWechatAgentPrompt(intent, {
imagePolicy,
preferSessionPageContinuation: sessionPageContinuation,
});
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -2690,6 +2852,7 @@ export function createWechatMpService({
userId: user.userId,
sessionId,
userMessage,
preserveAgentPrompt: sessionPageContinuation,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
@@ -2812,8 +2975,13 @@ export function createWechatMpService({
forcePageData: isPageDataRequest,
});
if (wechatIntent.kind === 'page.generate') {
const deliveryArtifacts = uniqueArtifactsByUrl([
...publishArtifacts,
...confirmedArtifacts,
...verifiedArtifacts,
]);
await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts,
artifacts: deliveryArtifacts,
reply,
openid: inbound.fromUserName,
user,
@@ -2821,6 +2989,27 @@ export function createWechatMpService({
sessionId,
imagePolicy,
});
if (deliveryArtifacts.length > 0) {
publishArtifacts = deliveryArtifacts;
} else {
const recovered = await resolveHtmlPublishArtifacts({
reply,
intent,
requestStartedAt: retryStartedAt,
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: true,
htmlDeliveryAuthority,
});
publishArtifacts = uniqueArtifactsByUrl([
...selectSendableHtmlArtifacts({
verifiedArtifacts: recovered.verifiedArtifacts,
confirmedArtifacts: recovered.confirmedArtifacts,
}),
...recovered.confirmedArtifacts,
]);
}
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
+268 -61
View File
@@ -16,7 +16,11 @@ import {
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
isWechatImmediateContextFollowup,
isWechatSessionPageContinuation,
shouldDeliverWechatHtmlArtifacts,
isWechatPageDataTask,
shouldRotateUnlinkedWechatRoute,
shouldRetryHtmlGenerationReply,
shouldForceNewWechatAgentSession,
splitWechatText,
@@ -27,6 +31,9 @@ import {
WECHAT_CUSTOMER_TEXT_MAX_BYTES,
} from './wechat-mp.mjs';
import { createChatIntentRouter } from './chat-intent-router.mjs';
import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
import { buildPageGenerateAgentPrompt, buildImmediateContextPageRepairPrompt } from './wechat/prompts/page-generate.mjs';
import { resolveWechatImageGenerationPolicy, WECHAT_PAGE_THUMBNAIL_MODE } from './wechat/image-generation-policy.mjs';
import {
ensureWechatFreshPageThumbnailsAtWorkspace,
prepareWechatHtmlDeliveryAtWorkspace,
@@ -333,6 +340,168 @@ test('Page Data requests always rotate away from an existing WeChat route', () =
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true);
});
test('WeChat immediate-context page guard does not match complete page requests', () => {
const pageIntent = { kind: 'page.generate' };
assert.equal(isWechatImmediateContextFollowup(pageIntent, '帮我做成页面吧'), true);
assert.equal(isWechatImmediateContextFollowup(pageIntent, '把刚才的诗做成页面'), true);
assert.equal(isWechatImmediateContextFollowup(pageIntent, '生成一个精美的 HTML 页面'), true);
assert.equal(isWechatImmediateContextFollowup(pageIntent, '生成一个HTML页面'), true);
assert.equal(isWechatImmediateContextFollowup(pageIntent, '生成一个上海旅游攻略页面'), false);
assert.equal(isWechatImmediateContextFollowup(pageIntent, '帮我做一个上海旅游页面'), false);
assert.equal(
isWechatImmediateContextFollowup({ kind: 'chat.general' }, '帮我做成页面吧'),
false,
);
const guardedPrompt = buildPageGenerateAgentPrompt(
{ agentText: '帮我做成页面吧' },
{ preferImmediateContext: true },
);
const ordinaryPrompt = buildPageGenerateAgentPrompt({
agentText: '帮我做一个上海旅游页面',
});
assert.match(guardedPrompt, /即时上下文优先/);
assert.match(guardedPrompt, /禁止用长期记忆、历史偏好或旧任务替换主题/);
assert.match(guardedPrompt, /write_file 硬门槛/);
assert.match(guardedPrompt, /禁止只调用 load_skill、todo_write 或 generate_image 就回复/);
assert.doesNotMatch(ordinaryPrompt, /即时上下文优先/);
assert.doesNotMatch(ordinaryPrompt, /write_file 硬门槛/);
});
test('WeChat session page continuation covers retry, edit, and poem edits', () => {
const retry = classifyWechatIntent({ msgType: 'text', agentText: '重试上次页面' });
assert.equal(retry.kind, 'page.generate');
assert.equal(isWechatSessionPageContinuation(retry, '重试上次页面'), true);
const edit = classifyWechatIntent({ msgType: 'text', agentText: '把页面标题改成《七月清晨》' });
assert.equal(edit.kind, 'page.generate');
assert.equal(isWechatSessionPageContinuation(edit, edit.topic), true);
const poemEdit = classifyWechatIntent({ msgType: 'text', agentText: '把诗里第三段改长一点' });
assert.equal(poemEdit.kind, 'chat.general');
assert.equal(isWechatSessionPageContinuation(poemEdit, '把诗里第三段改长一点'), true);
assert.equal(
shouldDeliverWechatHtmlArtifacts(poemEdit, { agentText: '把诗里第三段改长一点' }),
true,
);
});
test('WeChat immediate-context page skips fresh thumbnail requirement', () => {
const policy = resolveWechatImageGenerationPolicy({
text: '把刚才的诗做成页面',
isPageGenerate: true,
requireFreshPageThumbnail: false,
});
assert.equal(policy.pageThumbnailMode, WECHAT_PAGE_THUMBNAIL_MODE.AUTO);
const prompt = buildPageGenerateAgentPrompt(
{ agentText: '把刚才的诗做成页面' },
{ preferImmediateContext: true, imagePolicy: policy },
);
assert.doesNotMatch(prompt, /服务号页面新缩略图硬要求/);
});
test('WeChat immediate-context page repair prompt requires write_file', () => {
const repairPrompt = buildImmediateContextPageRepairPrompt({ agentText: '把刚才的诗做成页面' });
assert.match(repairPrompt, /未 write_file 落盘 HTML/);
assert.match(repairPrompt, /禁止再次只 generate_image/);
});
test('WeChat immediate-context page retains an unlinked route with adjacent snapshot', async () => {
const clearedRoutes = [];
const startedSessions = [];
let activeRoute = {
agentSessionId: 'session-poem',
updatedAt: Date.now() - 120_000,
};
const service = createBoundWechatService({
config: {
sessionMessageRotateCount: 0,
sessionIdleRotateMs: 0,
},
userAuth: {
async getWechatAgentRoute() {
return activeRoute;
},
async clearWechatAgentRoute(_appId, openid) {
clearedRoutes.push(openid);
activeRoute = null;
},
async countWechatAgentSessionMessages() {
return 0;
},
async getWechatAgentSessionSnapshotMessageCount() {
return 5;
},
async touchWechatAgentRoute(_appId, _openid, updatedAt) {
activeRoute = { ...activeRoute, updatedAt };
},
},
startAgentSession: async (sessionId) => {
startedSessions.push(sessionId);
return sessionId;
},
sessionApiFetch: async (pathname) => {
if (String(pathname).includes('/tools')) {
return new Response(JSON.stringify({ tools: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ conversation: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
submitSessionReply: async () => ({
ok: true,
conversation: [{ role: 'assistant', content: [{ type: 'text', text: 'ok' }] }],
}),
});
const timestamp = '1710000000';
const nonce = 'nonce-immediate-context-route';
await service.handleInboundMessage(
inboundXml({ content: '把刚才的诗做成页面' }),
{
timestamp,
nonce,
signature: signatureFor('token', timestamp, nonce),
},
);
assert.equal(clearedRoutes.length, 0);
assert.equal(startedSessions.length, 0);
});
test('WeChat only rotates an aged route with conversation but no linked inbound message', () => {
const now = 200_000;
assert.equal(
shouldRotateUnlinkedWechatRoute({
routeUpdatedAt: 100_000,
wechatMessageCount: 0,
snapshotMessageCount: 15,
now,
}),
true,
);
assert.equal(
shouldRotateUnlinkedWechatRoute({
routeUpdatedAt: 100_000,
wechatMessageCount: 1,
snapshotMessageCount: 15,
now,
}),
false,
);
assert.equal(
shouldRotateUnlinkedWechatRoute({
routeUpdatedAt: 180_000,
wechatMessageCount: 0,
snapshotMessageCount: 15,
now,
}),
false,
);
});
test('WeChat session reset acknowledges without sending the control text to Agent', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -4540,6 +4709,97 @@ test('wechat mp injects episodic recall context before submitting the Agent repl
assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/);
});
test('wechat immediate-context page keeps memory while preserving the guarded page prompt', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const submitCalls = [];
const memoryCalls = [];
const chatIntentRouter = createChatIntentRouter({
env: {
MEMORY_AGENT_RESOLVE_ENABLED: '1',
MEMORY_AGENT_INJECTION_MODE: 'canary',
MEMORY_AGENT_CANARY_USER_IDS: 'user-canary',
MEMORY_AGENT_RESOLVE_TIMEOUT_MS: '200',
},
memoryV2: {
async resolve(input) {
memoryCalls.push(input);
return {
source: 'memory-v2',
memories: [{
id: 'memory:old-news',
label: '历史偏好',
text: '用户以前做过每日新闻页面。',
}],
};
},
},
});
const service = createBoundWechatService({
token,
chatIntentRouter,
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-canary', status: 'active', nickname: '唐' };
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"请补充要制作的内容。"}]}}\n\n',
'data: {"type":"Finish"}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected api path: ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const result = await service.handleInboundMessage(
inboundXml({ content: '帮我做成页面吧' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
assert.equal(memoryCalls.length, 1);
assert.equal(submitCalls.length, 1);
const submitted = submitCalls[0].userMessage;
assert.equal(submitted.metadata.displayText, '帮我做成页面吧');
assert.match(submitted.content[0].text, /\[Memory Context\]/);
assert.match(submitted.content[0].text, /用户以前做过每日新闻页面/);
assert.match(submitted.content[0].text, /微信服务号 · 页面生成任务/);
assert.match(submitted.content[0].text, /即时上下文优先/);
assert.match(submitted.content[0].text, /用户需求:帮我做成页面吧/);
});
test('wechat mp serializes image and follow-up text and reattaches recent image', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -5724,42 +5984,23 @@ test('wechat mp page delivery repairs one unambiguous fresh thumbnail missing fr
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
});
test('wechat mp retries a page in a new session before reporting a missing fresh thumbnail', async () => {
test('wechat mp delivers page via workspace thumbnail when fresh image_make is missing', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-retry-');
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-fallback-');
const htmlPath = path.join(workspaceRoot, 'public', 'retry.html');
const generated = {
ok: true,
jobId: 'job-fresh-page-retry',
source: { mimeType: 'image/webp', width: 1280, height: 720 },
asset: {
id: 'asset-fresh-page-retry',
htmlSrc: 'images/retry-cover.webp',
publicUrl: 'https://example.com/MindSpace/user-1/public/images/retry-cover.webp',
workspaceRelativePath: 'public/images/retry-cover.webp',
},
};
const firstHtml = previewReadyPageHtml({
title: 'Retry',
subtitle: '首次没有新缩略图',
cover: 'images/missing-cover.webp',
});
const retryHtml = previewReadyPageHtml({
title: 'Retry',
subtitle: '重试生成新缩略图',
cover: generated.asset.htmlSrc,
});
const pageImage = await sharp({
create: { width: 64, height: 64, channels: 3, background: '#cc6633' },
}).webp().toBuffer();
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
const wechatPayloads = [];
let routeCleared = false;
let startedSessions = 0;
const replyEvents = ({ requestId, html, includeImage = false }) => [
const replyEvents = ({ requestId, html }) => [
eventFrame({
type: 'Message',
request_id: requestId,
@@ -5773,25 +6014,6 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
type: 'toolRequest',
toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } },
},
...(includeImage
? [
{
id: `${requestId}-image`,
type: 'toolRequest',
toolCall: {
value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } },
},
},
{
id: `${requestId}-image`,
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
},
},
]
: []),
{
id: `${requestId}-write`,
type: 'toolRequest',
@@ -5861,24 +6083,12 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
}
if (pathname === `/sessions/${sessionId}/reply`) {
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
if (sessionId === 'session-1') {
fs.writeFileSync(htmlPath, firstHtml, 'utf8');
} else {
fs.writeFileSync(htmlPath, retryHtml, 'utf8');
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
}
fs.writeFileSync(htmlPath, firstHtml, 'utf8');
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/sessions/session-1/events') {
return new Response(
replyEvents({ requestId: 'req-thumbnail-first', html: firstHtml }),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-2/events') {
return new Response(
replyEvents({ requestId: 'req-thumbnail-retry', html: retryHtml, includeImage: true }),
replyEvents({ requestId: 'req-thumbnail-fallback', html: firstHtml }),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
@@ -5903,10 +6113,7 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-thumbnail-first', 'req-thumbnail-retry'];
return () => ids.shift() ?? 'req-thumbnail-retry';
})();
crypto.randomUUID = () => 'req-thumbnail-fallback';
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }),
@@ -5920,8 +6127,8 @@ test('wechat mp retries a page in a new session before reporting a missing fresh
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
assert.equal(routeCleared, true);
assert.equal(startedSessions, 1);
assert.equal(routeCleared, false);
assert.equal(startedSessions, 0);
assert.equal(wechatPayloads.length, 1);
assert.equal(wechatPayloads[0].msgtype, 'text');
assert.match(wechatPayloads[0].text.content, /retry\.html/);
+69
View File
@@ -0,0 +1,69 @@
/** WeChat short follow-ups that continue an in-session page or poem without a full new topic. */
const PAGE_DECORATORS =
'(?:精美(?:的)?|漂亮(?:的)?|好看(?:的)?|简约(?:的)?|优雅(?:的)?)';
export const PAGE_RETRY_PATTERN =
/^(?:(?:请|麻烦|可以|能不能)\s*)?(?:(?:帮我|帮忙|给我)\s*)?重试(?:上次|上一个)?页面[。.!\s]*$/iu;
export const PAGE_EDIT_PATTERN =
/^(?:(?:请|麻烦|可以|能不能)\s*)?(?:(?:帮我|帮忙|给我)\s*)?(?:(?:把|将)\s*)?(?:(?:这个|刚才(?:的)?|上面(?:的)?|前面(?:的)?|上一条|它|刚才(?:生成|做|弄)(?:的)?|上面(?:生成|做|弄)(?:的)?)\s*)?(?:的)?\s*(?:页面|网页|html)?\s*(?:的)?\s*(?:内容|标题|背景|样式|排版|封面|页脚|字体|颜色)?\s*(?:改|修改|调整|更新|换|优化|美化)/iu;
export const PAGE_IMMEDIATE_CREATE_PATTERN =
new RegExp(
'^'
+ '(?:(?:请|麻烦|可以|能不能)\\s*)?'
+ '(?:(?:帮我|帮忙|给我)\\s*)?'
+ '(?:(?:把\\s*)?(?:这个|刚才(?:的)?(?:诗|文章|内容)?|上面(?:的)?|前面(?:的)?|上一条|它|这首(?:诗)?|这篇(?:文章)?|这段(?:内容)?)\\s*)?'
+ '(?:继续\\s*)?'
+ '(?:做|弄|改|转|写|排版|生成|制作)(?:成|为)?\\s*'
+ '(?:一个|个)?'
+ '(?:(?:\\s*'
+ PAGE_DECORATORS
+ '){1,3}\\s*(?:h5|html)|(?:\\s*(?:h5|html)))?'
+ '\\s*(?:页面|网页)(?:吧|呀|呢|一下)?[。.!\\s]*'
+ '$',
'iu',
);
export const PAGE_CONTENT_EDIT_PATTERN =
/^(?:(?:请|麻烦|可以|能不能)\s*)?(?:(?:帮我|帮忙|给我)\s*)?(?:(?:把|将)\s*)?(?:(?:这个|刚才(?:的)?|上面(?:的)?|前面(?:的)?|它|这首(?:诗)?|这篇(?:文章)?|这段(?:内容)?|诗(?:里|中)?)\s*)?(?:的)?\s*(?:[\p{L}\p{N}]{0,12}\s*)?(?:改|修改|调整|更新|换|加长|缩短|润色)/iu;
export function isWechatPageRetryText(text) {
const normalized = String(text ?? '').trim();
return Boolean(normalized && PAGE_RETRY_PATTERN.test(normalized));
}
export function isWechatPageEditText(text) {
const normalized = String(text ?? '').trim();
return Boolean(normalized && PAGE_EDIT_PATTERN.test(normalized));
}
export function isWechatContentEditFollowup(text) {
const normalized = String(text ?? '').trim();
return Boolean(normalized && PAGE_CONTENT_EDIT_PATTERN.test(normalized));
}
export function isWechatImmediateContextPageCreate(wechatIntent, text) {
if (wechatIntent?.kind !== 'page.generate') return false;
const normalized = String(text ?? '').trim();
return Boolean(normalized && PAGE_IMMEDIATE_CREATE_PATTERN.test(normalized));
}
export function isWechatSessionPageContinuation(wechatIntent, text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
if (isWechatPageRetryText(normalized)) return true;
if (isWechatImmediateContextPageCreate(wechatIntent, normalized)) return true;
if (wechatIntent?.kind === 'page.generate' && isWechatPageEditText(normalized)) return true;
if (wechatIntent?.kind === 'chat.general' && isWechatContentEditFollowup(normalized)) return true;
return false;
}
export function expectsWechatHtmlArtifactDelivery(wechatIntent, text) {
const normalized = String(text ?? '').trim();
return (
wechatIntent?.kind === 'page.generate'
|| isWechatSessionPageContinuation(wechatIntent, normalized)
);
}
+10 -1
View File
@@ -1,5 +1,10 @@
/** WeChat channel intent patterns — not shared with H5 chat-skills routing. */
import {
isWechatPageEditText,
isWechatPageRetryText,
} from './page-continuation.mjs';
export const PAGE_GENERATE_PATTERN =
/(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu;
@@ -25,7 +30,11 @@ export function isPageGenerateText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
if (PAGE_GENERATE_NEGATION_PATTERN.test(normalized)) return false;
return PAGE_GENERATE_PATTERN.test(normalized);
return (
PAGE_GENERATE_PATTERN.test(normalized)
|| isWechatPageRetryText(normalized)
|| isWechatPageEditText(normalized)
);
}
export function isTopicResetText(text) {
+14 -1
View File
@@ -8,7 +8,11 @@ import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
import { buildWechatStandaloneImageInstruction } from '../image-generation-policy.mjs';
export function buildWechatAgentPrompt(intent, { grantedSkills = [], imagePolicy = null } = {}) {
export function buildWechatAgentPrompt(intent, {
grantedSkills = [],
imagePolicy = null,
preferSessionPageContinuation = false,
} = {}) {
const msgType = String(intent?.msgType ?? 'text');
const agentText = intent?.agentText ?? intent?.content ?? '';
const autoSkillPrefix =
@@ -56,6 +60,14 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [], imagePolicy
'',
].join('\n')
: '';
const sessionPageContinuationHint = preferSessionPageContinuation
? [
'【会话页面续作】用户在修改本会话刚生成的页面或诗/文章内容。',
'必须读取当前会话最近 assistant 回复或已落盘的 public/*.html,用 edit_file 更新目标 HTML。',
'禁止切换到无关主题;没有 edit_file/write_file 落盘就不要声称已修改或发送链接。',
'',
].join('\n')
: '';
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone });
const scheduleAssistantHint = shouldUseScheduleAssistant(agentText)
@@ -156,6 +168,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [], imagePolicy
if (docxDownloadHint) lines.push(docxDownloadHint);
if (pageDataCollectHint) lines.push(pageDataCollectHint);
if (pageDataRepairHint) lines.push(pageDataRepairHint);
if (sessionPageContinuationHint) lines.push(sessionPageContinuationHint);
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
if (imageGenerationHint) lines.push(imageGenerationHint);
+34 -1
View File
@@ -8,7 +8,14 @@ import { buildWechatPageImageInstruction } from '../image-generation-policy.mjs'
/**
* Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix.
*/
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imagePolicy = null } = {}) {
export function buildPageGenerateAgentPrompt(
intent,
{
wantsDocx = false,
imagePolicy = null,
preferImmediateContext = false,
} = {},
) {
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
const pageDataTask = isPageDataIntent(topic) || isPageDataDevIntent(topic);
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
@@ -22,6 +29,18 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP
const imageBlock = buildWechatPageImageInstruction(imagePolicy, {
sourceMessageId: intent?.msgId,
});
const immediateContextBlock = preferImmediateContext
? [
'【即时上下文优先】当前需求是对本会话紧邻内容的续作(做成页面、修改页面、重试上次页面或改诗/文章内容)。',
'“这个/刚才/做成页面/修改页面/重试上次页面”等指代只能解析为当前会话最近一条 assistant 回复或最近落盘的 public/*.html,禁止用长期记忆、历史偏好或旧任务替换主题。',
'如果当前会话没有可辨识的紧邻内容,必须请用户补充主题,禁止猜测并生成其他页面。',
'【write_file 硬门槛】必须先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入或更新 `public/*.html` 后才能结束。',
'修改已有页面时必须 edit_file 更新同一 HTML 或明确的新文件,禁止只回复文字说明。',
'禁止只调用 load_skill、todo_write 或 generate_image 就回复;没有 write_file/edit_file 落盘 HTML 视为未完成。',
'缩略图/配图可选:优先完成 HTML 落盘与 mindspace-cover;没有 hero 图时 cover 可用 CSS 渐变或 omit,不要卡在 generate_image。',
'',
].join('\n')
: '';
const coverExample = imageBlock
? '<本轮 generate_image 返回的 asset.htmlSrc>'
: 'assets/hero.jpg';
@@ -42,6 +61,7 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP
'',
docxBlock,
imageBlock,
immediateContextBlock,
pageDataBlock,
'步骤(必须全部完成):',
pageDataTask
@@ -68,6 +88,19 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP
.join('\n');
}
export function buildImmediateContextPageRepairPrompt(intent) {
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
return [
'【即时上下文页面补写】上一轮页面生成未完成:已 load_skill 但未 write_file 落盘 HTML。',
'请立即读取本会话最近一条 assistant 回复中的完整正文,将其排版为完整静态页。',
'步骤:确认 static-page-publish 已加载 → 用 write_file 写入 public/*.html(含 mindspace-cover、platform-brand 页脚)→ 确认落盘后再回复唯一正式链接。',
'禁止再次只 generate_image 或只回复文字;本轮必须完成 write_file。',
topic ? `用户原话:${topic}` : '',
]
.filter(Boolean)
.join('\n');
}
export function buildPagePublishFailureText({
missingSharePreview = false,
missingPlatformBrand = false,
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { classifyWechatIntent } from './intent/classifier.mjs';
import {
isWechatSessionPageContinuation,
isWechatPageRetryText,
isWechatPageEditText,
isWechatContentEditFollowup,
} from './intent/page-continuation.mjs';
test('WeChat page continuation detects retry and edit phrasings', () => {
assert.equal(isWechatPageRetryText('重试上次页面'), true);
assert.equal(isWechatPageEditText('修改刚才生成的页面,背景改成浅蓝色'), true);
assert.equal(isWechatContentEditFollowup('把诗里第三段改长一点'), true);
const retryIntent = classifyWechatIntent({ msgType: 'text', agentText: '重试上次页面' });
assert.equal(retryIntent.kind, 'page.generate');
assert.equal(isWechatSessionPageContinuation(retryIntent, '重试上次页面'), true);
const editIntent = classifyWechatIntent({
msgType: 'text',
agentText: '把页面标题改成《七月清晨》',
});
assert.equal(editIntent.kind, 'page.generate');
assert.equal(isWechatSessionPageContinuation(editIntent, editIntent.topic), true);
const poemEdit = classifyWechatIntent({ msgType: 'text', agentText: '把诗里第三段改长一点' });
assert.equal(poemEdit.kind, 'chat.general');
assert.equal(isWechatSessionPageContinuation(poemEdit, '把诗里第三段改长一点'), true);
});
test('WeChat page continuation rejects full new-topic page requests', () => {
const intent = classifyWechatIntent({
msgType: 'text',
agentText: '帮我做一个上海旅游攻略页面',
});
assert.equal(intent.kind, 'page.generate');
assert.equal(isWechatSessionPageContinuation(intent, intent.topic), false);
});