Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 371900bae5 | |||
| b553107817 | |||
| 065b1588b7 | |||
| 7eedda092d | |||
| 6307f974cf | |||
| 491a62aed6 | |||
| e1ad094f93 | |||
| b10f0a7600 | |||
| 31cfdf7a94 | |||
| 9b68dc6b77 |
@@ -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 / OpenHands(Colima 迁出后走 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 worker(memind-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
|
||||
|
||||
@@ -32,8 +32,17 @@ export function stripMindSpaceContextPrefix(text) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const ASSISTANT_DELIVERABLE_RE =
|
||||
/\[[^\]]+\]\(https?:\/\/[^)]+\)|https?:\/\/(?:m\.)?[^/\s]*tkmind\.(?:cn|ai)\/MindSpace\//i;
|
||||
/** MindSpace 交付链接:生产域名与本地 dev(127.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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 或字段权限。
|
||||
|
||||
运行时路径必须按语义区分:
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createMindSpaceLocalRuntimeServices({
|
||||
syncWorkspaceAssets,
|
||||
conversationArtifactService,
|
||||
workspacePageDeliveryService,
|
||||
getQuotaPool: () => pool,
|
||||
});
|
||||
const cleanupService = createCleanupService(pool, {
|
||||
storageRoot: runtime.storageRoot,
|
||||
|
||||
+5
-7
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+22
@@ -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}" "$@"
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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-tests(ALLOW_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 report(ALLOW_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
|
||||
|
||||
@@ -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);
|
||||
@@ -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 8081;5173 仅 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),便于用户在「页面数据」面板排查
|
||||
|
||||
@@ -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
@@ -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
@@ -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/);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user